Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f71d678c2d | ||
|
|
f8e1022a41 | ||
|
|
2e5996463d |
@@ -0,0 +1,315 @@
|
||||
#!/usr/bin/env python3
|
||||
"""HTTP API for AIOS external-agent online task distribution.
|
||||
|
||||
The service is intended to bind to loopback and be published through the
|
||||
existing TLS reverse proxy. Control operations use a server-managed control
|
||||
Bearer token. Agents receive individual Bearer tokens at registration; only
|
||||
token hashes are persisted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any, Mapping
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from aios_agent_task_protocol import TaskProtocolError, TaskStore
|
||||
|
||||
|
||||
MAX_BODY = 1024 * 1024
|
||||
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class APIConfig:
|
||||
db_path: Path
|
||||
control_token_path: Path
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 18940
|
||||
token_pepper: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "APIConfig":
|
||||
return cls(
|
||||
db_path=Path(os.getenv(
|
||||
"AIOS_AGENT_TASK_DB",
|
||||
"/var/lib/aios-agent-task/state.sqlite3",
|
||||
)),
|
||||
control_token_path=Path(os.getenv(
|
||||
"AIOS_AGENT_TASK_CONTROL_TOKEN_FILE",
|
||||
"/run/credentials/aios-agent-task-api.service/control_token",
|
||||
)),
|
||||
host=os.getenv("AIOS_AGENT_TASK_HOST", "127.0.0.1"),
|
||||
port=int(os.getenv("AIOS_AGENT_TASK_PORT", "18940")),
|
||||
token_pepper=os.getenv("AIOS_AGENT_TASK_TOKEN_PEPPER", ""),
|
||||
)
|
||||
|
||||
|
||||
class AgentTaskApplication:
|
||||
def __init__(self, config: APIConfig) -> None:
|
||||
self.config = config
|
||||
self.store = TaskStore(
|
||||
config.db_path,
|
||||
token_pepper=config.token_pepper,
|
||||
)
|
||||
|
||||
def _control_token(self) -> str:
|
||||
path = self.config.control_token_path
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise TaskProtocolError("CONTROL_CREDENTIAL_UNAVAILABLE", 503)
|
||||
value = path.read_text(encoding="utf-8").strip()
|
||||
if len(value) < 32:
|
||||
raise TaskProtocolError("CONTROL_CREDENTIAL_INVALID", 503)
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _bearer(headers: Mapping[str, str]) -> str:
|
||||
value = str(headers.get("Authorization") or "")
|
||||
if not value.startswith("Bearer "):
|
||||
return ""
|
||||
return value[7:].strip()
|
||||
|
||||
def authenticate_control(self, headers: Mapping[str, str]) -> None:
|
||||
supplied = self._bearer(headers)
|
||||
expected = self._control_token()
|
||||
if not supplied or not hmac.compare_digest(supplied, expected):
|
||||
raise TaskProtocolError("UNAUTHORIZED", 401)
|
||||
|
||||
def authenticate_agent(self, headers: Mapping[str, str]) -> dict[str, Any]:
|
||||
return self.store.authenticate_agent(self._bearer(headers))
|
||||
|
||||
def principal(self, headers: Mapping[str, str]) -> tuple[str, dict[str, Any] | None]:
|
||||
supplied = self._bearer(headers)
|
||||
if not supplied:
|
||||
raise TaskProtocolError("UNAUTHORIZED", 401)
|
||||
try:
|
||||
expected = self._control_token()
|
||||
except TaskProtocolError:
|
||||
expected = ""
|
||||
if expected and hmac.compare_digest(supplied, expected):
|
||||
return "control", None
|
||||
return "agent", self.store.authenticate_agent(supplied)
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"schema": "aios.agent-task.v1",
|
||||
"task_states": [
|
||||
"QUEUED", "CLAIMED", "RUNNING", "VERIFYING",
|
||||
"SUCCEEDED", "FAILED", "EXPIRED", "CANCELLED",
|
||||
],
|
||||
"lease_recovery": True,
|
||||
"atomic_claim": True,
|
||||
"idempotent_result_submission": True,
|
||||
"server_side_verification_required": True,
|
||||
"plaintext_credentials_persisted": False,
|
||||
}
|
||||
|
||||
def register_agent(self, headers: Mapping[str, str], body: Mapping[str, Any]) -> dict[str, Any]:
|
||||
self.authenticate_control(headers)
|
||||
return self.store.register_agent(
|
||||
agent_id=str(body.get("agent_id") or ""),
|
||||
agent_type=str(body.get("agent_type") or ""),
|
||||
agent_version=str(body.get("agent_version") or ""),
|
||||
capabilities=body.get("capabilities") or [],
|
||||
metadata=body.get("metadata") if isinstance(body.get("metadata"), Mapping) else {},
|
||||
)
|
||||
|
||||
def create_task(self, headers: Mapping[str, str], body: Mapping[str, Any]) -> dict[str, Any]:
|
||||
self.authenticate_control(headers)
|
||||
return self.store.create_task(
|
||||
task_id=str(body.get("task_id") or "") or None,
|
||||
goal=str(body.get("goal") or ""),
|
||||
instructions=str(body.get("instructions") or ""),
|
||||
success_criteria=body.get("success_criteria"),
|
||||
required_capabilities=body.get("required_capabilities") or [],
|
||||
constraints=body.get("constraints") if isinstance(body.get("constraints"), Mapping) else {},
|
||||
metadata=body.get("metadata") if isinstance(body.get("metadata"), Mapping) else {},
|
||||
deadline=str(body.get("deadline") or "") or None,
|
||||
priority=body.get("priority", 0),
|
||||
max_attempts=body.get("max_attempts", 3),
|
||||
idempotency_key=str(body.get("idempotency_key") or ""),
|
||||
)
|
||||
|
||||
def poll(self, headers: Mapping[str, str], body: Mapping[str, Any]) -> dict[str, Any]:
|
||||
agent = self.authenticate_agent(headers)
|
||||
tasks = self.store.poll_tasks(
|
||||
agent_id=agent["agent_id"],
|
||||
limit=int(body.get("limit", 20)),
|
||||
)
|
||||
return {"agent_id": agent["agent_id"], "tasks": tasks}
|
||||
|
||||
def claim(self, task_id: str, headers: Mapping[str, str], body: Mapping[str, Any]) -> dict[str, Any]:
|
||||
agent = self.authenticate_agent(headers)
|
||||
return self.store.claim_task(
|
||||
task_id=task_id,
|
||||
agent_id=agent["agent_id"],
|
||||
lease_seconds=int(body.get("lease_seconds", 300)),
|
||||
)
|
||||
|
||||
def heartbeat(self, task_id: str, headers: Mapping[str, str], body: Mapping[str, Any]) -> dict[str, Any]:
|
||||
agent = self.authenticate_agent(headers)
|
||||
return self.store.heartbeat(
|
||||
task_id=task_id,
|
||||
agent_id=agent["agent_id"],
|
||||
lease_token=str(body.get("lease_token") or ""),
|
||||
progress=body.get("progress") if isinstance(body.get("progress"), Mapping) else {},
|
||||
lease_seconds=int(body.get("lease_seconds", 300)),
|
||||
)
|
||||
|
||||
def result(self, task_id: str, headers: Mapping[str, str], body: Mapping[str, Any]) -> dict[str, Any]:
|
||||
agent = self.authenticate_agent(headers)
|
||||
result = body.get("result")
|
||||
if not isinstance(result, Mapping):
|
||||
raise TaskProtocolError("RESULT_INVALID", 400)
|
||||
return self.store.submit_result(
|
||||
task_id=task_id,
|
||||
agent_id=agent["agent_id"],
|
||||
lease_token=str(body.get("lease_token") or ""),
|
||||
idempotency_key=str(body.get("idempotency_key") or ""),
|
||||
result=result,
|
||||
)
|
||||
|
||||
def verify(self, task_id: str, headers: Mapping[str, str], body: Mapping[str, Any]) -> dict[str, Any]:
|
||||
self.authenticate_control(headers)
|
||||
return self.store.verify_task(
|
||||
task_id=task_id,
|
||||
decision=str(body.get("decision") or ""),
|
||||
verification=body.get("verification") if isinstance(body.get("verification"), Mapping) else {},
|
||||
)
|
||||
|
||||
def cancel(self, task_id: str, headers: Mapping[str, str], body: Mapping[str, Any]) -> dict[str, Any]:
|
||||
self.authenticate_control(headers)
|
||||
return self.store.cancel_task(task_id=task_id, reason=str(body.get("reason") or ""))
|
||||
|
||||
def get_task(self, task_id: str, headers: Mapping[str, str]) -> dict[str, Any]:
|
||||
kind, agent = self.principal(headers)
|
||||
task = self.store.get_task(task_id)
|
||||
if kind == "agent" and task.get("claimed_by") != agent["agent_id"]:
|
||||
raise TaskProtocolError("FORBIDDEN", 403)
|
||||
return task
|
||||
|
||||
def events(self, task_id: str, headers: Mapping[str, str]) -> dict[str, Any]:
|
||||
self.authenticate_control(headers)
|
||||
return {"task_id": task_id, "events": self.store.get_events(task_id)}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
app: AgentTaskApplication | None = None
|
||||
server_version = "AIOSAgentTaskAPI/1"
|
||||
|
||||
def log_message(self, _format: str, *_args: Any) -> None:
|
||||
return
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {key: value for key, value in self.headers.items()}
|
||||
|
||||
def _json(self, value: Any, status: int = 200) -> None:
|
||||
payload = json.dumps(value, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def _body(self) -> dict[str, Any]:
|
||||
try:
|
||||
size = int(self.headers.get("Content-Length", "0"))
|
||||
except ValueError as exc:
|
||||
raise TaskProtocolError("CONTENT_LENGTH_INVALID", 400) from exc
|
||||
if size < 0 or size > MAX_BODY:
|
||||
raise TaskProtocolError("REQUEST_TOO_LARGE", 413)
|
||||
raw = self.rfile.read(size) if size else b"{}"
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise TaskProtocolError("JSON_INVALID", 400) from exc
|
||||
if not isinstance(value, dict):
|
||||
raise TaskProtocolError("JSON_OBJECT_REQUIRED", 400)
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _task_route(path: str) -> tuple[str, str | None]:
|
||||
match = re.fullmatch(
|
||||
r"/v1/tasks/([A-Za-z0-9][A-Za-z0-9._:-]{2,179})(?:/(claim|heartbeat|result|verify|cancel|events))?",
|
||||
path,
|
||||
)
|
||||
if match:
|
||||
return match.group(1), match.group(2)
|
||||
return "", None
|
||||
|
||||
def _run(self, callback) -> None:
|
||||
try:
|
||||
callback()
|
||||
except TaskProtocolError as exc:
|
||||
self._json({"error": exc.code}, exc.http_status)
|
||||
except Exception as exc:
|
||||
self._json({"error": "INTERNAL_ERROR", "detail": type(exc).__name__}, 500)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
def action() -> None:
|
||||
if self.app is None:
|
||||
raise TaskProtocolError("SERVICE_UNAVAILABLE", 503)
|
||||
path = urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path == "/health":
|
||||
self._json(self.app.health())
|
||||
return
|
||||
task_id, suffix = self._task_route(path)
|
||||
if task_id and suffix is None:
|
||||
self._json(self.app.get_task(task_id, self._headers()))
|
||||
return
|
||||
if task_id and suffix == "events":
|
||||
self._json(self.app.events(task_id, self._headers()))
|
||||
return
|
||||
self._json({"error": "NOT_FOUND"}, 404)
|
||||
self._run(action)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
def action() -> None:
|
||||
if self.app is None:
|
||||
raise TaskProtocolError("SERVICE_UNAVAILABLE", 503)
|
||||
path = urlparse(self.path).path.rstrip("/") or "/"
|
||||
body = self._body()
|
||||
if path == "/v1/agents/register":
|
||||
self._json(self.app.register_agent(self._headers(), body), 201)
|
||||
return
|
||||
if path == "/v1/tasks":
|
||||
self._json(self.app.create_task(self._headers(), body), 201)
|
||||
return
|
||||
if path == "/v1/tasks/poll":
|
||||
self._json(self.app.poll(self._headers(), body))
|
||||
return
|
||||
task_id, suffix = self._task_route(path)
|
||||
if not task_id:
|
||||
self._json({"error": "NOT_FOUND"}, 404)
|
||||
return
|
||||
if suffix == "claim":
|
||||
self._json(self.app.claim(task_id, self._headers(), body))
|
||||
elif suffix == "heartbeat":
|
||||
self._json(self.app.heartbeat(task_id, self._headers(), body))
|
||||
elif suffix == "result":
|
||||
self._json(self.app.result(task_id, self._headers(), body))
|
||||
elif suffix == "verify":
|
||||
self._json(self.app.verify(task_id, self._headers(), body))
|
||||
elif suffix == "cancel":
|
||||
self._json(self.app.cancel(task_id, self._headers(), body))
|
||||
else:
|
||||
self._json({"error": "NOT_FOUND"}, 404)
|
||||
self._run(action)
|
||||
|
||||
|
||||
def serve(config: APIConfig | None = None) -> None:
|
||||
config = config or APIConfig.from_env()
|
||||
application = AgentTaskApplication(config)
|
||||
Handler.app = application
|
||||
ThreadingHTTPServer((config.host, config.port), Handler).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve()
|
||||
@@ -0,0 +1,827 @@
|
||||
#!/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
|
||||
|
||||
|
||||
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")
|
||||
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',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 = set(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 = set(json.loads(row["required_capabilities_json"]))
|
||||
if required.issubset(capabilities):
|
||||
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 = set(json.loads(row["required_capabilities_json"]))
|
||||
if not required.issubset(capabilities):
|
||||
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=?,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)
|
||||
]
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Private Unix-socket caller projection and rehydration client.
|
||||
|
||||
The broker response body is deliberately public metadata only. The short-lived
|
||||
credential projection is delivered in a private response header, consumed
|
||||
inside the model API process, and never returned by an HTTP API or persisted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
from typing import Any, Mapping
|
||||
|
||||
CALLER = "chatgpt-governed-implementer-singlehost"
|
||||
SCOPE = "release:candidate"
|
||||
CREDENTIAL_HEADER = "X-AIOS-Caller-Projection-Credential"
|
||||
|
||||
|
||||
class CallerProjectionError(RuntimeError):
|
||||
def __init__(self, code: str, status: int = 503) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectionResult:
|
||||
public: dict[str, Any]
|
||||
credential_loaded: bool
|
||||
|
||||
|
||||
class _UnixHTTPConnection(http.client.HTTPConnection):
|
||||
def __init__(self, socket_path: Path, timeout: float) -> None:
|
||||
super().__init__("localhost", timeout=timeout)
|
||||
self.socket_path = socket_path
|
||||
|
||||
def connect(self) -> None:
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.settimeout(self.timeout)
|
||||
try:
|
||||
sock.connect(str(self.socket_path))
|
||||
except Exception:
|
||||
sock.close()
|
||||
raise
|
||||
self.sock = sock
|
||||
|
||||
|
||||
class CallerProjectionClient:
|
||||
def __init__(self, socket_path: Path, *, timeout: float = 2.0) -> None:
|
||||
self.socket_path = Path(socket_path)
|
||||
self.timeout = timeout
|
||||
|
||||
@staticmethod
|
||||
def _idempotency_key(caller: str, scope: str) -> str:
|
||||
digest = hashlib.sha256((caller + "\0" + scope).encode()).hexdigest()
|
||||
return "caller-projection-" + digest[:32] + "-v1"
|
||||
|
||||
def rehydrate(
|
||||
self,
|
||||
caller: str,
|
||||
scope: str,
|
||||
request: Mapping[str, Any] | None = None,
|
||||
) -> ProjectionResult:
|
||||
if caller != CALLER:
|
||||
raise CallerProjectionError("CALLER_MISMATCH", 403)
|
||||
if scope != SCOPE:
|
||||
raise CallerProjectionError("SCOPE_MISMATCH", 403)
|
||||
body = {
|
||||
"caller": caller,
|
||||
"scope": scope,
|
||||
"idempotency_key": self._idempotency_key(caller, scope),
|
||||
"request": dict(request or {}),
|
||||
}
|
||||
connection = _UnixHTTPConnection(self.socket_path, self.timeout)
|
||||
try:
|
||||
connection.request(
|
||||
"POST",
|
||||
"/v1/project-caller",
|
||||
body=json.dumps(body, sort_keys=True, separators=(",", ":")).encode(),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"X-AIOS-Account": caller,
|
||||
},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
status = response.status
|
||||
raw = response.read()
|
||||
credential = response.getheader(CREDENTIAL_HEADER, "")
|
||||
except (OSError, TimeoutError, http.client.HTTPException) as exc:
|
||||
raise CallerProjectionError("PROJECTION_BROKER_UNAVAILABLE", 503) from exc
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise CallerProjectionError("PROJECTION_RESPONSE_INVALID", 503) from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise CallerProjectionError("PROJECTION_RESPONSE_INVALID", 503)
|
||||
if status != 200:
|
||||
code = str(value.get("error") or "PROJECTION_DENIED")
|
||||
raise CallerProjectionError(code, 403 if status < 500 else 503)
|
||||
if any(
|
||||
str(key).lower() in {
|
||||
"credential",
|
||||
"credential_material",
|
||||
"token",
|
||||
"bearer",
|
||||
"secret",
|
||||
"password",
|
||||
}
|
||||
for key in value
|
||||
):
|
||||
raise CallerProjectionError("PROJECTION_SECRET_FIELD_REJECTED", 503)
|
||||
if value.get("caller") != CALLER or value.get("scope") != SCOPE:
|
||||
raise CallerProjectionError("PROJECTION_BINDING_INVALID", 503)
|
||||
if value.get("projection_state") != "ACKED":
|
||||
raise CallerProjectionError("PROJECTION_STATE_INVALID", 503)
|
||||
if value.get("credential_state") != "REHYDRATED":
|
||||
raise CallerProjectionError("CREDENTIAL_REHYDRATION_INVALID", 503)
|
||||
if value.get("rehydration_state") not in {"GENERATED", "RECOVERED"}:
|
||||
raise CallerProjectionError("REHYDRATION_STATE_INVALID", 503)
|
||||
if value.get("effective_scopes") != [SCOPE]:
|
||||
raise CallerProjectionError("PROJECTION_SCOPE_INVALID", 503)
|
||||
if value.get("secret_exposed") is not False:
|
||||
raise CallerProjectionError("PROJECTION_SECRET_EXPOSURE", 503)
|
||||
if not isinstance(credential, str) or len(credential) < 32:
|
||||
raise CallerProjectionError("CREDENTIAL_REHYDRATION_MISSING", 503)
|
||||
return ProjectionResult(public=dict(value), credential_loaded=True)
|
||||
@@ -3,12 +3,6 @@ from __future__ import annotations
|
||||
import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, urllib.request, uuid
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from version_registry import (
|
||||
configured_authoritative_git_dir,
|
||||
configured_repository_identity,
|
||||
list_registry_versions,
|
||||
list_registry_versions_report,
|
||||
)
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
@@ -78,8 +72,8 @@ HOST = os.getenv('AIOS_GITOPS_HOST', '127.0.0.1')
|
||||
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
|
||||
OPENAPI = WS / 'docs' / 'gitops' / 'openapi-chatgpt-actions.yaml'
|
||||
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios.git')))
|
||||
AUTHORITATIVE_GIT_DIR = configured_authoritative_git_dir()
|
||||
REPOSITORY_IDENTITY = configured_repository_identity()
|
||||
AUTHORITATIVE_GIT_DIR = Path(os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR')) if os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR') else None
|
||||
REPOSITORY_IDENTITY = os.getenv('AIOS_GITOPS_REPOSITORY_IDENTITY', '')
|
||||
SILENT_RELEASE_STATE_ROOT = Path(os.getenv(
|
||||
'AIOS_SILENT_RELEASE_STATE_ROOT',
|
||||
'/var/lib/aios-gitops/silent-release',
|
||||
@@ -472,14 +466,6 @@ class H(BaseHTTPRequestHandler):
|
||||
))
|
||||
return self.sendj(data)
|
||||
if path in ('/api/ver','/api/versions','/ver'):
|
||||
registry_report = list_registry_versions_report(
|
||||
WS,
|
||||
baseline_file=Path(os.getenv('AIOS_GITOPS_BASELINE_AUTHORITY', '/var/lib/aios-gitops-runtime-anchor/baseline/current.json')),
|
||||
# Do not invent an authoritative repository when authority
|
||||
# discovery is disabled/omitted. Preserve the unavailable
|
||||
# authority path (503) instead of turning it into a 404.
|
||||
repo_git_dir=AUTHORITATIVE_GIT_DIR,
|
||||
)
|
||||
snapshot = repository_snapshot(
|
||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||
repository_identity=REPOSITORY_IDENTITY,
|
||||
@@ -488,8 +474,7 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj({
|
||||
'repository_available': False,
|
||||
'query_error': snapshot['query_error'],
|
||||
'registry_versions': registry_report['registry_versions'],
|
||||
**{key: registry_report[key] for key in ('effective_registry_versions', 'runtime_registry_versions', 'attested_projection_versions', 'attested_projection_version', 'source_registry_converged', 'runtime_registry_converged', 'runtime_bundle_contains_current_version', 'runtime_bundle_contains_version', 'registry_consistency_status')},
|
||||
'registry_versions': [p.name for p in (WS/'versions').glob('*')],
|
||||
'local_authoritative_tags': [],
|
||||
}, 503)
|
||||
try:
|
||||
@@ -498,11 +483,10 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj({
|
||||
'repository_available': False,
|
||||
'query_error': str(exc),
|
||||
'registry_versions': registry_report['registry_versions'],
|
||||
**{key: registry_report[key] for key in ('effective_registry_versions', 'runtime_registry_versions', 'attested_projection_versions', 'attested_projection_version', 'source_registry_converged', 'runtime_registry_converged', 'runtime_bundle_contains_current_version', 'runtime_bundle_contains_version', 'registry_consistency_status')},
|
||||
'registry_versions': [p.name for p in (WS/'versions').glob('*')],
|
||||
'local_authoritative_tags': [],
|
||||
}, 503)
|
||||
registry = registry_report['registry_versions']
|
||||
registry = [p.name for p in (WS/'versions').glob('*')]
|
||||
return self.sendj({
|
||||
'registry_versions': registry,
|
||||
'local_authoritative_tags': tags,
|
||||
@@ -512,7 +496,6 @@ class H(BaseHTTPRequestHandler):
|
||||
'versions_dir': registry,
|
||||
'head': snapshot['authoritative_head_oid'],
|
||||
'authoritative_head_oid': snapshot['authoritative_head_oid'],
|
||||
**{key: registry_report[key] for key in ('effective_registry_versions', 'runtime_registry_versions', 'attested_projection_versions', 'attested_projection_version', 'source_registry_converged', 'runtime_registry_converged', 'runtime_bundle_contains_current_version', 'runtime_bundle_contains_version', 'registry_consistency_status')},
|
||||
})
|
||||
if path in ('/api/diff','/diff'):
|
||||
frm=(q.get('from') or [''])[0]
|
||||
@@ -967,3 +950,4 @@ def main():
|
||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
||||
if __name__ == '__main__': main()
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ BRIDGE_OPERATIONS = (
|
||||
"acquireWriterLease",
|
||||
"commitFileGoverned",
|
||||
"commitFilesGoverned",
|
||||
"publishExistingBranchGoverned",
|
||||
"getGitOpsJob",
|
||||
"resumeGitOpsJob",
|
||||
"reconcileGitOpsJob",
|
||||
@@ -34,7 +33,6 @@ OPERATION_REQUIRED_FIELDS = {
|
||||
"acquireWriterLease": ("schema", "task_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "lease_seconds"),
|
||||
"commitFileGoverned": ("task_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "path", "content_base64", "message"),
|
||||
"commitFilesGoverned": ("task_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "files", "message"),
|
||||
"publishExistingBranchGoverned": ("repository", "branch", "expected_branch_oid", "owner_approval_reference", "idempotency_key"),
|
||||
"getGitOpsJob": ("job_id",),
|
||||
"resumeGitOpsJob": ("job_id", "expected_revision"),
|
||||
"reconcileGitOpsJob": ("job_id", "expected_revision"),
|
||||
@@ -155,7 +153,6 @@ class BridgeV2Adapter:
|
||||
def acquireWriterLease(self, request: Mapping[str, Any]) -> Any: return self._call("acquireWriterLease", request)
|
||||
def commitFileGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("commitFileGoverned", request)
|
||||
def commitFilesGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("commitFilesGoverned", request)
|
||||
def publishExistingBranchGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("publishExistingBranchGoverned", request)
|
||||
def getGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("getGitOpsJob", request)
|
||||
def resumeGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("resumeGitOpsJob", request)
|
||||
def reconcileGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("reconcileGitOpsJob", request)
|
||||
|
||||
@@ -19,7 +19,6 @@ from aios_continuation_lease import WriterLeaseRequestV2
|
||||
from aios_control_plane_snapshot import build_control_plane_snapshot
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobStore
|
||||
from aios_governed_task_bootstrap import BootstrapError, GovernedTaskBootstrap
|
||||
from aios_existing_branch_publisher import ExistingBranchPublisher
|
||||
from governed_production_release import build_executor_config
|
||||
|
||||
MAX_BODY = 2 * 1024 * 1024
|
||||
@@ -30,7 +29,6 @@ ROUTES = {
|
||||
"/v2/writer-lease": "acquireWriterLease",
|
||||
"/v2/commit-file": "commitFileGoverned",
|
||||
"/v2/commit-files": "commitFilesGoverned",
|
||||
"/v2/branch/publish": "publishExistingBranchGoverned",
|
||||
"/v2/job": "getGitOpsJob",
|
||||
"/v2/job/resume": "resumeGitOpsJob",
|
||||
"/v2/job/reconcile": "reconcileGitOpsJob",
|
||||
@@ -152,36 +150,6 @@ class GovernedRuntime:
|
||||
executor.workspace.materialize_files(commit.files)
|
||||
return executor.execute(binding, commit)
|
||||
|
||||
def publish_existing_branch(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
expected = str(request["expected_branch_oid"])
|
||||
key = _safe("existing-branch-" + str(request["idempotency_key"]))
|
||||
worktree_root = self.config.state_root / "worktrees"
|
||||
worktree_path = worktree_root / key
|
||||
if not worktree_path.exists():
|
||||
self.authority.create_detached_worktree(expected, key, worktree_root)
|
||||
workspace = DetachedGitWorkspace(worktree_path)
|
||||
credential = ServerSideCredentialProjection(self.config.ssh_key_path, self.config.token_path)
|
||||
remote = GiteaContinuationRemote(
|
||||
workspace=worktree_path,
|
||||
credential=credential,
|
||||
remote_name=self.config.remote_name,
|
||||
repository=self.config.repository,
|
||||
api_base=self.config.api_base,
|
||||
)
|
||||
publisher = ExistingBranchPublisher(
|
||||
self.authority,
|
||||
remote,
|
||||
self.config.state_root / "existing-branch-publications",
|
||||
)
|
||||
projected = False
|
||||
try:
|
||||
credential.project()
|
||||
projected = True
|
||||
return publisher.publish(request)
|
||||
finally:
|
||||
if projected:
|
||||
credential.revoke()
|
||||
|
||||
def _controller_for_job(self, job_id: str) -> GitOpsJobController:
|
||||
for controller in self.controllers.values():
|
||||
try:
|
||||
@@ -377,7 +345,6 @@ class GovernedRuntime:
|
||||
"acquireWriterLease": self.acquire_lease,
|
||||
"commitFileGoverned": lambda request: self.commit_files({**request, "files": {request["path"]: request["content_base64"]}}),
|
||||
"commitFilesGoverned": self.commit_files,
|
||||
"publishExistingBranchGoverned": self.publish_existing_branch,
|
||||
"getGitOpsJob": lambda request: self.job(request, "get"),
|
||||
"resumeGitOpsJob": lambda request: self.job(request, "resume"),
|
||||
"reconcileGitOpsJob": lambda request: self.job(request, "reconcile"),
|
||||
|
||||
@@ -258,7 +258,7 @@ class GiteaContinuationRemote:
|
||||
raise ContinuationExecutionError("GITEA_GIT_READBACK_INVALID")
|
||||
return oid
|
||||
|
||||
def push_exact(self, branch: str, expected: str | None, new: str) -> None:
|
||||
def push_exact(self, branch: str, expected: str, new: str) -> None:
|
||||
self._require_projected()
|
||||
self.repository_identity()
|
||||
if self.read_git_ref(branch) != expected:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,252 +0,0 @@
|
||||
"""Fail-closed governed publication of an existing Authority branch at an exact OID."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import asdict, dataclass
|
||||
from enum import Enum
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
BRANCH_RE = re.compile(r"^(?!refs/)(?!tags/)[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
||||
IDEMPOTENCY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$")
|
||||
|
||||
|
||||
class ExistingBranchPublicationError(RuntimeError):
|
||||
"""Stable, non-secret error for the governed existing-branch publisher."""
|
||||
|
||||
|
||||
class ExistingBranchPublicationConflict(ExistingBranchPublicationError):
|
||||
pass
|
||||
|
||||
|
||||
class ExistingBranchPublicationState(str, Enum):
|
||||
QUEUED = "QUEUED"
|
||||
PUBLISHING = "PUBLISHING"
|
||||
REMOTE_PUSH_PENDING = "REMOTE_PUSH_PENDING"
|
||||
RECONCILIATION_REQUIRED = "RECONCILIATION_REQUIRED"
|
||||
MANUAL_OWNER_REVIEW_REQUIRED = "MANUAL_OWNER_REVIEW_REQUIRED"
|
||||
COMPLETED = "COMPLETED"
|
||||
FAILED = "FAILED"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExistingBranchPublicationRequest:
|
||||
repository: str
|
||||
branch: str
|
||||
expected_branch_oid: str
|
||||
owner_approval_reference: str
|
||||
idempotency_key: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "ExistingBranchPublicationRequest":
|
||||
required = ("repository", "branch", "expected_branch_oid", "owner_approval_reference", "idempotency_key")
|
||||
if not isinstance(value, Mapping):
|
||||
raise ExistingBranchPublicationConflict("REQUEST_OBJECT_REQUIRED")
|
||||
if any(not isinstance(value.get(field), str) or not value[field].strip() for field in required):
|
||||
raise ExistingBranchPublicationConflict("REQUEST_FIELDS_MISSING")
|
||||
repository = value["repository"].strip()
|
||||
branch = value["branch"].strip()
|
||||
expected = value["expected_branch_oid"].strip().lower()
|
||||
approval = value["owner_approval_reference"].strip()
|
||||
idempotency = value["idempotency_key"].strip()
|
||||
if repository != "aios/aios":
|
||||
raise ExistingBranchPublicationConflict("REPOSITORY_IDENTITY_MISMATCH")
|
||||
if not BRANCH_RE.fullmatch(branch) or branch in {"main", "master"}:
|
||||
raise ExistingBranchPublicationConflict("BRANCH_NAMESPACE_REJECTED")
|
||||
if not OID_RE.fullmatch(expected):
|
||||
raise ExistingBranchPublicationConflict("EXPECTED_BRANCH_OID_INVALID")
|
||||
if len(approval) > 512:
|
||||
raise ExistingBranchPublicationConflict("OWNER_APPROVAL_REFERENCE_INVALID")
|
||||
if not IDEMPOTENCY_RE.fullmatch(idempotency):
|
||||
raise ExistingBranchPublicationConflict("IDEMPOTENCY_KEY_INVALID")
|
||||
return cls(repository, branch, expected, approval, idempotency)
|
||||
|
||||
@property
|
||||
def publication_job_id(self) -> str:
|
||||
return "publication-" + hashlib.sha256(self.idempotency_key.encode()).hexdigest()[:32]
|
||||
|
||||
@property
|
||||
def digest(self) -> str:
|
||||
raw = json.dumps(asdict(self), sort_keys=True, separators=(",", ":")).encode()
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
class ExistingBranchAuthority(Protocol):
|
||||
def read_ref(self, ref: str) -> str | None: ...
|
||||
|
||||
|
||||
class ExistingBranchRemote(Protocol):
|
||||
def repository_identity(self) -> str: ...
|
||||
def read_git_ref(self, branch: str) -> str | None: ...
|
||||
def read_api_branch(self, branch: str) -> str | None: ...
|
||||
def commit_visible(self, oid: str) -> bool: ...
|
||||
def push_exact(self, branch: str, expected: str | None, new: str) -> None: ...
|
||||
|
||||
|
||||
class ExistingBranchPublicationStore:
|
||||
def __init__(self, root: Path):
|
||||
self.root = Path(root)
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _path(self, request: ExistingBranchPublicationRequest) -> Path:
|
||||
return self.root / f"{request.publication_job_id}.json"
|
||||
|
||||
def load(self, request: ExistingBranchPublicationRequest) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(self._path(request).read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
if not isinstance(value, dict):
|
||||
raise ExistingBranchPublicationConflict("PUBLICATION_STATE_INVALID")
|
||||
return value
|
||||
|
||||
def record(self, request: ExistingBranchPublicationRequest, state: ExistingBranchPublicationState, **evidence: Any) -> dict[str, Any]:
|
||||
payload = {
|
||||
"schema": "aios.existing-branch-publication.v1",
|
||||
"publication_job_id": request.publication_job_id,
|
||||
"repository": request.repository,
|
||||
"branch": request.branch,
|
||||
"expected_branch_oid": request.expected_branch_oid,
|
||||
"owner_approval_reference": request.owner_approval_reference,
|
||||
"idempotency_key": request.idempotency_key,
|
||||
"request_digest": request.digest,
|
||||
"publication_state": state.value,
|
||||
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
**evidence,
|
||||
}
|
||||
path = self._path(request)
|
||||
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, ensure_ascii=False, sort_keys=True)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
return payload
|
||||
|
||||
|
||||
class ExistingBranchPublisher:
|
||||
"""Publish only the exact, already-authorized Authority branch tip."""
|
||||
|
||||
def __init__(self, authority: ExistingBranchAuthority, remote: ExistingBranchRemote, state_root: Path):
|
||||
self.authority = authority
|
||||
self.remote = remote
|
||||
self.store = ExistingBranchPublicationStore(state_root)
|
||||
|
||||
@staticmethod
|
||||
def _safe_error(exc: BaseException) -> str:
|
||||
return type(exc).__name__
|
||||
|
||||
def _observe(self, request: ExistingBranchPublicationRequest) -> dict[str, Any]:
|
||||
return {
|
||||
"authority_branch_oid": self.authority.read_ref(f"refs/heads/{request.branch}"),
|
||||
"gitea_git_branch_oid": self.remote.read_git_ref(request.branch),
|
||||
"gitea_api_branch_oid": self.remote.read_api_branch(request.branch),
|
||||
"commit_visible": bool(self.remote.commit_visible(request.expected_branch_oid)),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _has_remote_conflict(request: ExistingBranchPublicationRequest, observation: Mapping[str, Any]) -> bool:
|
||||
return any(
|
||||
observation.get(field) not in (None, request.expected_branch_oid)
|
||||
for field in ("gitea_git_branch_oid", "gitea_api_branch_oid")
|
||||
)
|
||||
|
||||
def _manual(self, request: ExistingBranchPublicationRequest, reason: str, observation: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return self.store.record(
|
||||
request,
|
||||
ExistingBranchPublicationState.MANUAL_OWNER_REVIEW_REQUIRED,
|
||||
error_code=reason,
|
||||
**dict(observation),
|
||||
)
|
||||
|
||||
def _finish(self, request: ExistingBranchPublicationRequest) -> dict[str, Any]:
|
||||
try:
|
||||
observation = self._observe(request)
|
||||
except Exception as exc:
|
||||
return self.store.record(
|
||||
request,
|
||||
ExistingBranchPublicationState.RECONCILIATION_REQUIRED,
|
||||
error_code=self._safe_error(exc),
|
||||
)
|
||||
if observation.get("authority_branch_oid") != request.expected_branch_oid:
|
||||
return self._manual(request, "AUTHORITY_BRANCH_OID_CHANGED", observation)
|
||||
if self._has_remote_conflict(request, observation):
|
||||
return self._manual(request, "REMOTE_BRANCH_OID_CONFLICT", observation)
|
||||
if (
|
||||
observation.get("gitea_git_branch_oid") == request.expected_branch_oid
|
||||
and observation.get("gitea_api_branch_oid") == request.expected_branch_oid
|
||||
and observation.get("commit_visible") is True
|
||||
):
|
||||
return self.store.record(request, ExistingBranchPublicationState.COMPLETED, **dict(observation))
|
||||
return self.store.record(
|
||||
request,
|
||||
ExistingBranchPublicationState.RECONCILIATION_REQUIRED,
|
||||
error_code="REMOTE_READBACK_INCOMPLETE",
|
||||
**dict(observation),
|
||||
)
|
||||
|
||||
def publish(self, value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
request = ExistingBranchPublicationRequest.from_mapping(value)
|
||||
previous = self.store.load(request)
|
||||
if previous:
|
||||
if previous.get("request_digest") != request.digest:
|
||||
raise ExistingBranchPublicationConflict("IDEMPOTENCY_KEY_REUSED")
|
||||
if previous.get("publication_state") == ExistingBranchPublicationState.COMPLETED.value:
|
||||
return {**previous, "idempotent": True}
|
||||
authority_oid = self.authority.read_ref(f"refs/heads/{request.branch}")
|
||||
if authority_oid is None:
|
||||
raise ExistingBranchPublicationConflict("AUTHORITY_BRANCH_MISSING")
|
||||
if authority_oid != request.expected_branch_oid:
|
||||
raise ExistingBranchPublicationConflict("AUTHORITY_BRANCH_OID_MISMATCH")
|
||||
if self.remote.repository_identity() != request.repository:
|
||||
raise ExistingBranchPublicationConflict("REMOTE_REPOSITORY_IDENTITY_MISMATCH")
|
||||
self.store.record(request, ExistingBranchPublicationState.QUEUED)
|
||||
try:
|
||||
observation = self._observe(request)
|
||||
except Exception as exc:
|
||||
return self.store.record(
|
||||
request,
|
||||
ExistingBranchPublicationState.RECONCILIATION_REQUIRED,
|
||||
error_code=self._safe_error(exc),
|
||||
)
|
||||
if observation.get("authority_branch_oid") != request.expected_branch_oid:
|
||||
return self._manual(request, "AUTHORITY_BRANCH_OID_CHANGED", observation)
|
||||
if self._has_remote_conflict(request, observation):
|
||||
return self._manual(request, "REMOTE_BRANCH_OID_CONFLICT", observation)
|
||||
if (
|
||||
observation.get("gitea_git_branch_oid") == request.expected_branch_oid
|
||||
and observation.get("gitea_api_branch_oid") == request.expected_branch_oid
|
||||
and observation.get("commit_visible") is True
|
||||
):
|
||||
return self.store.record(request, ExistingBranchPublicationState.COMPLETED, **dict(observation))
|
||||
if observation.get("gitea_git_branch_oid") is not None or observation.get("gitea_api_branch_oid") is not None:
|
||||
return self.store.record(
|
||||
request,
|
||||
ExistingBranchPublicationState.RECONCILIATION_REQUIRED,
|
||||
error_code="REMOTE_READBACK_INCOMPLETE",
|
||||
**dict(observation),
|
||||
)
|
||||
self.store.record(request, ExistingBranchPublicationState.PUBLISHING, **dict(observation))
|
||||
try:
|
||||
self.remote.push_exact(request.branch, None, request.expected_branch_oid)
|
||||
except Exception as exc:
|
||||
return self.store.record(
|
||||
request,
|
||||
ExistingBranchPublicationState.REMOTE_PUSH_PENDING,
|
||||
error_code=self._safe_error(exc),
|
||||
desired_remote_oid=request.expected_branch_oid,
|
||||
**dict(observation),
|
||||
)
|
||||
return self._finish(request)
|
||||
@@ -3,11 +3,12 @@ from __future__ import annotations
|
||||
import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, urllib.request, uuid
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from version_registry import (
|
||||
configured_authoritative_git_dir,
|
||||
configured_repository_identity,
|
||||
list_registry_versions,
|
||||
list_registry_versions_report,
|
||||
|
||||
from aios_caller_admission_projection import (
|
||||
CALLER,
|
||||
SCOPE,
|
||||
CallerProjectionClient,
|
||||
CallerProjectionError,
|
||||
)
|
||||
from baseline_authority import BaselineAuthority, BaselineAuthorityError
|
||||
from typing import Any
|
||||
@@ -21,9 +22,6 @@ except ModuleNotFoundError:
|
||||
from gitea_tag_publication import (
|
||||
GiteaTagClient,
|
||||
GitTagBackend,
|
||||
GiteaConfigurationError,
|
||||
InvalidReleaseVersion,
|
||||
PublicationError,
|
||||
PublicationConflict,
|
||||
PublicationPending,
|
||||
PublicationManualOwnerReviewRequired,
|
||||
@@ -82,13 +80,10 @@ HOST = os.getenv('AIOS_GITOPS_HOST', '127.0.0.1')
|
||||
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
|
||||
OPENAPI = WS / 'docs' / 'gitops' / 'openapi-chatgpt-actions.yaml'
|
||||
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios.git')))
|
||||
AUTHORITATIVE_GIT_DIR = configured_authoritative_git_dir()
|
||||
REPOSITORY_IDENTITY = configured_repository_identity()
|
||||
AUTHORITATIVE_GIT_DIR = Path(os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR')) if os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR') else None
|
||||
REPOSITORY_IDENTITY = os.getenv('AIOS_GITOPS_REPOSITORY_IDENTITY', '')
|
||||
BASELINE_AUTHORITY = BaselineAuthority()
|
||||
# Retain the generic Git directory only for legacy/staging compatibility
|
||||
# commands. Source-registry convergence receives the separate authority
|
||||
# below and never falls back to this path.
|
||||
GIT_DIR = Path(os.getenv('AIOS_GITOPS_REPO', '/data/aios-gitops/repo.git'))
|
||||
GIT_DIR = Path(os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR') or os.getenv('AIOS_GITOPS_REPO', '/data/aios-gitops/repo.git'))
|
||||
LEGACY_GIT_WRITE_COMMANDS = frozenset({'add','commit','checkout','branch','tag','push','fetch','reset','merge','rebase','update-ref','worktree'})
|
||||
SILENT_RELEASE_STATE_ROOT = Path(os.getenv(
|
||||
'AIOS_SILENT_RELEASE_STATE_ROOT',
|
||||
@@ -101,6 +96,11 @@ SILENT_RELEASE_PUBLIC_KEYS = Path(os.getenv(
|
||||
'AIOS_SILENT_RELEASE_PUBLIC_KEYS',
|
||||
'/etc/aios-gitops/silent-release-owner-public-keys.json',
|
||||
))
|
||||
CALLER_PROJECTION_SOCKET = Path(os.getenv(
|
||||
'AIOS_OWNER_AUTH_BROKER_SOCKET',
|
||||
'/run/aios-owner-authorization-broker/broker.sock',
|
||||
))
|
||||
CALLER_PROJECTION_CLIENT = CallerProjectionClient(CALLER_PROJECTION_SOCKET)
|
||||
monitor = ActionMonitor()
|
||||
|
||||
BASE_MODELS = [
|
||||
@@ -124,6 +124,9 @@ READ_ONLY_GET_PATHS = frozenset({
|
||||
'/admin/ai-evolution/api/data',
|
||||
'/api/release-consistency', '/api/tag-publication-status',
|
||||
'/api/release-acceptance',
|
||||
'/api/governed-write-capabilities', '/api/control-plane-snapshot',
|
||||
'/api/caller-capabilities',
|
||||
'/api/online-agent-task', '/api/online-agent-task-events',
|
||||
'/getReleaseCandidate', '/getReleasePlan', '/getReleaseAcceptance',
|
||||
'/getApprovalEnvelope', '/getReleaseSaga', '/getCapabilityStatus',
|
||||
'/getRecoveryStatus',
|
||||
@@ -370,7 +373,12 @@ def publication_publisher() -> TagPublisher:
|
||||
return TagPublisher(
|
||||
WS,
|
||||
GitTagBackend(WS, authoritative_repo=authoritative_repo),
|
||||
GiteaTagClient.from_authoritative_environment(),
|
||||
GiteaTagClient(
|
||||
os.getenv('AIOS_GITOPS_GITEA_API_URL',''),
|
||||
os.getenv('AIOS_GITOPS_GITEA_REPOSITORY',''),
|
||||
Path(os.getenv('AIOS_GITOPS_GITEA_TOKEN_FILE','')),
|
||||
token_line=int(os.getenv('AIOS_GITOPS_GITEA_TOKEN_LINE','1')),
|
||||
),
|
||||
os.getenv('AIOS_GITOPS_GITEA_REMOTE','gitea'),
|
||||
)
|
||||
|
||||
@@ -414,14 +422,113 @@ class H(BaseHTTPRequestHandler):
|
||||
except Exception: return {'_raw':raw}
|
||||
def actor(self):
|
||||
auth=self.headers.get('Authorization',''); tok=auth[7:].strip() if auth.startswith('Bearer ') else self.headers.get('X-AIOS-Token','')
|
||||
rec=authenticate(tok)
|
||||
if rec: return {'ok':True,'account':safe(rec.get('account') or rec.get('name') or 'token-user'),'role':rec.get('role','user'),'scopes':set(rec.get('scopes') or [])}
|
||||
return {'ok':False,'account':'anonymous','role':'anonymous','scopes':set()}
|
||||
def need(self, scope):
|
||||
a=self.actor()
|
||||
if not a['ok']: self.sendj({'error':'unauthorized','hint':'Bearer token required'},401); return None
|
||||
if '*' not in a['scopes'] and scope not in a['scopes']: self.sendj({'error':'forbidden','required_scope':scope,'account':a['account']},403); return None
|
||||
try:
|
||||
rec=authenticate(tok)
|
||||
except Exception:
|
||||
rec=None
|
||||
if not isinstance(rec, dict) or not rec:
|
||||
return {
|
||||
'ok': False,
|
||||
'account': 'anonymous',
|
||||
'role': 'anonymous',
|
||||
'scopes': set(),
|
||||
'static_scopes': set(),
|
||||
'projection': None,
|
||||
'credential_loaded': False,
|
||||
}
|
||||
scopes = set(rec.get('scopes') or [])
|
||||
return {
|
||||
'ok': True,
|
||||
'account': safe(rec.get('account') or rec.get('name') or 'token-user'),
|
||||
'role': rec.get('role', 'user'),
|
||||
'scopes': scopes,
|
||||
'static_scopes': set(scopes),
|
||||
'projection': None,
|
||||
'credential_loaded': False,
|
||||
}
|
||||
def need(self, scope, *, projection_request=None):
|
||||
a = self.actor()
|
||||
if not a['ok']:
|
||||
self.sendj({'error': 'unauthorized', 'hint': 'Bearer token required'}, 401)
|
||||
return None
|
||||
if a['account'] == CALLER and scope == SCOPE:
|
||||
try:
|
||||
result = CALLER_PROJECTION_CLIENT.rehydrate(
|
||||
a['account'],
|
||||
scope,
|
||||
projection_request,
|
||||
)
|
||||
except CallerProjectionError as exc:
|
||||
self.sendj(
|
||||
{
|
||||
'error': 'caller_projection_denied',
|
||||
'projection_state': 'DENIED',
|
||||
'detail': exc.code,
|
||||
'account': a['account'],
|
||||
},
|
||||
exc.status,
|
||||
)
|
||||
return None
|
||||
projection = result.public
|
||||
a['scopes'].update(projection.get('effective_scopes') or [])
|
||||
a['projection'] = projection
|
||||
a['credential_loaded'] = result.credential_loaded
|
||||
if '*' not in a['scopes'] and scope not in a['scopes']:
|
||||
self.sendj(
|
||||
{
|
||||
'error': 'forbidden',
|
||||
'required_scope': scope,
|
||||
'account': a['account'],
|
||||
},
|
||||
403,
|
||||
)
|
||||
return None
|
||||
return a
|
||||
def caller_capabilities(self):
|
||||
actor = self.actor()
|
||||
if not actor['ok']:
|
||||
return self.sendj(
|
||||
{'error': 'unauthorized', 'hint': 'Bearer token required'},
|
||||
401,
|
||||
)
|
||||
effective = set(actor['static_scopes'])
|
||||
projection_state = 'NOT_REQUIRED'
|
||||
credential_state = 'NOT_REQUIRED'
|
||||
rehydration_state = 'NOT_APPLICABLE'
|
||||
admitted = True
|
||||
projection_error = None
|
||||
if actor['account'] == CALLER:
|
||||
try:
|
||||
result = CALLER_PROJECTION_CLIENT.rehydrate(
|
||||
actor['account'],
|
||||
SCOPE,
|
||||
{},
|
||||
)
|
||||
except CallerProjectionError as exc:
|
||||
admitted = False
|
||||
projection_state = 'DENIED'
|
||||
credential_state = 'NOT_REHYDRATED'
|
||||
rehydration_state = 'FAILED'
|
||||
projection_error = exc.code
|
||||
else:
|
||||
effective.update(result.public.get('effective_scopes') or [])
|
||||
projection_state = result.public['projection_state']
|
||||
credential_state = result.public['credential_state']
|
||||
rehydration_state = result.public['rehydration_state']
|
||||
return self.sendj(
|
||||
{
|
||||
'schema': 'aios.caller-capability-introspection.v1',
|
||||
'caller_identity': actor['account'],
|
||||
'admitted': admitted,
|
||||
'effective_scopes': sorted(effective),
|
||||
'projection_state': projection_state,
|
||||
'credential_state': credential_state,
|
||||
'rehydration_state': rehydration_state,
|
||||
'projection_error': projection_error,
|
||||
'secret_exposed': False,
|
||||
}
|
||||
)
|
||||
|
||||
def bridge_release(self, path, data):
|
||||
route = {
|
||||
'/createTagApprovalRequest': '/v2/tag/approval-request',
|
||||
@@ -453,24 +560,183 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj(body, exc.code)
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
return self.sendj({'error': 'GOVERNED_BRIDGE_UNAVAILABLE', 'detail': type(exc).__name__}, 503)
|
||||
def do_POST(self):
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
if path in {
|
||||
'/createTagApprovalRequest', '/requestOrCreateTag', '/getTagPublicationStatus',
|
||||
'/buildProductionArtifact', '/executeApprovedProductionRelease',
|
||||
'/getReleaseConsistency', '/runReleaseAcceptance',
|
||||
}:
|
||||
actor = self.need('tag:create')
|
||||
if not actor:
|
||||
return
|
||||
return self.bridge_release(path, self.body())
|
||||
if reject_non_read_http(self, 'POST', path):
|
||||
return
|
||||
return self.sendj({'error': 'not_found', 'path': path}, 404)
|
||||
def bridge_v2_json(self, route, data=None, method='POST'):
|
||||
base = os.getenv('AIOS_BRIDGE_V2_URL', 'http://127.0.0.1:18892').rstrip('/')
|
||||
payload = None if data is None else json.dumps(
|
||||
data, sort_keys=True, separators=(',', ':')
|
||||
).encode('utf-8')
|
||||
request = urllib.request.Request(
|
||||
base + route,
|
||||
data=payload,
|
||||
headers={'Content-Type': 'application/json'} if payload is not None else {},
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
result = json.loads(response.read().decode('utf-8'))
|
||||
return self.sendj(result, response.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
body = json.loads(exc.read().decode('utf-8'))
|
||||
except Exception:
|
||||
body = {'error': 'bridge_http_error'}
|
||||
return self.sendj(body, exc.code)
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
return self.sendj(
|
||||
{'error': 'GOVERNED_BRIDGE_UNAVAILABLE', 'detail': type(exc).__name__},
|
||||
503,
|
||||
)
|
||||
|
||||
def control_plane_snapshot(self):
|
||||
"""Build a non-sensitive snapshot locally when Bridge v2 has no snapshot HTTP route."""
|
||||
base = os.getenv('AIOS_BRIDGE_V2_URL', 'http://127.0.0.1:18892').rstrip('/')
|
||||
|
||||
def bridge_get(route):
|
||||
request = urllib.request.Request(
|
||||
base + route,
|
||||
headers={'Accept': 'application/json'},
|
||||
method='GET',
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
if response.status != 200:
|
||||
raise RuntimeError('bridge_read_http_' + str(response.status))
|
||||
value = json.loads(response.read().decode('utf-8'))
|
||||
if not isinstance(value, dict):
|
||||
raise RuntimeError('bridge_read_not_object')
|
||||
return value
|
||||
|
||||
try:
|
||||
runtime = bridge_get('/v2/runtime')
|
||||
capabilities = bridge_get('/v2/capabilities')
|
||||
repository = repository_snapshot(
|
||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||
repository_identity=REPOSITORY_IDENTITY,
|
||||
)
|
||||
if not repository.get('repository_available'):
|
||||
return self.sendj({
|
||||
'error': 'AUTHORITATIVE_REPOSITORY_UNAVAILABLE',
|
||||
'query_error': repository.get('query_error'),
|
||||
}, 503)
|
||||
baseline = dict(BASELINE_AUTHORITY.read())
|
||||
except BaselineAuthorityError as exc:
|
||||
return self.sendj({
|
||||
'error': 'RECONCILIATION_REQUIRED',
|
||||
'detail': str(exc),
|
||||
}, 503)
|
||||
except urllib.error.HTTPError as exc:
|
||||
return self.sendj({
|
||||
'error': 'GOVERNED_BRIDGE_UNAVAILABLE',
|
||||
'detail': 'HTTP_' + str(exc.code),
|
||||
}, 503)
|
||||
except (urllib.error.URLError, TimeoutError, OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
|
||||
return self.sendj({
|
||||
'error': 'GOVERNED_BRIDGE_UNAVAILABLE',
|
||||
'detail': type(exc).__name__,
|
||||
}, 503)
|
||||
|
||||
legacy = capabilities.get('legacy_writes_enabled')
|
||||
if legacy is None:
|
||||
legacy = capabilities.get('legacy_global_writes_enabled')
|
||||
payload = {
|
||||
'schema': 'aios.control-plane-snapshot.aggregate.v1',
|
||||
'snapshot_source': 'model-api-local-aggregate',
|
||||
'version': {
|
||||
'target_release': runtime.get('control_plane_release_version'),
|
||||
},
|
||||
'runtime': {
|
||||
'bridge_schema_version': runtime.get('bridge_schema_version'),
|
||||
'control_plane_release_version': runtime.get('control_plane_release_version'),
|
||||
'runtime_release_commit': runtime.get('runtime_release_commit'),
|
||||
'runtime_path': runtime.get('runtime_path'),
|
||||
'artifact_sha256': runtime.get('artifact_sha256'),
|
||||
'drift_status': runtime.get('drift_status'),
|
||||
'deployment_state': runtime.get('deployment_state'),
|
||||
},
|
||||
'repository': {
|
||||
'identity': runtime.get('repository_identity') or runtime.get('repository') or REPOSITORY_IDENTITY,
|
||||
'authority_git_dir': runtime.get('authoritative_git_dir') or repository.get('authoritative_git_dir'),
|
||||
'authority_main_oid': repository.get('authoritative_head_oid'),
|
||||
},
|
||||
'production': {
|
||||
'baseline_version': baseline.get('current_baseline'),
|
||||
'target_oid': baseline.get('target_oid'),
|
||||
'deployment_state': baseline.get('status'),
|
||||
'baseline_generation': baseline.get('generation'),
|
||||
'baseline_sha256': baseline.get('authority_sha256'),
|
||||
},
|
||||
'task': {},
|
||||
'capabilities': {
|
||||
'legacy_writes_enabled': legacy,
|
||||
'governed_writes_enabled': capabilities.get('governed_writes_enabled'),
|
||||
'force_push': capabilities.get('force_push'),
|
||||
'main_write': capabilities.get('main_write'),
|
||||
'tag_write': capabilities.get('tag_write'),
|
||||
'baseline_write': capabilities.get('baseline_write'),
|
||||
'production_write': capabilities.get('production_write'),
|
||||
},
|
||||
'credential_broker': {
|
||||
'status': 'SERVER_SIDE_ONLY',
|
||||
'provider': 'BridgeV2RuntimeCredentialProjection',
|
||||
},
|
||||
'secret_material_exposed': False,
|
||||
}
|
||||
return self.sendj(payload)
|
||||
|
||||
def agent_task_control(self, method, route, data=None):
|
||||
token_path = Path(os.getenv(
|
||||
'AIOS_AGENT_TASK_CONTROL_TOKEN_FILE',
|
||||
'/run/credentials/aios-gitops-model-api-v2r2c1-staging.service/agent_task_control_token',
|
||||
))
|
||||
if token_path.is_symlink() or not token_path.is_file():
|
||||
return self.sendj({'error': 'AGENT_TASK_CONTROL_CREDENTIAL_UNAVAILABLE'}, 503)
|
||||
token = token_path.read_text(encoding='utf-8').strip()
|
||||
if len(token) < 32:
|
||||
return self.sendj({'error': 'AGENT_TASK_CONTROL_CREDENTIAL_INVALID'}, 503)
|
||||
base = os.getenv('AIOS_AGENT_TASK_API_URL', 'http://127.0.0.1:18940').rstrip('/')
|
||||
payload = None if data is None else json.dumps(
|
||||
data, sort_keys=True, separators=(',', ':')
|
||||
).encode('utf-8')
|
||||
headers = {'Authorization': 'Bearer ' + token, 'Accept': 'application/json'}
|
||||
if payload is not None:
|
||||
headers['Content-Type'] = 'application/json'
|
||||
request = urllib.request.Request(
|
||||
base + route, data=payload, headers=headers, method=method
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
result = json.loads(response.read().decode('utf-8'))
|
||||
return self.sendj(result, response.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
body = json.loads(exc.read().decode('utf-8'))
|
||||
except Exception:
|
||||
body = {'error': 'agent_task_http_error'}
|
||||
return self.sendj(body, exc.code)
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
return self.sendj(
|
||||
{'error': 'AGENT_TASK_API_UNAVAILABLE', 'detail': type(exc).__name__},
|
||||
503,
|
||||
)
|
||||
|
||||
def do_GET(self):
|
||||
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path
|
||||
if path not in READ_ONLY_GET_PATHS: return self.sendj({'error':'not_found','path':path},404)
|
||||
q=urllib.parse.parse_qs(u.query)
|
||||
if path == '/api/governed-write-capabilities':
|
||||
return self.bridge_v2_json('/v2/capabilities', method='GET')
|
||||
if path == '/api/control-plane-snapshot':
|
||||
return self.control_plane_snapshot()
|
||||
if path == '/api/caller-capabilities':
|
||||
return self.caller_capabilities()
|
||||
if path in ('/api/online-agent-task', '/api/online-agent-task-events'):
|
||||
actor = self.need('agent-task:control')
|
||||
if not actor:
|
||||
return
|
||||
task_id = (q.get('task_id') or [''])[0]
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._:-]{2,179}', task_id):
|
||||
return self.sendj({'error': 'task_id_invalid'}, 400)
|
||||
suffix = '/events' if path.endswith('-events') else ''
|
||||
return self.agent_task_control('GET', '/v1/tasks/' + task_id + suffix)
|
||||
try:
|
||||
if path in ('/health','/api/health'):
|
||||
return self.sendj({
|
||||
@@ -502,11 +768,6 @@ class H(BaseHTTPRequestHandler):
|
||||
data['legacy_global_writes_enabled'] = False
|
||||
return self.sendj(data)
|
||||
if path in ('/api/ver','/api/versions','/ver'):
|
||||
registry_report = list_registry_versions_report(
|
||||
WS,
|
||||
baseline_file=BASELINE_AUTHORITY.path,
|
||||
repo_git_dir=AUTHORITATIVE_GIT_DIR,
|
||||
)
|
||||
try:
|
||||
baseline = dict(BASELINE_AUTHORITY.read())
|
||||
except BaselineAuthorityError as exc:
|
||||
@@ -519,8 +780,7 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj({
|
||||
'repository_available': False,
|
||||
'query_error': snapshot['query_error'],
|
||||
'registry_versions': registry_report['registry_versions'],
|
||||
**{key: registry_report[key] for key in ('effective_registry_versions', 'runtime_registry_versions', 'attested_projection_versions', 'attested_projection_version', 'source_registry_converged', 'runtime_registry_converged', 'runtime_bundle_contains_current_version', 'runtime_bundle_contains_version', 'registry_consistency_status')},
|
||||
'registry_versions': [p.name for p in (WS/'versions').glob('*')],
|
||||
'local_authoritative_tags': [],
|
||||
'baseline_version': baseline.get('current_baseline'),
|
||||
'baseline_generation': baseline.get('generation'),
|
||||
@@ -532,14 +792,13 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj({
|
||||
'repository_available': False,
|
||||
'query_error': str(exc),
|
||||
'registry_versions': registry_report['registry_versions'],
|
||||
**{key: registry_report[key] for key in ('effective_registry_versions', 'runtime_registry_versions', 'attested_projection_versions', 'attested_projection_version', 'source_registry_converged', 'runtime_registry_converged', 'runtime_bundle_contains_current_version', 'runtime_bundle_contains_version', 'registry_consistency_status')},
|
||||
'registry_versions': [p.name for p in (WS/'versions').glob('*')],
|
||||
'local_authoritative_tags': [],
|
||||
'baseline_version': baseline.get('current_baseline'),
|
||||
'baseline_generation': baseline.get('generation'),
|
||||
'baseline_sha256': baseline.get('authority_sha256'),
|
||||
}, 503)
|
||||
registry = registry_report['registry_versions']
|
||||
registry = [p.name for p in (WS/'versions').glob('*')]
|
||||
return self.sendj({
|
||||
'registry_versions': registry,
|
||||
'local_authoritative_tags': tags,
|
||||
@@ -553,7 +812,6 @@ class H(BaseHTTPRequestHandler):
|
||||
'baseline_generation': baseline.get('generation'),
|
||||
'baseline_sha256': baseline.get('authority_sha256'),
|
||||
'baseline': baseline,
|
||||
**{key: registry_report[key] for key in ('effective_registry_versions', 'runtime_registry_versions', 'attested_projection_versions', 'attested_projection_version', 'source_registry_converged', 'runtime_registry_converged', 'runtime_bundle_contains_current_version', 'runtime_bundle_contains_version', 'registry_consistency_status')},
|
||||
})
|
||||
if path in ('/api/diff','/diff'):
|
||||
frm=(q.get('from') or [''])[0]
|
||||
@@ -649,22 +907,12 @@ class H(BaseHTTPRequestHandler):
|
||||
try:
|
||||
version=validate_version(version)
|
||||
status=publication_publisher().get_tag_publication_status(version)
|
||||
except InvalidReleaseVersion as exc:
|
||||
return self.sendj({'error': exc.code},400)
|
||||
except GiteaConfigurationError as exc:
|
||||
return self.sendj({'error': exc.code},503)
|
||||
except PublicationError as exc:
|
||||
return self.sendj({'error': getattr(exc, 'code', str(exc))},503)
|
||||
except ValueError:
|
||||
return self.sendj({'error':'invalid release version'},400)
|
||||
return self.sendj(status)
|
||||
if path == '/api/release-consistency':
|
||||
request=publication_request_from_query(q)
|
||||
try:
|
||||
result=publication_publisher().get_release_consistency(request)
|
||||
except GiteaConfigurationError as exc:
|
||||
return self.sendj({'error': exc.code},503)
|
||||
except PublicationError as exc:
|
||||
return self.sendj({'error': getattr(exc, 'code', str(exc))},503)
|
||||
return self.sendj(result)
|
||||
return self.sendj(publication_publisher().get_release_consistency(request))
|
||||
if path == '/api/release-acceptance':
|
||||
request=publication_request_from_query(q)
|
||||
return self.sendj(publication_publisher().run_release_acceptance(request))
|
||||
@@ -725,12 +973,58 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj({'error':'not_found','path':path},404)
|
||||
def do_POST(self):
|
||||
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
||||
governed_routes = {
|
||||
'/createGovernedTask': ('/v2/governed-task', 'branch:create'),
|
||||
'/acquireWriterLease': ('/v2/writer-lease', 'file:write'),
|
||||
'/commitFileGoverned': ('/v2/commit-file', 'file:write'),
|
||||
'/commitFilesGoverned': ('/v2/commit-files', 'file:write'),
|
||||
'/getGitOpsJob': ('/v2/job', 'file:write'),
|
||||
'/resumeGitOpsJob': ('/v2/job/resume', 'file:write'),
|
||||
'/reconcileGitOpsJob': ('/v2/job/reconcile', 'file:write'),
|
||||
'/createTagApprovalRequest': ('/v2/tag/approval-request', 'tag:create'),
|
||||
'/requestOrCreateTag': ('/v2/tag/publish', 'tag:create'),
|
||||
'/getTagPublicationStatus': ('/v2/tag/status', 'tag:create'),
|
||||
'/buildProductionArtifact': ('/v2/release/artifact', 'tag:create'),
|
||||
'/executeApprovedProductionRelease': ('/v2/release/execute', 'tag:create'),
|
||||
'/getReleaseConsistency': ('/v2/release/consistency', 'tag:create'),
|
||||
'/runReleaseAcceptance': ('/v2/release/acceptance', 'tag:create'),
|
||||
}
|
||||
if path in governed_routes:
|
||||
route, scope = governed_routes[path]
|
||||
actor = self.need(scope)
|
||||
if not actor:
|
||||
return
|
||||
return self.bridge_v2_json(route, self.body(), method='POST')
|
||||
if path in {
|
||||
'/createOnlineAgentTask', '/registerOnlineAgent',
|
||||
'/verifyOnlineAgentTask', '/cancelOnlineAgentTask',
|
||||
}:
|
||||
actor = self.need('agent-task:control')
|
||||
if not actor:
|
||||
return
|
||||
data = self.body()
|
||||
if path == '/createOnlineAgentTask':
|
||||
return self.agent_task_control('POST', '/v1/tasks', data)
|
||||
if path == '/registerOnlineAgent':
|
||||
return self.agent_task_control('POST', '/v1/agents/register', data)
|
||||
task_id = str(data.pop('task_id', '') or '')
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._:-]{2,179}', task_id):
|
||||
return self.sendj({'error': 'task_id_invalid'}, 400)
|
||||
suffix = '/verify' if path == '/verifyOnlineAgentTask' else '/cancel'
|
||||
return self.agent_task_control('POST', '/v1/tasks/' + task_id + suffix, data)
|
||||
if reject_non_read_http(self, 'POST', path): return
|
||||
data=self.body()
|
||||
try:
|
||||
if path == '/createReleaseCandidate':
|
||||
a=self.need('release:candidate')
|
||||
if not a: return
|
||||
a = self.need(
|
||||
'release:candidate',
|
||||
projection_request={
|
||||
'source_commit': str(data.get('source_commit') or ''),
|
||||
'version': str(data.get('version') or ''),
|
||||
},
|
||||
)
|
||||
if not a:
|
||||
return
|
||||
return self.sendj(
|
||||
silent_release_service().create_release_candidate(data)
|
||||
)
|
||||
@@ -1021,7 +1315,7 @@ def api_spec():
|
||||
return {
|
||||
'version': 'V2R2C1-IMMUTABLE-STAGING',
|
||||
'openapi': '/api/openapi.yaml',
|
||||
'read': ['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],
|
||||
'read': ['GET /api/caller-capabilities','GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],
|
||||
'write': ['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl','POST /createReleaseCandidate','POST /submitOwnerApproval','POST /startDeployment'],
|
||||
'policy': {
|
||||
'bearer_required_for_write': True,
|
||||
@@ -1036,6 +1330,9 @@ def api_spec():
|
||||
'silent_release_shadow_default': True,
|
||||
'runtime_mode': 'IMMUTABLE_STAGING',
|
||||
'governed_release_routing': 'bridge-v2',
|
||||
'caller_admission_projection': 'durable-owner-policy-server-side-ack',
|
||||
'caller_projection_scope': SCOPE,
|
||||
'caller_projection_caller': CALLER,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1044,3 +1341,4 @@ def main():
|
||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
||||
if __name__ == '__main__': main()
|
||||
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
"""Narrow, non-root client contract for the privileged signer broker."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
from typing import Any, Protocol
|
||||
|
||||
DEFAULT_SIGNER_BROKER_SOCKET = Path("/run/aios/signer-broker.sock")
|
||||
MAX_SIGNING_PAYLOAD = 1024 * 1024
|
||||
CODE_RE = re.compile(r"^[A-Z0-9_.:-]{3,96}$")
|
||||
|
||||
|
||||
class SignerBrokerError(RuntimeError):
|
||||
def __init__(self, code: str):
|
||||
self.code = code
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
class SignerBroker(Protocol):
|
||||
def self_test(self) -> None: ...
|
||||
def sign(self, payload_kind: str, payload: bytes) -> tuple[bytes, str]: ...
|
||||
|
||||
|
||||
class UnixSignerBroker:
|
||||
"""AF_UNIX request client; it never accepts commands, paths, or shell text."""
|
||||
|
||||
def __init__(self, socket_path: Path = DEFAULT_SIGNER_BROKER_SOCKET, timeout: float = 15.0):
|
||||
path = Path(socket_path)
|
||||
if path != DEFAULT_SIGNER_BROKER_SOCKET or not path.is_absolute():
|
||||
raise SignerBrokerError("SIGNER_BROKER_SOCKET_INVALID")
|
||||
self.socket_path = path
|
||||
self.timeout = timeout
|
||||
|
||||
def _request(self, operation: str, **payload: Any) -> dict[str, Any]:
|
||||
if operation not in {"self_test", "sign"}:
|
||||
raise SignerBrokerError("SIGNER_OPERATION_REJECTED")
|
||||
request = {
|
||||
"schema": "aios.signer-broker.request.v1",
|
||||
"operation": operation,
|
||||
**payload,
|
||||
}
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as channel:
|
||||
channel.settimeout(self.timeout)
|
||||
channel.connect(str(self.socket_path))
|
||||
channel.sendall((json.dumps(request, sort_keys=True, separators=(",", ":")) + "\n").encode())
|
||||
raw = channel.makefile("rb").readline()
|
||||
except (OSError, TimeoutError) as exc:
|
||||
raise SignerBrokerError("SIGNER_BROKER_UNAVAILABLE") from exc
|
||||
try:
|
||||
response = json.loads(raw.decode())
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SignerBrokerError("SIGNER_BROKER_RESPONSE_INVALID") from exc
|
||||
if not isinstance(response, dict) or response.get("schema") != "aios.signer-broker.response.v1":
|
||||
raise SignerBrokerError("SIGNER_BROKER_RESPONSE_INVALID")
|
||||
if response.get("status") != "PASS":
|
||||
code = str(response.get("error_code") or "SIGNER_BROKER_REJECTED")
|
||||
raise SignerBrokerError(code if CODE_RE.fullmatch(code) else "SIGNER_BROKER_REJECTED")
|
||||
return response
|
||||
|
||||
def self_test(self) -> None:
|
||||
self._request("self_test")
|
||||
|
||||
def sign(self, payload_kind: str, payload: bytes) -> tuple[bytes, str]:
|
||||
if payload_kind not in {"approval_plan", "grant_payload"}:
|
||||
raise SignerBrokerError("SIGNER_PAYLOAD_KIND_REJECTED")
|
||||
if not isinstance(payload, bytes) or not payload or len(payload) > MAX_SIGNING_PAYLOAD:
|
||||
raise SignerBrokerError("SIGNER_PAYLOAD_INVALID")
|
||||
response = self._request(
|
||||
"sign",
|
||||
payload_kind=payload_kind,
|
||||
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||
payload_base64=base64.b64encode(payload).decode("ascii"),
|
||||
)
|
||||
try:
|
||||
signature = base64.b64decode(str(response["signature_base64"]), validate=True)
|
||||
key_id = str(response["key_id"]).strip()
|
||||
except (KeyError, ValueError) as exc:
|
||||
raise SignerBrokerError("SIGNER_BROKER_RESPONSE_INVALID") from exc
|
||||
if not signature or not key_id or len(key_id) > 256:
|
||||
raise SignerBrokerError("SIGNER_BROKER_RESPONSE_INVALID")
|
||||
return signature, key_id
|
||||
@@ -1,289 +0,0 @@
|
||||
"""Privileged, fixed-function signer broker service."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import binascii
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import socketserver
|
||||
import stat
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from typing import Any, Callable, Mapping, Protocol, Sequence
|
||||
|
||||
REQUEST_SCHEMA = "aios.signer-broker.request.v1"
|
||||
RESPONSE_SCHEMA = "aios.signer-broker.response.v1"
|
||||
AUDIT_SCHEMA = "aios.signer-broker.audit.v1"
|
||||
DEFAULT_SOCKET_PATH = Path("/run/aios/signer-broker.sock")
|
||||
DEFAULT_SIGNER_PATH = Path("/usr/local/libexec/aios-grant-signer-adapter")
|
||||
DEFAULT_AUDIT_PATH = Path("/var/lib/aios-gitops/audit/signer-broker.jsonl")
|
||||
DEFAULT_STATE_ROOT = Path("/var/lib/aios-gitops/signer-broker")
|
||||
ALLOWED_OPERATIONS = frozenset({"self_test", "sign"})
|
||||
ALLOWED_PAYLOAD_KINDS = frozenset({"approval_plan", "grant_payload"})
|
||||
MAX_SIGNING_PAYLOAD = 1024 * 1024
|
||||
MAX_REQUEST_BYTES = 2 * MAX_SIGNING_PAYLOAD + 8192
|
||||
CODE_RE = re.compile(r"^[A-Z0-9_.:-]{3,96}$")
|
||||
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class SignerBrokerServiceError(RuntimeError):
|
||||
def __init__(self, code: str):
|
||||
self.code = code if CODE_RE.fullmatch(code) else "SIGNER_INTERNAL_ERROR"
|
||||
super().__init__(self.code)
|
||||
|
||||
|
||||
class SignerRunner(Protocol):
|
||||
def self_test(self) -> None: ...
|
||||
def sign(self, payload_kind: str, payload: bytes) -> tuple[bytes, str]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SignerBrokerPolicy:
|
||||
socket_path: Path = DEFAULT_SOCKET_PATH
|
||||
signer_path: Path = DEFAULT_SIGNER_PATH
|
||||
audit_path: Path = DEFAULT_AUDIT_PATH
|
||||
state_root: Path = DEFAULT_STATE_ROOT
|
||||
allowed_uids: frozenset[int] = frozenset()
|
||||
signer_timeout: float = 60.0
|
||||
max_payload_bytes: int = MAX_SIGNING_PAYLOAD
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("socket_path", "signer_path", "audit_path", "state_root"):
|
||||
if not Path(getattr(self, name)).is_absolute():
|
||||
raise SignerBrokerServiceError("SIGNER_POLICY_PATH_INVALID")
|
||||
if not self.allowed_uids or any(not isinstance(uid, int) or uid < 0 for uid in self.allowed_uids):
|
||||
raise SignerBrokerServiceError("SIGNER_POLICY_CALLER_INVALID")
|
||||
if not 0 < self.signer_timeout <= 300:
|
||||
raise SignerBrokerServiceError("SIGNER_POLICY_TIMEOUT_INVALID")
|
||||
if not 0 < self.max_payload_bytes <= MAX_SIGNING_PAYLOAD:
|
||||
raise SignerBrokerServiceError("SIGNER_POLICY_PAYLOAD_LIMIT_INVALID")
|
||||
|
||||
|
||||
class FixedSignerRunner:
|
||||
"""Use one configured signer executable and broker-owned temporary paths."""
|
||||
|
||||
def __init__(self, policy: SignerBrokerPolicy):
|
||||
self.policy = policy
|
||||
|
||||
def _run(self, argv: Sequence[str]) -> None:
|
||||
try:
|
||||
result = subprocess.run(list(argv), capture_output=True, text=True, check=False, timeout=self.policy.signer_timeout)
|
||||
except FileNotFoundError as exc:
|
||||
raise SignerBrokerServiceError("SIGNER_UNAVAILABLE") from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise SignerBrokerServiceError("SIGNER_TIMEOUT") from exc
|
||||
except OSError as exc:
|
||||
raise SignerBrokerServiceError("SIGNER_UNAVAILABLE") from exc
|
||||
if result.returncode != 0:
|
||||
raise SignerBrokerServiceError("SIGNER_FAILED")
|
||||
|
||||
def self_test(self) -> None:
|
||||
self._run((str(self.policy.signer_path), "self-test"))
|
||||
|
||||
def sign(self, payload_kind: str, payload: bytes) -> tuple[bytes, str]:
|
||||
if payload_kind not in ALLOWED_PAYLOAD_KINDS:
|
||||
raise SignerBrokerServiceError("SIGNER_PAYLOAD_KIND_REJECTED")
|
||||
try:
|
||||
self.policy.state_root.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(self.policy.state_root, 0o700)
|
||||
with tempfile.TemporaryDirectory(prefix="request-", dir=self.policy.state_root) as directory:
|
||||
root = Path(directory)
|
||||
input_path = root / "payload.canonical"
|
||||
signature_path = root / "payload.sig"
|
||||
key_id_path = root / "payload.key-id"
|
||||
input_path.write_bytes(payload)
|
||||
os.chmod(input_path, 0o600)
|
||||
self._run((str(self.policy.signer_path), "sign", "--namespace", payload_kind, "--input", str(input_path), "--signature-out", str(signature_path), "--key-id-out", str(key_id_path)))
|
||||
signature = signature_path.read_bytes()
|
||||
key_id = key_id_path.read_text(encoding="utf-8").strip()
|
||||
except SignerBrokerServiceError:
|
||||
raise
|
||||
except (OSError, UnicodeError) as exc:
|
||||
raise SignerBrokerServiceError("SIGNER_OUTPUT_INVALID") from exc
|
||||
if not signature or not key_id or len(key_id) > 256 or "\n" in key_id or "\r" in key_id:
|
||||
raise SignerBrokerServiceError("SIGNER_OUTPUT_INVALID")
|
||||
return signature, key_id
|
||||
|
||||
|
||||
class SignerBrokerService:
|
||||
def __init__(self, policy: SignerBrokerPolicy, runner: SignerRunner | None = None, *, now: Callable[[], datetime] | None = None) -> None:
|
||||
self.policy = policy
|
||||
self.runner = runner or FixedSignerRunner(policy)
|
||||
self.now = now or (lambda: datetime.now(timezone.utc))
|
||||
self._audit_lock = threading.Lock()
|
||||
|
||||
@staticmethod
|
||||
def _operation(value: Any) -> str:
|
||||
return value if isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9_.:-]{1,64}", value) else "INVALID"
|
||||
|
||||
def _audit(self, *, peer_uid: int, operation: str, status: str, error_code: str | None = None, payload_sha256: str | None = None) -> None:
|
||||
event: dict[str, Any] = {"schema": AUDIT_SCHEMA, "timestamp": self.now().astimezone(timezone.utc).isoformat(), "peer_uid": peer_uid, "operation": self._operation(operation), "status": status}
|
||||
if isinstance(payload_sha256, str) and SHA256_RE.fullmatch(payload_sha256):
|
||||
event["payload_sha256"] = payload_sha256
|
||||
if error_code:
|
||||
event["error_code"] = error_code if CODE_RE.fullmatch(error_code) else "SIGNER_INTERNAL_ERROR"
|
||||
try:
|
||||
with self._audit_lock:
|
||||
self.policy.audit_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self.policy.audit_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, sort_keys=True, separators=(",", ":")) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(self.policy.audit_path, 0o600)
|
||||
except OSError as exc:
|
||||
raise SignerBrokerServiceError("SIGNER_AUDIT_UNAVAILABLE") from exc
|
||||
|
||||
@staticmethod
|
||||
def _failure(code: str) -> dict[str, Any]:
|
||||
return {"schema": RESPONSE_SCHEMA, "status": "FAIL", "error_code": code if CODE_RE.fullmatch(code) else "SIGNER_INTERNAL_ERROR"}
|
||||
|
||||
def _dispatch(self, request: Mapping[str, Any], peer_uid: int) -> tuple[dict[str, Any], str, str | None]:
|
||||
operation = self._operation(request.get("operation"))
|
||||
if peer_uid not in self.policy.allowed_uids:
|
||||
raise SignerBrokerServiceError("CALLER_UNAUTHORIZED")
|
||||
if request.get("schema") != REQUEST_SCHEMA:
|
||||
raise SignerBrokerServiceError("REQUEST_SCHEMA_INVALID")
|
||||
if operation not in ALLOWED_OPERATIONS:
|
||||
raise SignerBrokerServiceError("SIGNER_OPERATION_REJECTED")
|
||||
if operation == "self_test":
|
||||
if set(request) != {"schema", "operation"}:
|
||||
raise SignerBrokerServiceError("REQUEST_FIELDS_REJECTED")
|
||||
self.runner.self_test()
|
||||
return {"schema": RESPONSE_SCHEMA, "status": "PASS", "operation": operation}, operation, None
|
||||
if set(request) != {"schema", "operation", "payload_kind", "payload_sha256", "payload_base64"}:
|
||||
raise SignerBrokerServiceError("REQUEST_FIELDS_REJECTED")
|
||||
payload_kind = request.get("payload_kind")
|
||||
encoded = request.get("payload_base64")
|
||||
claimed_hash = request.get("payload_sha256")
|
||||
if payload_kind not in ALLOWED_PAYLOAD_KINDS or not isinstance(encoded, str) or not isinstance(claimed_hash, str) or not SHA256_RE.fullmatch(claimed_hash):
|
||||
raise SignerBrokerServiceError("SIGNER_PAYLOAD_INVALID")
|
||||
if len(encoded) > self.policy.max_payload_bytes * 2 + 8:
|
||||
raise SignerBrokerServiceError("SIGNER_PAYLOAD_INVALID")
|
||||
try:
|
||||
payload = base64.b64decode(encoded.encode("ascii"), validate=True)
|
||||
except (ValueError, UnicodeError, binascii.Error) as exc:
|
||||
raise SignerBrokerServiceError("SIGNER_PAYLOAD_INVALID") from exc
|
||||
if not payload or len(payload) > self.policy.max_payload_bytes:
|
||||
raise SignerBrokerServiceError("SIGNER_PAYLOAD_INVALID")
|
||||
actual_hash = hashlib.sha256(payload).hexdigest()
|
||||
if actual_hash != claimed_hash:
|
||||
raise SignerBrokerServiceError("SIGNER_PAYLOAD_HASH_MISMATCH")
|
||||
signature, key_id = self.runner.sign(payload_kind, payload)
|
||||
if not isinstance(signature, bytes) or not signature or not isinstance(key_id, str) or not key_id.strip() or len(key_id) > 256:
|
||||
raise SignerBrokerServiceError("SIGNER_OUTPUT_INVALID")
|
||||
return {"schema": RESPONSE_SCHEMA, "status": "PASS", "operation": operation, "signature_base64": base64.b64encode(signature).decode("ascii"), "key_id": key_id.strip()}, operation, actual_hash
|
||||
|
||||
def handle_request(self, request: Mapping[str, Any], peer_uid: int) -> dict[str, Any]:
|
||||
operation = self._operation(request.get("operation")) if isinstance(request, Mapping) else "INVALID"
|
||||
payload_hash = request.get("payload_sha256") if isinstance(request, Mapping) else None
|
||||
status = "FAIL"
|
||||
error_code: str | None = None
|
||||
try:
|
||||
if not isinstance(request, Mapping):
|
||||
raise SignerBrokerServiceError("REQUEST_OBJECT_REQUIRED")
|
||||
response, operation, payload_hash = self._dispatch(request, peer_uid)
|
||||
status = "PASS"
|
||||
except SignerBrokerServiceError as exc:
|
||||
error_code = exc.code
|
||||
response = self._failure(exc.code)
|
||||
except Exception:
|
||||
error_code = "SIGNER_INTERNAL_ERROR"
|
||||
response = self._failure(error_code)
|
||||
try:
|
||||
self._audit(peer_uid=peer_uid, operation=operation, status=status, error_code=error_code, payload_sha256=payload_hash if isinstance(payload_hash, str) else None)
|
||||
except SignerBrokerServiceError as exc:
|
||||
response = self._failure(exc.code)
|
||||
return response
|
||||
|
||||
def handle_json_line(self, raw: bytes, peer_uid: int) -> dict[str, Any]:
|
||||
if len(raw) > MAX_REQUEST_BYTES:
|
||||
return self.handle_request({"operation": "invalid"}, peer_uid) | {"error_code": "REQUEST_TOO_LARGE", "status": "FAIL"}
|
||||
try:
|
||||
request = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return self.handle_request({"operation": "invalid"}, peer_uid) | {"error_code": "REQUEST_JSON_INVALID", "status": "FAIL"}
|
||||
return self.handle_request(request, peer_uid)
|
||||
|
||||
|
||||
class _UnixRequestHandler(socketserver.StreamRequestHandler):
|
||||
def handle(self) -> None:
|
||||
service: SignerBrokerService = self.server.service # type: ignore[attr-defined]
|
||||
response = service.handle_json_line(self.rfile.readline(MAX_REQUEST_BYTES + 1), _peer_uid(self.request))
|
||||
self.wfile.write((json.dumps(response, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8"))
|
||||
self.wfile.flush()
|
||||
|
||||
|
||||
class SignerBrokerUnixServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(self, socket_path: Path, service: SignerBrokerService):
|
||||
self.socket_path = Path(socket_path)
|
||||
self.service = service
|
||||
super().__init__(str(self.socket_path), _UnixRequestHandler)
|
||||
|
||||
def server_close(self) -> None:
|
||||
super().server_close()
|
||||
try:
|
||||
if self.socket_path.exists() and stat.S_ISSOCK(self.socket_path.stat().st_mode):
|
||||
self.socket_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _peer_uid(channel: socket.socket) -> int:
|
||||
if not hasattr(socket, "SO_PEERCRED"):
|
||||
return -1
|
||||
try:
|
||||
_pid, uid, _gid = struct.unpack("3i", channel.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i")))
|
||||
return int(uid)
|
||||
except (OSError, struct.error):
|
||||
return -1
|
||||
|
||||
|
||||
def build_server(policy: SignerBrokerPolicy, runner: SignerRunner | None = None) -> SignerBrokerUnixServer:
|
||||
socket_path = policy.socket_path
|
||||
socket_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if socket_path.exists():
|
||||
if not stat.S_ISSOCK(socket_path.stat().st_mode):
|
||||
raise SignerBrokerServiceError("SIGNER_BROKER_SOCKET_CONFLICT")
|
||||
socket_path.unlink()
|
||||
server = SignerBrokerUnixServer(socket_path, SignerBrokerService(policy, runner))
|
||||
os.chmod(socket_path, 0o660)
|
||||
return server
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="AIOS fixed-function signer broker")
|
||||
parser.add_argument("command", choices=("serve",))
|
||||
parser.add_argument("--socket", default=str(DEFAULT_SOCKET_PATH))
|
||||
parser.add_argument("--signer", default=str(DEFAULT_SIGNER_PATH))
|
||||
parser.add_argument("--audit", default=str(DEFAULT_AUDIT_PATH))
|
||||
parser.add_argument("--state-root", default=str(DEFAULT_STATE_ROOT))
|
||||
parser.add_argument("--allowed-uid", action="append", type=int, required=True)
|
||||
parser.add_argument("--timeout", type=float, default=60.0)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
policy = SignerBrokerPolicy(socket_path=Path(args.socket), signer_path=Path(args.signer), audit_path=Path(args.audit), state_root=Path(args.state_root), allowed_uids=frozenset(args.allowed_uid), signer_timeout=args.timeout)
|
||||
server = build_server(policy)
|
||||
try:
|
||||
server.serve_forever()
|
||||
finally:
|
||||
server.server_close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -31,8 +31,6 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from version_registry import configured_authoritative_git_dir, verify_authoritative_repository
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
@@ -51,11 +49,6 @@ REQUIRED_V2R1C7_MEMBERS = {
|
||||
"payload/ARTIFACT-MANIFEST.json",
|
||||
"payload/ROLLBACK-METADATA.json",
|
||||
}
|
||||
REQUIRED_V2R2C1_MEMBERS = {
|
||||
"payload/versions/V2R2C1.json",
|
||||
}
|
||||
V2R2C1_TAG_OID = "07fc2746b688f5f243f29b39de39f32edc3dd9d2"
|
||||
DEFAULT_AUTHORITATIVE_GIT_DIR = Path("/data/aios-gitops/repo.git")
|
||||
|
||||
class ReleaseBlocked(RuntimeError):
|
||||
pass
|
||||
@@ -79,13 +72,6 @@ class Binding:
|
||||
production_service: str
|
||||
production_port: int
|
||||
repository_identity: str = "aios/aios"
|
||||
# Legacy V2R2C1 receipts intentionally omit tag_oid. Future release
|
||||
# bindings must provide it explicitly and may use a versioned schema.
|
||||
tag_oid: str | None = None
|
||||
binding_schema: str | None = None
|
||||
source_registry_main_oid: str | None = None
|
||||
source_registry_path: str | None = None
|
||||
source_registry_sha256: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.version or not OID_RE.fullmatch(self.target_oid):
|
||||
@@ -104,32 +90,6 @@ class Binding:
|
||||
raise ValueError("invalid production port")
|
||||
if self.repository_identity != "aios/aios":
|
||||
raise ValueError("unexpected repository identity")
|
||||
if self.tag_oid is not None and not OID_RE.fullmatch(self.tag_oid):
|
||||
raise ValueError("invalid annotated tag object OID")
|
||||
if self.binding_schema is not None and not self.binding_schema.startswith(
|
||||
"aios.release-binding."
|
||||
):
|
||||
raise ValueError("invalid release binding schema")
|
||||
provenance = (
|
||||
self.source_registry_main_oid,
|
||||
self.source_registry_sha256,
|
||||
)
|
||||
if any(item is not None for item in provenance):
|
||||
if not all(item is not None for item in provenance):
|
||||
raise ValueError("source registry provenance must be complete")
|
||||
if not OID_RE.fullmatch(str(self.source_registry_main_oid)):
|
||||
raise ValueError("invalid source registry main OID")
|
||||
if not SHA_RE.fullmatch(str(self.source_registry_sha256)):
|
||||
raise ValueError("invalid source registry SHA-256")
|
||||
path = self.source_registry_path or f"versions/{self.version}.json"
|
||||
candidate = Path(path)
|
||||
if (
|
||||
candidate.is_absolute()
|
||||
or ".." in candidate.parts
|
||||
or "\\" in path
|
||||
or not path.endswith(".json")
|
||||
):
|
||||
raise ValueError("invalid source registry path")
|
||||
|
||||
@property
|
||||
def tag(self) -> str:
|
||||
@@ -148,22 +108,10 @@ class Binding:
|
||||
"production_port": self.production_port,
|
||||
"repository_identity": self.repository_identity,
|
||||
}
|
||||
if self.tag_oid is not None:
|
||||
payload["tag_oid"] = self.tag_oid
|
||||
payload["binding_schema"] = self.binding_schema or "aios.release-binding.v2"
|
||||
if self.source_registry_main_oid is not None:
|
||||
payload.update(
|
||||
{
|
||||
"source_registry_main_oid": self.source_registry_main_oid,
|
||||
"source_registry_path": self.source_registry_path
|
||||
or f"versions/{self.version}.json",
|
||||
"source_registry_sha256": self.source_registry_sha256,
|
||||
}
|
||||
)
|
||||
return hashlib.sha256(canonical_json(payload)).hexdigest()
|
||||
|
||||
def request(self, operation: str) -> dict[str, Any]:
|
||||
request = {
|
||||
return {
|
||||
"schema": "aios.production-executor-request.v1",
|
||||
"operation": operation,
|
||||
"binding_fingerprint": self.fingerprint,
|
||||
@@ -179,19 +127,6 @@ class Binding:
|
||||
"production_port": self.production_port,
|
||||
"repository_identity": self.repository_identity,
|
||||
}
|
||||
if self.tag_oid is not None:
|
||||
request["tag_oid"] = self.tag_oid
|
||||
request["binding_schema"] = self.binding_schema or "aios.release-binding.v2"
|
||||
if self.source_registry_main_oid is not None:
|
||||
request.update(
|
||||
{
|
||||
"source_registry_main_oid": self.source_registry_main_oid,
|
||||
"source_registry_path": self.source_registry_path
|
||||
or f"versions/{self.version}.json",
|
||||
"source_registry_sha256": self.source_registry_sha256,
|
||||
}
|
||||
)
|
||||
return request
|
||||
|
||||
|
||||
def canonical_json(value: object) -> bytes:
|
||||
@@ -233,23 +168,6 @@ def run_git(git_dir: Path, *args: str, check: bool = True) -> subprocess.Complet
|
||||
)
|
||||
|
||||
|
||||
def run_git_bytes(
|
||||
git_dir: Path,
|
||||
*args: str,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
completed = subprocess.run(
|
||||
["git", f"--git-dir={git_dir}", *args],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
if check and completed.returncode != 0:
|
||||
raise ReleaseBlocked(completed.stderr.decode("utf-8", "replace").strip())
|
||||
return completed
|
||||
|
||||
|
||||
def verify_git_lineage(git_dir: Path, binding: Binding) -> dict[str, Any]:
|
||||
authority_main = run_git(git_dir, "rev-parse", "refs/heads/main").stdout.strip()
|
||||
if not OID_RE.fullmatch(authority_main):
|
||||
@@ -291,45 +209,7 @@ def verify_git_lineage(git_dir: Path, binding: Binding) -> dict[str, Any]:
|
||||
if not target_in_evidence:
|
||||
raise ReleaseBlocked("approved target is not an ancestor of evidence closure")
|
||||
|
||||
source_main = None
|
||||
if binding.source_registry_main_oid is not None:
|
||||
source_main = binding.source_registry_main_oid
|
||||
source_type = run_git(git_dir, "cat-file", "-t", source_main).stdout.strip()
|
||||
if source_type != "commit":
|
||||
raise ReleaseBlocked("source_registry_main_oid is not a commit")
|
||||
target_in_source_main = run_git(
|
||||
git_dir,
|
||||
"merge-base",
|
||||
"--is-ancestor",
|
||||
binding.target_oid,
|
||||
source_main,
|
||||
check=False,
|
||||
).returncode == 0
|
||||
if not target_in_source_main:
|
||||
raise ManualOwnerReviewRequired(
|
||||
"approved target is not an ancestor of source registry main"
|
||||
)
|
||||
expected_tag_oid = binding.tag_oid
|
||||
if expected_tag_oid is None and binding.version == "V2R2C1":
|
||||
expected_tag_oid = V2R2C1_TAG_OID
|
||||
if expected_tag_oid is not None:
|
||||
local_tag = run_git(
|
||||
git_dir,
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
f"refs/tags/{binding.version}",
|
||||
).stdout.strip()
|
||||
if local_tag != expected_tag_oid:
|
||||
raise ManualOwnerReviewRequired(
|
||||
"future release binding tag object differs from local tag"
|
||||
)
|
||||
if run_git(git_dir, "cat-file", "-t", local_tag).stdout.strip() != "tag":
|
||||
raise ReleaseBlocked("release tag is not annotated")
|
||||
peeled = run_git(git_dir, "rev-parse", "--verify", f"{local_tag}^{{commit}}").stdout.strip()
|
||||
if peeled != binding.target_oid:
|
||||
raise ManualOwnerReviewRequired("release tag peeled target mismatch")
|
||||
|
||||
result = {
|
||||
return {
|
||||
"authority_main_oid": authority_main,
|
||||
"target_oid": binding.target_oid,
|
||||
"target_is_ancestor_of_main": True,
|
||||
@@ -337,231 +217,9 @@ def verify_git_lineage(git_dir: Path, binding: Binding) -> dict[str, Any]:
|
||||
"evidence_object_type": evidence_type,
|
||||
"target_is_ancestor_of_evidence": True,
|
||||
}
|
||||
if source_main is not None:
|
||||
result["source_registry_main_oid"] = source_main
|
||||
result["target_is_ancestor_of_source_registry_main"] = True
|
||||
return result
|
||||
|
||||
|
||||
def _source_registry_git_provenance(
|
||||
git_dir: Path,
|
||||
binding: Binding,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Read M:path and C:path independently of the artifact metadata."""
|
||||
if binding.source_registry_main_oid is None:
|
||||
return None
|
||||
verify_authoritative_repository(git_dir)
|
||||
source_main = binding.source_registry_main_oid
|
||||
source_path = binding.source_registry_path or f"versions/{binding.version}.json"
|
||||
authority_main = run_git(git_dir, "rev-parse", "--verify", "refs/heads/main").stdout.strip()
|
||||
if run_git(git_dir, "cat-file", "-t", source_main).stdout.strip() != "commit":
|
||||
raise ReleaseBlocked("source_registry_main_oid is not a commit")
|
||||
if run_git(git_dir, "cat-file", "-t", authority_main).stdout.strip() != "commit":
|
||||
raise ReleaseBlocked("Authority main OID is not a commit")
|
||||
if run_git(
|
||||
git_dir,
|
||||
"merge-base",
|
||||
"--is-ancestor",
|
||||
source_main,
|
||||
authority_main,
|
||||
check=False,
|
||||
).returncode != 0:
|
||||
raise ManualOwnerReviewRequired(
|
||||
"source registry main is not an ancestor of current Authority main"
|
||||
)
|
||||
source_blob = run_git_bytes(git_dir, "show", f"{source_main}:{source_path}").stdout
|
||||
current_blob = run_git_bytes(git_dir, "show", f"{authority_main}:{source_path}").stdout
|
||||
if source_blob != current_blob:
|
||||
raise ManualOwnerReviewRequired(
|
||||
"current Authority main source registry entry differs from pinned M"
|
||||
)
|
||||
try:
|
||||
source = json.loads(source_blob.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ReleaseBlocked("Git source registry entry is invalid") from exc
|
||||
expected_tag_oid = binding.tag_oid
|
||||
if expected_tag_oid is None and binding.version == "V2R2C1":
|
||||
expected_tag_oid = V2R2C1_TAG_OID
|
||||
expected = {
|
||||
"version": binding.version,
|
||||
"repository": "aios/aios",
|
||||
"tag_oid": expected_tag_oid,
|
||||
"target_oid": binding.target_oid,
|
||||
"peeled_target_oid": binding.target_oid,
|
||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||
"approval_plan_id": binding.approval_plan_id,
|
||||
}
|
||||
if not isinstance(source, dict) or source.get("schema") not in {
|
||||
"aios.source-registry.v1",
|
||||
"aios.production-registry.v2",
|
||||
}:
|
||||
raise ReleaseBlocked("Git source registry schema is invalid")
|
||||
if expected_tag_oid is None or any(source.get(key) != value for key, value in expected.items()):
|
||||
raise ReleaseBlocked("Git source registry identity mismatch")
|
||||
if source.get("tag_object") != expected_tag_oid:
|
||||
raise ReleaseBlocked("Git source registry tag object mismatch")
|
||||
digest = hashlib.sha256(source_blob).hexdigest()
|
||||
if digest != binding.source_registry_sha256:
|
||||
raise ReleaseBlocked("Git source registry SHA-256 mismatch")
|
||||
return {
|
||||
"source_registry_main_oid": source_main,
|
||||
"current_main_oid": authority_main,
|
||||
"source_registry_path": source_path,
|
||||
"source_registry_bytes": source_blob,
|
||||
"source_registry_sha256": digest,
|
||||
"source_registry_identity_verified": True,
|
||||
"source_registry_main_converged": True,
|
||||
}
|
||||
|
||||
|
||||
def _verify_artifact_members(
|
||||
path: Path,
|
||||
required: set[str],
|
||||
binding: Binding | None = None,
|
||||
git_provenance: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Verify archive safety and independently verify packaged release data."""
|
||||
# Python tarfile may not have zstd support on the target host, so
|
||||
# decompress into a private temporary tar and inspect it there.
|
||||
with tempfile.TemporaryDirectory(prefix="aios-artifact-verify-") as temp_dir:
|
||||
tar_path = Path(temp_dir) / "release.tar"
|
||||
with tar_path.open("wb") as output:
|
||||
completed = subprocess.run(
|
||||
["zstd", "-dc", str(path)],
|
||||
check=False,
|
||||
stdout=output,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=60,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise ReleaseBlocked("artifact zstd decompression failed")
|
||||
import tarfile
|
||||
|
||||
with tarfile.open(tar_path, "r:") as archive:
|
||||
members = archive.getmembers()
|
||||
names: set[str] = set()
|
||||
for member in members:
|
||||
candidate = Path(member.name)
|
||||
if (
|
||||
candidate.is_absolute()
|
||||
or ".." in candidate.parts
|
||||
or member.issym()
|
||||
or member.islnk()
|
||||
or not (member.isfile() or member.isdir())
|
||||
):
|
||||
raise ReleaseBlocked(f"unsafe artifact member: {member.name}")
|
||||
if member.name in names:
|
||||
raise ReleaseBlocked(f"duplicate artifact member: {member.name}")
|
||||
names.add(member.name)
|
||||
source_path = None
|
||||
if binding is not None and binding.version != "V2R1C7":
|
||||
source_path = binding.source_registry_path or f"versions/{binding.version}.json"
|
||||
required_members = set(required)
|
||||
if binding is not None and binding.source_registry_main_oid is not None:
|
||||
required_members.add("payload/BUILD-METADATA.json")
|
||||
if source_path is not None and binding is not None:
|
||||
required_members.add(f"payload/{source_path}")
|
||||
missing = sorted(required_members - names)
|
||||
if missing:
|
||||
raise ReleaseBlocked(
|
||||
"artifact missing required members: " + ",".join(missing)
|
||||
)
|
||||
verification: dict[str, Any] = {}
|
||||
if binding is not None and source_path is not None:
|
||||
member_name = f"payload/{source_path}"
|
||||
member = archive.getmember(member_name)
|
||||
stream = archive.extractfile(member)
|
||||
if stream is None:
|
||||
raise ReleaseBlocked("source registry member is unreadable")
|
||||
raw = stream.read()
|
||||
try:
|
||||
source = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ReleaseBlocked("source registry member is invalid") from exc
|
||||
canonical_source = (
|
||||
json.dumps(source, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
|
||||
).encode("utf-8")
|
||||
if raw != canonical_source:
|
||||
raise ReleaseBlocked("source registry member is not canonical JSON")
|
||||
expected_tag_oid = binding.tag_oid
|
||||
if expected_tag_oid is None and binding.version == "V2R2C1":
|
||||
expected_tag_oid = V2R2C1_TAG_OID
|
||||
expected = {
|
||||
"version": binding.version,
|
||||
"repository": "aios/aios",
|
||||
"tag_oid": expected_tag_oid,
|
||||
"target_oid": binding.target_oid,
|
||||
"peeled_target_oid": binding.target_oid,
|
||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||
"approval_plan_id": binding.approval_plan_id,
|
||||
}
|
||||
if expected_tag_oid is None:
|
||||
raise ReleaseBlocked("future release binding tag object is required")
|
||||
if not isinstance(source, dict) or any(
|
||||
source.get(key) != value for key, value in expected.items()
|
||||
):
|
||||
raise ReleaseBlocked("packaged source registry identity mismatch")
|
||||
if source.get("tag_object") != expected_tag_oid:
|
||||
raise ReleaseBlocked("packaged source registry tag object mismatch")
|
||||
source_digest = hashlib.sha256(raw).hexdigest()
|
||||
if git_provenance is not None:
|
||||
if raw != git_provenance["source_registry_bytes"]:
|
||||
raise ReleaseBlocked(
|
||||
"packaged source registry differs from Git M:path"
|
||||
)
|
||||
if source_digest != git_provenance["source_registry_sha256"]:
|
||||
raise ReleaseBlocked(
|
||||
"packaged source registry SHA-256 differs from Git M:path"
|
||||
)
|
||||
verification.update(
|
||||
{
|
||||
"source_registry_path": source_path,
|
||||
"source_registry_sha256": source_digest,
|
||||
"source_registry_identity_verified": True,
|
||||
}
|
||||
)
|
||||
if binding.source_registry_sha256 is not None:
|
||||
if source_digest != binding.source_registry_sha256:
|
||||
raise ReleaseBlocked("packaged source registry SHA-256 mismatch")
|
||||
metadata_member = archive.getmember("payload/BUILD-METADATA.json")
|
||||
metadata_stream = archive.extractfile(metadata_member)
|
||||
if metadata_stream is None:
|
||||
raise ReleaseBlocked("BUILD-METADATA.json is unreadable")
|
||||
try:
|
||||
metadata = json.loads(metadata_stream.read().decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ReleaseBlocked("BUILD-METADATA.json is invalid") from exc
|
||||
metadata_expected = {
|
||||
"release_target_oid": binding.target_oid,
|
||||
"source_registry_main_oid": binding.source_registry_main_oid,
|
||||
"source_registry_path": source_path,
|
||||
"source_registry_sha256": binding.source_registry_sha256,
|
||||
}
|
||||
if not isinstance(metadata, dict) or any(
|
||||
metadata.get(key) != value
|
||||
for key, value in metadata_expected.items()
|
||||
):
|
||||
raise ReleaseBlocked("BUILD-METADATA source provenance mismatch")
|
||||
verification["build_metadata_provenance_verified"] = True
|
||||
if git_provenance is not None:
|
||||
verification.update(
|
||||
{
|
||||
"git_source_registry_member_verified": True,
|
||||
"git_source_registry_main_converged": True,
|
||||
"git_source_registry_sha256": git_provenance[
|
||||
"source_registry_sha256"
|
||||
],
|
||||
}
|
||||
)
|
||||
return verification
|
||||
|
||||
|
||||
def verify_artifact(
|
||||
binding: Binding,
|
||||
*,
|
||||
repository: Path | str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
def verify_artifact(binding: Binding) -> dict[str, Any]:
|
||||
path = Path(binding.artifact_path)
|
||||
if path.stat().st_size != binding.artifact_size:
|
||||
raise ReleaseBlocked("artifact size mismatch")
|
||||
@@ -569,32 +227,46 @@ def verify_artifact(
|
||||
if actual != binding.artifact_sha256:
|
||||
raise ReleaseBlocked("artifact SHA-256 mismatch")
|
||||
|
||||
required = {
|
||||
"V2R1C7": REQUIRED_V2R1C7_MEMBERS,
|
||||
"V2R2C1": REQUIRED_V2R2C1_MEMBERS,
|
||||
}.get(binding.version)
|
||||
member_verification: dict[str, Any] = {}
|
||||
git_provenance = None
|
||||
if binding.source_registry_main_oid is not None:
|
||||
git_provenance = _source_registry_git_provenance(
|
||||
Path(repository) if repository is not None else Path(
|
||||
configured_authoritative_git_dir()
|
||||
),
|
||||
binding,
|
||||
)
|
||||
if required or binding.source_registry_main_oid is not None:
|
||||
member_verification = _verify_artifact_members(
|
||||
path,
|
||||
required or set(),
|
||||
binding,
|
||||
git_provenance,
|
||||
)
|
||||
if binding.version == "V2R1C7":
|
||||
# Python tarfile may not have zstd support on the target host, so
|
||||
# decompress into a private temporary tar and inspect it there.
|
||||
with tempfile.TemporaryDirectory(prefix="aios-artifact-verify-") as temp_dir:
|
||||
tar_path = Path(temp_dir) / "release.tar"
|
||||
with tar_path.open("wb") as output:
|
||||
completed = subprocess.run(
|
||||
["zstd", "-dc", str(path)],
|
||||
check=False,
|
||||
stdout=output,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=60,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise ReleaseBlocked("artifact zstd decompression failed")
|
||||
import tarfile
|
||||
with tarfile.open(tar_path, "r:") as archive:
|
||||
names: set[str] = set()
|
||||
for member in archive.getmembers():
|
||||
candidate = Path(member.name)
|
||||
if (
|
||||
candidate.is_absolute()
|
||||
or ".." in candidate.parts
|
||||
or member.issym()
|
||||
or member.islnk()
|
||||
or not (member.isfile() or member.isdir())
|
||||
):
|
||||
raise ReleaseBlocked(f"unsafe artifact member: {member.name}")
|
||||
names.add(member.name)
|
||||
missing = sorted(REQUIRED_V2R1C7_MEMBERS - names)
|
||||
if missing:
|
||||
raise ReleaseBlocked(
|
||||
"artifact missing required members: " + ",".join(missing)
|
||||
)
|
||||
return {
|
||||
"artifact_path": str(path),
|
||||
"artifact_sha256": actual,
|
||||
"artifact_size": binding.artifact_size,
|
||||
"verified": True,
|
||||
**member_verification,
|
||||
}
|
||||
|
||||
|
||||
@@ -610,18 +282,6 @@ def verify_authorization(receipt: Mapping[str, Any], binding: Binding) -> dict[s
|
||||
"artifact_sha256": binding.artifact_sha256,
|
||||
"approval_plan_id": binding.approval_plan_id,
|
||||
}
|
||||
if binding.tag_oid is not None:
|
||||
expected["tag_oid"] = binding.tag_oid
|
||||
expected["binding_schema"] = binding.binding_schema or "aios.release-binding.v2"
|
||||
if binding.source_registry_main_oid is not None:
|
||||
expected.update(
|
||||
{
|
||||
"source_registry_main_oid": binding.source_registry_main_oid,
|
||||
"source_registry_path": binding.source_registry_path
|
||||
or f"versions/{binding.version}.json",
|
||||
"source_registry_sha256": binding.source_registry_sha256,
|
||||
}
|
||||
)
|
||||
for key, value in expected.items():
|
||||
if receipt.get(key) != value:
|
||||
raise ManualOwnerReviewRequired(f"Owner authorization mismatch: {key}")
|
||||
@@ -773,29 +433,13 @@ def tag_step(command: Sequence[str], binding: Binding) -> dict[str, Any]:
|
||||
tag_oid = probe.get("tag_oid")
|
||||
if not isinstance(tag_oid, str) or not OID_RE.fullmatch(tag_oid):
|
||||
raise ReleaseBlocked("verified annotated tag object OID missing")
|
||||
if binding.tag_oid is not None and tag_oid != binding.tag_oid:
|
||||
raise ManualOwnerReviewRequired(
|
||||
"same-name tag object differs from the future release binding"
|
||||
)
|
||||
return dict(probe)
|
||||
|
||||
|
||||
def deploy_step(command: Sequence[str], binding: Binding) -> dict[str, Any]:
|
||||
def require_runtime_registry(result: Mapping[str, Any]) -> None:
|
||||
if binding.source_registry_main_oid is None:
|
||||
return
|
||||
if (
|
||||
result.get("runtime_bundle_contains_current_version") is not True
|
||||
or result.get("runtime_registry_converged") is not True
|
||||
):
|
||||
raise ReconciliationRequired(
|
||||
"runtime bundle/runtime registry convergence was not independently verified"
|
||||
)
|
||||
|
||||
probe = run_adapter(command, binding.request("probe"))
|
||||
require_exact_response(probe, binding)
|
||||
if probe.get("status") == "ACTIVE_VERIFIED":
|
||||
require_runtime_registry(probe)
|
||||
return dict(probe)
|
||||
if probe.get("status") == "CONFLICT":
|
||||
raise ManualOwnerReviewRequired("production runtime has conflicting binding")
|
||||
@@ -814,30 +458,9 @@ def deploy_step(command: Sequence[str], binding: Binding) -> dict[str, Any]:
|
||||
or probe.get("production_deployed") is not True
|
||||
):
|
||||
raise ReconciliationRequired("production health contract is not ACTIVE")
|
||||
require_runtime_registry(probe)
|
||||
return dict(probe)
|
||||
|
||||
|
||||
def verify_post_deploy_source_registry(
|
||||
repository: Path,
|
||||
binding: Binding,
|
||||
) -> dict[str, Any]:
|
||||
"""Re-read authoritative Git after runtime deployment and before closure."""
|
||||
if binding.source_registry_main_oid is None:
|
||||
return {
|
||||
"source_registry_converged": "not_required",
|
||||
"source_registry_check": "not_required_for_legacy_binding",
|
||||
}
|
||||
provenance = _source_registry_git_provenance(repository, binding)
|
||||
if provenance is None:
|
||||
raise ReconciliationRequired("authoritative source registry was not checked")
|
||||
return {
|
||||
**provenance,
|
||||
"source_registry_converged": True,
|
||||
"source_registry_check": "post_deploy_authoritative_git_read",
|
||||
}
|
||||
|
||||
|
||||
def finalize_step(
|
||||
command: Sequence[str],
|
||||
binding: Binding,
|
||||
@@ -878,7 +501,7 @@ def execute(
|
||||
) -> dict[str, Any]:
|
||||
authorization = verify_authorization(secure_json(authorization_path), binding)
|
||||
lineage = verify_git_lineage(repository, binding)
|
||||
artifact = verify_artifact(binding, repository=repository)
|
||||
artifact = verify_artifact(binding)
|
||||
state = DurableState(state_root, binding)
|
||||
with FileLock(state_root / "executor.lock"):
|
||||
state.record(
|
||||
@@ -895,13 +518,6 @@ def execute(
|
||||
runtime = deploy_step(deploy_command, binding)
|
||||
state.record("RUNTIME_VERIFIED", "runtime", runtime)
|
||||
|
||||
# The runtime adapter proves only runtime facts. Re-read M:path
|
||||
# and C:path from authoritative Git immediately before Registry
|
||||
# and Baseline closure so a post-preflight main change cannot be
|
||||
# mistaken for source convergence.
|
||||
source_registry = verify_post_deploy_source_registry(repository, binding)
|
||||
state.record("SOURCE_REGISTRY_VERIFIED", "source_registry", source_registry)
|
||||
|
||||
registry = finalize_step(
|
||||
registry_command,
|
||||
binding,
|
||||
@@ -931,21 +547,10 @@ def execute(
|
||||
require_exact_response(value, binding)
|
||||
if final_runtime.get("status") != "ACTIVE_VERIFIED":
|
||||
raise ReconciliationRequired("final runtime readback failed")
|
||||
if binding.source_registry_main_oid is not None and not all(
|
||||
final_runtime.get(name) is True
|
||||
for name in (
|
||||
"runtime_bundle_contains_current_version",
|
||||
"runtime_registry_converged",
|
||||
)
|
||||
):
|
||||
raise ReconciliationRequired(
|
||||
"final runtime registry convergence readback failed"
|
||||
)
|
||||
if final_registry.get("status") != "VERIFIED":
|
||||
raise ReconciliationRequired("final Registry readback failed")
|
||||
if final_baseline.get("status") != "VERIFIED":
|
||||
raise ReconciliationRequired("final Baseline readback failed")
|
||||
final_source_registry = verify_post_deploy_source_registry(repository, binding)
|
||||
final = {
|
||||
"status": "PRODUCTION_ACCEPTED",
|
||||
"version": binding.version,
|
||||
@@ -956,7 +561,6 @@ def execute(
|
||||
"tag_oid": tag["tag_oid"],
|
||||
"tag": final_tag,
|
||||
"runtime": final_runtime,
|
||||
"source_registry": final_source_registry,
|
||||
"registry": final_registry,
|
||||
"baseline": final_baseline,
|
||||
"force_push_used": False,
|
||||
@@ -996,11 +600,6 @@ def parser() -> argparse.ArgumentParser:
|
||||
p.add_argument("--evidence-closure-oid", required=True)
|
||||
p.add_argument("--artifact-sha256", required=True)
|
||||
p.add_argument("--approval-plan-id", required=True)
|
||||
p.add_argument("--tag-oid")
|
||||
p.add_argument("--binding-schema")
|
||||
p.add_argument("--source-registry-main-oid")
|
||||
p.add_argument("--source-registry-path")
|
||||
p.add_argument("--source-registry-sha256")
|
||||
p.add_argument("--production-service", required=True)
|
||||
p.add_argument("--production-port", required=True, type=int)
|
||||
p.add_argument("--repository-identity", default="aios/aios")
|
||||
@@ -1024,11 +623,6 @@ def main() -> int:
|
||||
production_service=args.production_service,
|
||||
production_port=args.production_port,
|
||||
repository_identity=args.repository_identity,
|
||||
tag_oid=args.tag_oid,
|
||||
binding_schema=args.binding_schema,
|
||||
source_registry_main_oid=args.source_registry_main_oid,
|
||||
source_registry_path=args.source_registry_path,
|
||||
source_registry_sha256=args.source_registry_sha256,
|
||||
)
|
||||
try:
|
||||
result = execute(
|
||||
|
||||
@@ -64,40 +64,6 @@ class PublicationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class PublicationValueError(PublicationError, ValueError):
|
||||
def __init__(self, code: str, message: str):
|
||||
self.code = code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class InvalidReleaseVersion(PublicationValueError):
|
||||
def __init__(self):
|
||||
super().__init__("INVALID_RELEASE_VERSION", "invalid release version")
|
||||
|
||||
|
||||
class GiteaConfigurationError(PublicationValueError):
|
||||
def __init__(self, code: str):
|
||||
super().__init__(code, code)
|
||||
|
||||
|
||||
class GiteaUnavailableError(PublicationError):
|
||||
def __init__(self, code: str = "GITEA_UNAVAILABLE"):
|
||||
self.code = code
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
class GiteaTagNotFound(PublicationError):
|
||||
def __init__(self):
|
||||
self.code = "TAG_NOT_FOUND"
|
||||
super().__init__(self.code)
|
||||
|
||||
|
||||
class TagTargetMismatch(PublicationError):
|
||||
def __init__(self):
|
||||
self.code = "TAG_TARGET_MISMATCH"
|
||||
super().__init__(self.code)
|
||||
|
||||
|
||||
class PublicationConflict(PublicationError):
|
||||
pass
|
||||
|
||||
@@ -119,7 +85,7 @@ class PublicationManualOwnerReviewRequired(
|
||||
def validate_version(version: str) -> str:
|
||||
value = str(version or "")
|
||||
if not VERSION_RE.fullmatch(value):
|
||||
raise InvalidReleaseVersion()
|
||||
raise ValueError("invalid release version")
|
||||
return value
|
||||
|
||||
|
||||
@@ -436,40 +402,6 @@ class GitTagBackend:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GiteaClientConfig:
|
||||
api_url: str
|
||||
repository: str
|
||||
token_file: Path
|
||||
remote: str = "gitea"
|
||||
token_line: int = 1
|
||||
|
||||
@classmethod
|
||||
def from_authoritative_environment(cls, environ: dict[str, str] | None = None) -> "GiteaClientConfig":
|
||||
values = os.environ if environ is None else environ
|
||||
api_url = str(values.get("AIOS_GITOPS_GITEA_API_URL", "")).strip()
|
||||
repository = str(values.get("AIOS_GITOPS_GITEA_REPOSITORY", "")).strip()
|
||||
token_ref = str(values.get("AIOS_GITOPS_GITEA_TOKEN_FILE", "")).strip()
|
||||
if not api_url:
|
||||
raise GiteaConfigurationError("GITEA_API_URL_MISSING")
|
||||
if not api_url.startswith(("https://", "http://")):
|
||||
raise GiteaConfigurationError("GITEA_API_URL_INVALID")
|
||||
if not repository:
|
||||
raise GiteaConfigurationError("GITEA_REPOSITORY_MISSING")
|
||||
if repository.count("/") != 1 or repository.startswith("/"):
|
||||
raise GiteaConfigurationError("GITEA_REPOSITORY_INVALID")
|
||||
if not token_ref:
|
||||
raise GiteaConfigurationError("GITEA_TOKEN_REFERENCE_MISSING")
|
||||
token_file = Path(token_ref)
|
||||
if not token_file.is_absolute():
|
||||
raise GiteaConfigurationError("GITEA_TOKEN_REFERENCE_INVALID")
|
||||
remote = str(values.get("AIOS_GITOPS_GITEA_REMOTE", "gitea")).strip() or "gitea"
|
||||
token_line = int(str(values.get("AIOS_GITOPS_GITEA_TOKEN_LINE", "1")))
|
||||
if token_line < 1:
|
||||
raise GiteaConfigurationError("GITEA_TOKEN_REFERENCE_INVALID")
|
||||
return cls(api_url, repository, token_file, remote, token_line)
|
||||
|
||||
|
||||
class GiteaTagClient:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -479,37 +411,23 @@ class GiteaTagClient:
|
||||
timeout: float = 15,
|
||||
token_line: int = 1,
|
||||
):
|
||||
self.api_url = str(api_url or "").rstrip("/")
|
||||
self.repository = str(repository or "").strip("/")
|
||||
self.token_file = Path(token_file)
|
||||
self.api_url = api_url.rstrip("/")
|
||||
self.repository = repository.strip("/")
|
||||
self.token_file = token_file
|
||||
self.timeout = timeout
|
||||
self.token_line = token_line
|
||||
if not self.api_url:
|
||||
raise GiteaConfigurationError("GITEA_API_URL_MISSING")
|
||||
if not self.api_url.startswith(("https://", "http://")):
|
||||
raise GiteaConfigurationError("GITEA_API_URL_INVALID")
|
||||
if not self.repository:
|
||||
raise GiteaConfigurationError("GITEA_REPOSITORY_MISSING")
|
||||
raise ValueError("Gitea API URL must be HTTP(S)")
|
||||
if self.repository.count("/") != 1:
|
||||
raise GiteaConfigurationError("GITEA_REPOSITORY_INVALID")
|
||||
if not str(token_file) or not self.token_file.is_absolute():
|
||||
raise GiteaConfigurationError("GITEA_TOKEN_REFERENCE_MISSING")
|
||||
|
||||
@classmethod
|
||||
def from_authoritative_environment(cls, environ: dict[str, str] | None = None) -> "GiteaTagClient":
|
||||
config = GiteaClientConfig.from_authoritative_environment(environ)
|
||||
return cls(config.api_url, config.repository, config.token_file, token_line=config.token_line)
|
||||
raise ValueError("Gitea repository must be owner/name")
|
||||
|
||||
def _get(self, path: str) -> Any:
|
||||
try:
|
||||
lines = self.token_file.read_text(encoding="utf-8").splitlines()
|
||||
except (OSError, UnicodeError) as exc:
|
||||
raise GiteaConfigurationError("GITEA_TOKEN_REFERENCE_UNAVAILABLE") from exc
|
||||
lines = self.token_file.read_text(encoding="utf-8").splitlines()
|
||||
if self.token_line < 1 or self.token_line > len(lines):
|
||||
raise GiteaConfigurationError("GITEA_TOKEN_REFERENCE_INVALID")
|
||||
raise RuntimeError("Gitea credential reference has no configured token line")
|
||||
token = lines[self.token_line - 1].strip()
|
||||
if not token:
|
||||
raise GiteaConfigurationError("GITEA_TOKEN_REFERENCE_INVALID")
|
||||
raise RuntimeError("Gitea token file is empty")
|
||||
request = urllib.request.Request(
|
||||
f"{self.api_url}{path}",
|
||||
headers={
|
||||
@@ -521,12 +439,10 @@ class GiteaTagClient:
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
raise GiteaTagNotFound() from exc
|
||||
raise GiteaUnavailableError() from exc
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
raise GiteaUnavailableError() from exc
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as exc:
|
||||
status = getattr(exc, "code", None)
|
||||
detail = f" HTTP {status}" if status else ""
|
||||
raise RuntimeError(f"Gitea API unavailable{detail}") from exc
|
||||
|
||||
def repository_identity(self) -> str:
|
||||
repository = self._get(f"/repos/{self.repository}")
|
||||
@@ -1034,12 +950,6 @@ class TagPublisher:
|
||||
listing = state.get("gitea_listing_visible") is True
|
||||
expected_tag_oid = state.get("tag_oid")
|
||||
registry_tag_oid = registry.get("tag_oid") or registry.get("tag_object")
|
||||
target_mismatch = any(
|
||||
isinstance(value, tuple)
|
||||
and len(value) > 1
|
||||
and value[1] not in (None, request.target_oid)
|
||||
for value in (remote, api)
|
||||
)
|
||||
checks = {
|
||||
"fingerprint": all(
|
||||
baseline.get(field) == getattr(request, field)
|
||||
@@ -1064,7 +974,7 @@ class TagPublisher:
|
||||
and registry.get("enabled") is True,
|
||||
}
|
||||
status = "PASS" if all(checks.values()) else "FAIL"
|
||||
result = {
|
||||
return {
|
||||
"version": request.version,
|
||||
"status": status,
|
||||
"checks": checks,
|
||||
@@ -1076,19 +986,8 @@ class TagPublisher:
|
||||
"gitea_listing_visible": listing,
|
||||
"repository_identity": repository_identity,
|
||||
}
|
||||
if target_mismatch:
|
||||
result["fail_closed"] = True
|
||||
result["error"] = TagTargetMismatch().code
|
||||
return result
|
||||
except PublicationManualOwnerReviewRequired:
|
||||
raise
|
||||
except PublicationError as exc:
|
||||
return {
|
||||
"version": request.version,
|
||||
"status": "UNKNOWN",
|
||||
"fail_closed": True,
|
||||
"error": str(getattr(exc, "code", exc)),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"version": request.version,
|
||||
|
||||
@@ -29,18 +29,9 @@ from gitea_tag_publication import (
|
||||
PublicationRequest,
|
||||
TagPublisher,
|
||||
)
|
||||
from version_registry import (
|
||||
RegistryConvergenceError,
|
||||
assert_source_registry_convergence,
|
||||
resolve_source_registry_main_oid,
|
||||
source_registry_snapshot,
|
||||
verify_authoritative_repository,
|
||||
verify_source_registry_git_identity,
|
||||
)
|
||||
|
||||
|
||||
VERSION = "V2R2C1"
|
||||
TAG_OID = "07fc2746b688f5f243f29b39de39f32edc3dd9d2"
|
||||
TARGET_OID = "998d30304787e31e5ae607c0bd86b5edb2b0e5ad"
|
||||
EVIDENCE_CLOSURE_OID = "a8b906d1bc24088c19647e98de6a679268f6d0a2"
|
||||
APPROVAL_PLAN_ID = "sha256:c0df52008ae7f64cec5ee2d28947aa7b892066c670ab8295e4549140b1a7500e"
|
||||
@@ -88,7 +79,6 @@ def atomic_json(path: Path, value: Mapping[str, Any], mode: int = 0o600) -> None
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseBinding:
|
||||
version: str = VERSION
|
||||
tag_oid: str = TAG_OID
|
||||
target_oid: str = TARGET_OID
|
||||
evidence_closure_oid: str = EVIDENCE_CLOSURE_OID
|
||||
approval_plan_id: str = APPROVAL_PLAN_ID
|
||||
@@ -99,8 +89,6 @@ class ReleaseBinding:
|
||||
def validate(self) -> None:
|
||||
if self.version != VERSION or self.repository != REPOSITORY:
|
||||
raise ManualOwnerReviewRequired("release binding is outside V2R2C1 scope")
|
||||
if not OID_RE.fullmatch(self.tag_oid) or self.tag_oid != TAG_OID:
|
||||
raise ManualOwnerReviewRequired("V2R2C1 tag_oid mismatch")
|
||||
if not OID_RE.fullmatch(self.target_oid) or self.target_oid != TARGET_OID:
|
||||
raise ManualOwnerReviewRequired("V2R2C1 target_oid mismatch")
|
||||
if not OID_RE.fullmatch(self.evidence_closure_oid) or self.evidence_closure_oid != EVIDENCE_CLOSURE_OID:
|
||||
@@ -204,48 +192,19 @@ def _ancestor(git_dir: Path, ancestor: str, descendant: str) -> bool:
|
||||
).returncode == 0
|
||||
|
||||
|
||||
def verify_lineage(
|
||||
git_dir: Path,
|
||||
binding: ReleaseBinding,
|
||||
*,
|
||||
authority_main_oid: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
def verify_lineage(git_dir: Path, binding: ReleaseBinding) -> dict[str, Any]:
|
||||
binding.validate()
|
||||
main_oid = authority_main_oid or _git(git_dir, "rev-parse", "refs/heads/main")
|
||||
if not OID_RE.fullmatch(main_oid) or _git(git_dir, "cat-file", "-t", main_oid) != "commit":
|
||||
raise ReleaseBlocked("authoritative main OID is not a commit")
|
||||
if _git(git_dir, "cat-file", "-t", binding.target_oid) != "commit":
|
||||
raise ReleaseBlocked("approved target_oid is not a commit")
|
||||
if _git(git_dir, "cat-file", "-t", binding.evidence_closure_oid) != "commit":
|
||||
raise ReleaseBlocked("evidence closure is not a commit")
|
||||
if not _ancestor(git_dir, binding.target_oid, binding.evidence_closure_oid):
|
||||
raise ManualOwnerReviewRequired("approved target is not an ancestor of evidence closure")
|
||||
if not _ancestor(git_dir, binding.target_oid, main_oid):
|
||||
raise ManualOwnerReviewRequired("approved target is not an ancestor of source registry main")
|
||||
return {
|
||||
"target_oid": binding.target_oid,
|
||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||
"target_is_ancestor_of_evidence": True,
|
||||
"main_oid": main_oid,
|
||||
"target_is_ancestor_of_main": True,
|
||||
}
|
||||
|
||||
|
||||
def verify_local_annotated_tag(git_dir: Path, binding: ReleaseBinding) -> dict[str, Any]:
|
||||
"""Verify the local authoritative tag object without creating or rewriting it."""
|
||||
tag_oid = _git(git_dir, "rev-parse", "--verify", f"refs/tags/{binding.version}")
|
||||
if tag_oid != binding.tag_oid:
|
||||
raise ManualOwnerReviewRequired("local annotated tag object mismatch")
|
||||
if _git(git_dir, "cat-file", "-t", tag_oid) != "tag":
|
||||
raise ReleaseBlocked("release tag is not annotated")
|
||||
peeled = _git(git_dir, "rev-parse", "--verify", f"{tag_oid}^{{commit}}")
|
||||
if peeled != binding.target_oid:
|
||||
raise ManualOwnerReviewRequired("local annotated tag target mismatch")
|
||||
return {
|
||||
"tag_oid": tag_oid,
|
||||
"tag_object_type": "tag",
|
||||
"peeled_target_oid": peeled,
|
||||
"target_oid": binding.target_oid,
|
||||
"main_oid": _git(git_dir, "rev-parse", "refs/heads/main"),
|
||||
}
|
||||
|
||||
|
||||
@@ -289,41 +248,9 @@ def build_production_artifact(
|
||||
binding: ReleaseBinding,
|
||||
output_root: Path,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a deterministic artifact from composite immutable provenance.
|
||||
|
||||
Release payload files are read from target ``T``. The source registry is
|
||||
read once from the pinned main/convergence commit ``M`` and its exact blob
|
||||
bytes are reused for packaging and all recorded digests.
|
||||
"""
|
||||
"""Build a deterministic executable V2R2C1 health artifact from target_oid."""
|
||||
binding.validate()
|
||||
verify_authoritative_repository(git_dir)
|
||||
source_registry_main_oid = resolve_source_registry_main_oid(
|
||||
git_dir,
|
||||
main_ref="refs/heads/main",
|
||||
)
|
||||
verify_lineage(git_dir, binding, authority_main_oid=source_registry_main_oid)
|
||||
tag_lineage = verify_local_annotated_tag(git_dir, binding)
|
||||
try:
|
||||
source_snapshot = source_registry_snapshot(
|
||||
git_dir,
|
||||
VERSION,
|
||||
source_registry_main_oid=source_registry_main_oid,
|
||||
expected_tag_oid=binding.tag_oid,
|
||||
expected_target_oid=binding.target_oid,
|
||||
expected_evidence_closure_oid=binding.evidence_closure_oid,
|
||||
expected_approval_plan_id=binding.approval_plan_id,
|
||||
)
|
||||
source_registry = source_snapshot["record"]
|
||||
source_registry_bytes = source_snapshot["bytes"]
|
||||
verify_source_registry_git_identity(git_dir, source_registry)
|
||||
except RegistryConvergenceError as exc:
|
||||
raise ReleaseBlocked(f"SOURCE_REGISTRY_NOT_CONVERGED:{exc}") from exc
|
||||
if source_snapshot["source_registry_main_oid"] != source_registry_main_oid:
|
||||
raise ReconciliationRequired("source registry main OID changed during build")
|
||||
if not _ancestor(git_dir, binding.target_oid, source_registry_main_oid):
|
||||
raise ManualOwnerReviewRequired("release target is not an ancestor of source registry main")
|
||||
source_registry_path = source_snapshot["source_registry_path"]
|
||||
source_registry_sha256 = source_snapshot["source_registry_sha256"]
|
||||
verify_lineage(git_dir, binding)
|
||||
epoch = _commit_epoch(git_dir, binding.target_oid)
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"aios-{VERSION}-{binding.target_oid[:12]}"
|
||||
@@ -334,15 +261,11 @@ def build_production_artifact(
|
||||
source_paths = {
|
||||
"active-runtime/app/deployment_state.py": "active-runtime/app/deployment_state.py",
|
||||
"active-runtime/app/v2r1c6_privacy_tools.py": "active-runtime/app/v2r1c6_privacy_tools.py",
|
||||
"versions/V2R2C1.json": "versions/V2R2C1.json",
|
||||
}
|
||||
for destination, source in source_paths.items():
|
||||
target = root / destination
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if destination == source_registry_path:
|
||||
target.write_bytes(source_registry_bytes)
|
||||
else:
|
||||
target.write_bytes(_payload_file(git_dir, binding.target_oid, source))
|
||||
target.write_bytes(_payload_file(git_dir, binding.target_oid, source))
|
||||
generated: dict[str, str] = {
|
||||
"VERSION": VERSION + "\n",
|
||||
"active-runtime/app/v2r2c1_health_service.py": (
|
||||
@@ -384,6 +307,16 @@ def build_production_artifact(
|
||||
"with urllib.request.urlopen(url, timeout=5) as r: print(json.dumps(json.load(r), sort_keys=True))\n"
|
||||
),
|
||||
"scripts/rollback_v2r2c1_release.sh": "#!/bin/sh\nset -eu\nprintf '%s\\n' 'rollback requires governed owner-approved executor'\nexit 2\n",
|
||||
"release-manifests/V2R2C1.json": json.dumps({
|
||||
"schema": "aios.release-source.v2",
|
||||
"version": VERSION,
|
||||
"repository": REPOSITORY,
|
||||
"target_oid": binding.target_oid,
|
||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||
"production_deployed": False,
|
||||
"deployment_state": "STAGING",
|
||||
"artifact_kind": "executable-production-health",
|
||||
}, sort_keys=True, separators=(",", ":")) + "\n",
|
||||
}
|
||||
for rel, text in generated.items():
|
||||
target = root / rel
|
||||
@@ -395,10 +328,6 @@ def build_production_artifact(
|
||||
"schema": "aios.release-artifact-build.v3",
|
||||
"version": VERSION,
|
||||
"repository": REPOSITORY,
|
||||
"release_target_oid": binding.target_oid,
|
||||
"source_registry_main_oid": source_registry_main_oid,
|
||||
"source_registry_path": source_registry_path,
|
||||
"source_registry_sha256": source_registry_sha256,
|
||||
"source_head_oid": binding.target_oid,
|
||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||
"source_date_epoch": epoch,
|
||||
@@ -408,31 +337,6 @@ def build_production_artifact(
|
||||
"tag_created": False,
|
||||
"baseline_changed": False,
|
||||
"secrets_included": False,
|
||||
"source_registry": {
|
||||
"path": source_registry_path,
|
||||
"version": source_registry["version"],
|
||||
"tag_oid": source_registry["tag_oid"],
|
||||
"target_oid": source_registry["target_oid"],
|
||||
"peeled_target_oid": source_registry["peeled_target_oid"],
|
||||
"evidence_closure_oid": source_registry["evidence_closure_oid"],
|
||||
"approval_plan_id": source_registry["approval_plan_id"],
|
||||
"source_registry_main_oid": source_registry_main_oid,
|
||||
"source_registry_sha256": source_registry_sha256,
|
||||
},
|
||||
"provenance": {
|
||||
"release_payload_source": {
|
||||
"commit_oid": binding.target_oid,
|
||||
"role": "release_target",
|
||||
},
|
||||
"source_registry_source": {
|
||||
"commit_oid": source_registry_main_oid,
|
||||
"path": source_registry_path,
|
||||
"sha256": source_registry_sha256,
|
||||
"role": "source_registry_convergence",
|
||||
},
|
||||
"release_target_is_ancestor_of_source_registry_main": True,
|
||||
"local_annotated_tag": tag_lineage,
|
||||
},
|
||||
}
|
||||
(root / "BUILD-METADATA.json").write_bytes(canonical(metadata))
|
||||
(root / "ROLLBACK-METADATA.json").write_bytes(canonical(json.loads((root / "deploy/aios-v2/v2r2c1-rollback.json").read_text(encoding="utf-8"))))
|
||||
@@ -440,10 +344,6 @@ def build_production_artifact(
|
||||
(root / "ARTIFACT-MANIFEST.json").write_bytes(canonical({
|
||||
"schema": "aios.release-artifact-manifest.v3",
|
||||
"version": VERSION,
|
||||
"release_target_oid": binding.target_oid,
|
||||
"source_registry_main_oid": source_registry_main_oid,
|
||||
"source_registry_path": source_registry_path,
|
||||
"source_registry_sha256": source_registry_sha256,
|
||||
"source_head_oid": binding.target_oid,
|
||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||
"repository": REPOSITORY,
|
||||
@@ -475,10 +375,6 @@ def build_production_artifact(
|
||||
"schema": "aios.release-artifact-receipt.v3",
|
||||
"version": VERSION,
|
||||
"repository": REPOSITORY,
|
||||
"release_target_oid": binding.target_oid,
|
||||
"source_registry_main_oid": source_registry_main_oid,
|
||||
"source_registry_path": source_registry_path,
|
||||
"source_registry_sha256": source_registry_sha256,
|
||||
"source_head_oid": binding.target_oid,
|
||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||
"artifact": str(output),
|
||||
@@ -489,14 +385,6 @@ def build_production_artifact(
|
||||
"production_deployed": False,
|
||||
"tag_created": False,
|
||||
"baseline_changed": False,
|
||||
"source_registry_path": source_registry_path,
|
||||
"source_registry_identity": source_registry,
|
||||
"provenance": {
|
||||
"release_payload_source": binding.target_oid,
|
||||
"source_registry_source": source_registry_main_oid,
|
||||
"release_target_is_ancestor_of_source_registry_main": True,
|
||||
"local_annotated_tag": tag_lineage,
|
||||
},
|
||||
}
|
||||
atomic_json(output.with_suffix(output.suffix + ".receipt.json"), receipt, mode=0o640)
|
||||
return receipt
|
||||
@@ -570,12 +458,6 @@ class GovernedProductionReleaseExecutor:
|
||||
self.state.fail("RECONCILIATION_REQUIRED", str(exc))
|
||||
raise ReconciliationRequired(str(exc)) from exc
|
||||
result = dict(verified)
|
||||
if result.get("tag_oid") != self.binding.tag_oid:
|
||||
self.state.fail(
|
||||
"MANUAL_OWNER_REVIEW_REQUIRED",
|
||||
"same-name tag object differs from the approved V2R2C1 tag",
|
||||
)
|
||||
raise ManualOwnerReviewRequired("approved tag object mismatch")
|
||||
self.state.record("TAG_VERIFIED", "tag", result)
|
||||
return {"status": "REMOTE_VERIFIED", **result}
|
||||
|
||||
@@ -596,8 +478,6 @@ class GovernedProductionReleaseExecutor:
|
||||
tag_state = self.state.value.get("completed", {}).get("tag")
|
||||
if not isinstance(tag_state, Mapping) or tag_state.get("state") not in {"GITEA_API_CONFIRMED", "REMOTE_VERIFIED"}:
|
||||
raise ReleaseBlocked("REMOTE_TAG_PROOF_REQUIRED")
|
||||
if tag_state.get("tag_oid") != self.binding.tag_oid:
|
||||
raise ManualOwnerReviewRequired("approved tag object mismatch")
|
||||
artifact = self.state.value.get("completed", {}).get("production_artifact")
|
||||
if not isinstance(artifact, Mapping):
|
||||
artifact = self.build_artifact(request)
|
||||
@@ -608,13 +488,6 @@ class GovernedProductionReleaseExecutor:
|
||||
"artifact_size": int(artifact["artifact_size"]),
|
||||
"artifact_sha256": artifact["artifact_sha256"],
|
||||
}
|
||||
for name in (
|
||||
"source_registry_main_oid",
|
||||
"source_registry_path",
|
||||
"source_registry_sha256",
|
||||
):
|
||||
if name in artifact:
|
||||
value[name] = artifact[name]
|
||||
# The production adapter is privileged and is run through the root
|
||||
# approved-release service boundary by the caller. This handler is
|
||||
# still useful for a root-owned staging invocation and remains
|
||||
@@ -634,21 +507,6 @@ class GovernedProductionReleaseExecutor:
|
||||
result["production_health"] = _health(self.config.health_url, self.binding, self.state.value.get("completed", {}).get("production_artifact", {}))
|
||||
result["registry"] = _read_json(Path(self.config.registry_root) / f"{VERSION}.json")
|
||||
result["baseline"] = _read_json(Path(self.config.baseline_path))
|
||||
try:
|
||||
assert_source = Path(self.config.registry_root) / f"{VERSION}.json"
|
||||
if not assert_source.is_file():
|
||||
raise RegistryConvergenceError("runtime_registry_entry_missing")
|
||||
assert_source_registry_convergence(
|
||||
assert_source,
|
||||
repo_git_dir=self.config.authoritative_git_dir,
|
||||
)
|
||||
source_check = {"status": "PASS", "path": str(assert_source)}
|
||||
except RegistryConvergenceError as exc:
|
||||
source_check = {"status": "FAIL", "error": str(exc)}
|
||||
result["source_registry"] = source_check
|
||||
result.setdefault("checks", {})["source_registry"] = source_check["status"] == "PASS"
|
||||
if result.get("status") == "PASS" and source_check["status"] != "PASS":
|
||||
result["status"] = "FAIL"
|
||||
return result
|
||||
|
||||
def acceptance(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
|
||||
@@ -7,7 +7,7 @@ Registry and authoritative runtime-anchor Baseline only.
|
||||
Registry is finalized before Baseline. Baseline apply independently verifies:
|
||||
- exact Authority annotated tag and peeled target
|
||||
- exact ACTIVE production health
|
||||
- exact runtime Registry record and source-registry convergence
|
||||
- exact V2R1C7 Registry record
|
||||
- exact previous Baseline file SHA-256 (CAS)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -25,7 +25,6 @@ import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from version_registry import assert_source_registry_convergence
|
||||
from typing import Any, Mapping
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
@@ -98,40 +97,11 @@ def load_request() -> dict[str, Any]:
|
||||
raise ValueError("approval_plan_id_invalid")
|
||||
if "tag_oid" in value and not OID_RE.fullmatch(str(value["tag_oid"])):
|
||||
raise ValueError("tag_oid_invalid")
|
||||
if "source_registry_main_oid" in value and not OID_RE.fullmatch(
|
||||
str(value["source_registry_main_oid"])
|
||||
):
|
||||
raise ValueError("source_registry_main_oid_invalid")
|
||||
if "source_registry_sha256" in value and not SHA_RE.fullmatch(
|
||||
str(value["source_registry_sha256"])
|
||||
):
|
||||
raise ValueError("source_registry_sha256_invalid")
|
||||
if "source_registry_path" in value:
|
||||
source_path = str(value["source_registry_path"])
|
||||
if (
|
||||
not source_path.startswith("versions/")
|
||||
or ".." in Path(source_path).parts
|
||||
or "\\" in source_path
|
||||
or not source_path.endswith(".json")
|
||||
):
|
||||
raise ValueError("source_registry_path_invalid")
|
||||
provenance = tuple(
|
||||
value.get(name)
|
||||
for name in (
|
||||
"source_registry_main_oid",
|
||||
"source_registry_path",
|
||||
"source_registry_sha256",
|
||||
)
|
||||
)
|
||||
if any(item is not None for item in provenance) and not all(
|
||||
item is not None for item in provenance
|
||||
):
|
||||
raise ValueError("source_registry_provenance_incomplete")
|
||||
return value
|
||||
|
||||
|
||||
def exact_response(value: Mapping[str, Any], status: str, **extra: Any) -> dict[str, Any]:
|
||||
result = {
|
||||
return {
|
||||
"status": status,
|
||||
"version": value["version"],
|
||||
"target_oid": value["target_oid"],
|
||||
@@ -140,14 +110,6 @@ def exact_response(value: Mapping[str, Any], status: str, **extra: Any) -> dict[
|
||||
"approval_plan_id": value["approval_plan_id"],
|
||||
**extra,
|
||||
}
|
||||
for name in (
|
||||
"source_registry_main_oid",
|
||||
"source_registry_path",
|
||||
"source_registry_sha256",
|
||||
):
|
||||
if name in value:
|
||||
result[name] = value[name]
|
||||
return result
|
||||
|
||||
|
||||
def git(repository: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
@@ -192,7 +154,7 @@ def health(url: str, value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
|
||||
|
||||
def registry_record(value: Mapping[str, Any], tag_oid: str, production_health: Mapping[str, Any]) -> dict[str, Any]:
|
||||
result = {
|
||||
return {
|
||||
"schema": "aios.production-registry.v2",
|
||||
"version": value["version"],
|
||||
"repository": "aios/aios",
|
||||
@@ -213,18 +175,10 @@ def registry_record(value: Mapping[str, Any], tag_oid: str, production_health: M
|
||||
"deployment_state": production_health["deployment_state"],
|
||||
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
}
|
||||
for name in (
|
||||
"source_registry_main_oid",
|
||||
"source_registry_path",
|
||||
"source_registry_sha256",
|
||||
):
|
||||
if name in value:
|
||||
result[name] = value[name]
|
||||
return result
|
||||
|
||||
|
||||
def registry_matches(record: Mapping[str, Any], value: Mapping[str, Any], tag_oid: str) -> bool:
|
||||
matches = all(
|
||||
return all(
|
||||
record.get(key) == expected
|
||||
for key, expected in {
|
||||
"version": value["version"],
|
||||
@@ -238,16 +192,11 @@ def registry_matches(record: Mapping[str, Any], value: Mapping[str, Any], tag_oi
|
||||
"production_deployed": True,
|
||||
}.items()
|
||||
) and record.get("registered") is True and record.get("enabled") is True
|
||||
return matches and all(
|
||||
record.get(name) == value.get(name)
|
||||
for name in ("source_registry_main_oid", "source_registry_path", "source_registry_sha256")
|
||||
if name in value
|
||||
)
|
||||
|
||||
|
||||
def baseline_matches(record: Mapping[str, Any], value: Mapping[str, Any], tag_oid: str) -> bool:
|
||||
current = record.get("current_baseline") or record.get("version")
|
||||
matches = current == value["version"] and all(
|
||||
return current == value["version"] and all(
|
||||
record.get(key) == expected
|
||||
for key, expected in {
|
||||
"target_oid": value["target_oid"],
|
||||
@@ -260,11 +209,6 @@ def baseline_matches(record: Mapping[str, Any], value: Mapping[str, Any], tag_oi
|
||||
"status": "PRODUCTION_DEPLOYED",
|
||||
}.items()
|
||||
)
|
||||
return matches and all(
|
||||
record.get(name) == value.get(name)
|
||||
for name in ("source_registry_main_oid", "source_registry_path", "source_registry_sha256")
|
||||
if name in value
|
||||
)
|
||||
|
||||
|
||||
def registry_probe(args: argparse.Namespace, value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
@@ -361,11 +305,6 @@ def baseline_apply(args: argparse.Namespace, value: Mapping[str, Any]) -> dict[s
|
||||
int(previous.get("generation", 0) or 0),
|
||||
int(previous.get("authority_generation", 0) or 0),
|
||||
) + 1
|
||||
assert_source_registry_convergence(
|
||||
registry_path,
|
||||
repo_git_dir=args.repository,
|
||||
source_registry_main_oid=value.get("source_registry_main_oid"),
|
||||
)
|
||||
record = {
|
||||
"schema": "aios.production-baseline.v2",
|
||||
"source_identity": "aios-gitops-runtime-anchor-baseline-authority-v1",
|
||||
@@ -397,13 +336,6 @@ def baseline_apply(args: argparse.Namespace, value: Mapping[str, Any]) -> dict[s
|
||||
"promoted_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"promoted_by": "aios-approved-production-release-executor",
|
||||
}
|
||||
for name in (
|
||||
"source_registry_main_oid",
|
||||
"source_registry_path",
|
||||
"source_registry_sha256",
|
||||
):
|
||||
if name in value:
|
||||
record[name] = value[name]
|
||||
atomic_json(args.baseline_path, record)
|
||||
return baseline_probe(args, value)
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from aios_signer_broker import SignerBroker, SignerBrokerError, UnixSignerBroker
|
||||
|
||||
|
||||
_SIGNER_FLAGS = (
|
||||
"--namespace",
|
||||
@@ -157,36 +155,6 @@ def _sign(
|
||||
raise SignerContractError("signer_output_empty")
|
||||
|
||||
|
||||
def run_signer_self_test(broker: SignerBroker | None = None) -> dict[str, str]:
|
||||
active = broker or UnixSignerBroker()
|
||||
try:
|
||||
active.self_test()
|
||||
except SignerBrokerError as exc:
|
||||
raise SignerContractError(exc.code) from exc
|
||||
return {"status": "PASS", "contract": "aios.signer-broker.v1"}
|
||||
|
||||
|
||||
def _sign_with_broker(
|
||||
broker: SignerBroker,
|
||||
payload_kind: str,
|
||||
input_path: Path,
|
||||
signature_path: Path,
|
||||
key_id_path: Path,
|
||||
) -> None:
|
||||
if signature_path.exists() or key_id_path.exists():
|
||||
raise SignerContractError("contract_output_exists")
|
||||
try:
|
||||
signature, key_id = broker.sign(payload_kind, input_path.read_bytes())
|
||||
except SignerBrokerError as exc:
|
||||
raise SignerContractError(exc.code) from exc
|
||||
if not signature or not key_id.strip():
|
||||
raise SignerContractError("signer_output_empty")
|
||||
signature_path.write_bytes(signature)
|
||||
key_id_path.write_text(key_id.strip() + "\n", encoding="utf-8")
|
||||
os.chmod(signature_path, 0o600)
|
||||
os.chmod(key_id_path, 0o600)
|
||||
|
||||
|
||||
def _manifest(grant_dir: Path, names: Sequence[str]) -> None:
|
||||
rows = []
|
||||
for name in names:
|
||||
@@ -316,8 +284,6 @@ def run_compatible_signer(
|
||||
signer_command: Sequence[str],
|
||||
grant_dir: Path,
|
||||
result_path: Path,
|
||||
*,
|
||||
broker: SignerBroker | None = None,
|
||||
) -> dict[str, str]:
|
||||
try:
|
||||
if not _supports_deployed_signer(signer_command):
|
||||
@@ -352,21 +318,22 @@ def run_compatible_signer(
|
||||
"signing_key_identity": signer,
|
||||
}
|
||||
prepared = _prepare(grant_dir)
|
||||
active_broker = broker or UnixSignerBroker()
|
||||
try:
|
||||
active_broker.self_test()
|
||||
except SignerBrokerError as exc:
|
||||
raise SignerContractError(exc.code) from exc
|
||||
_sign_with_broker(
|
||||
active_broker,
|
||||
"approval_plan",
|
||||
config_path = Path(
|
||||
os.environ.get("AIOS_GRANT_ROOT_CONFIG", "/etc/aios/grant-root/config.json")
|
||||
)
|
||||
config = _read(config_path)
|
||||
grant_namespace = str(config["grant_namespace"])
|
||||
approval_namespace = str(config["approval_namespace"])
|
||||
_sign(
|
||||
signer_command,
|
||||
approval_namespace,
|
||||
grant_dir / "approval-plan.canonical.json",
|
||||
grant_dir / "approval-plan.sig",
|
||||
grant_dir / "approval-plan.key-id",
|
||||
)
|
||||
_sign_with_broker(
|
||||
active_broker,
|
||||
"grant_payload",
|
||||
_sign(
|
||||
signer_command,
|
||||
grant_namespace,
|
||||
grant_dir / "grant-payload.canonical.json",
|
||||
grant_dir / "signed-grant.sig",
|
||||
grant_dir / "signed-grant.key-id",
|
||||
|
||||
@@ -7,7 +7,6 @@ previous V2R1C7 release available for an explicit, owner-approved rollback.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
@@ -30,7 +29,6 @@ ROLLBACK_ROOT = Path("/var/lib/aios/v2r2c1/rollback")
|
||||
|
||||
REQUIRED_MEMBERS = {
|
||||
"payload/VERSION",
|
||||
"payload/versions/V2R2C1.json",
|
||||
"payload/active-runtime/app/deployment_state.py",
|
||||
"payload/active-runtime/app/v2r1c6_privacy_tools.py",
|
||||
"payload/active-runtime/app/v2r2c1_health_service.py",
|
||||
@@ -89,65 +87,12 @@ def execute_request(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
_backup(Path("/var/lib/aios/v2r1c7/deployment-state.json"), "v2r1c7-deployment-state.previous")
|
||||
return base.install_release(_args(), value)
|
||||
if value.get("operation") == "probe":
|
||||
result = base.probe(_args(), value)
|
||||
if result.get("status") == "ACTIVE_VERIFIED":
|
||||
result.update(_runtime_registry_gate(value))
|
||||
return result
|
||||
return base.probe(_args(), value)
|
||||
if value.get("operation") == "rollback":
|
||||
return rollback(value)
|
||||
raise ValueError("unsupported_operation")
|
||||
|
||||
|
||||
def _runtime_registry_gate(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Prove the deployed immutable runtime contains the exact M:path blob."""
|
||||
source_digest = value.get("source_registry_sha256")
|
||||
source_main = value.get("source_registry_main_oid")
|
||||
if not source_digest or not source_main:
|
||||
return {}
|
||||
source_path = str(value.get("source_registry_path") or f"versions/{VERSION}.json")
|
||||
member = CURRENT_LINK / source_path
|
||||
if member.is_symlink() or not member.is_file():
|
||||
raise RuntimeError("RUNTIME_SOURCE_REGISTRY_MEMBER_MISSING")
|
||||
raw = member.read_bytes()
|
||||
digest = hashlib.sha256(raw).hexdigest()
|
||||
if digest != source_digest:
|
||||
raise RuntimeError("RUNTIME_SOURCE_REGISTRY_DIGEST_MISMATCH")
|
||||
try:
|
||||
source = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeError("RUNTIME_SOURCE_REGISTRY_INVALID") from exc
|
||||
expected = {
|
||||
"version": VERSION,
|
||||
"repository": "aios/aios",
|
||||
"tag_oid": value.get("tag_oid"),
|
||||
"target_oid": value.get("target_oid"),
|
||||
"peeled_target_oid": value.get("target_oid"),
|
||||
"evidence_closure_oid": value.get("evidence_closure_oid"),
|
||||
"approval_plan_id": value.get("approval_plan_id"),
|
||||
}
|
||||
if not isinstance(source, dict) or any(source.get(key) != expected_value for key, expected_value in expected.items()):
|
||||
raise RuntimeError("RUNTIME_SOURCE_REGISTRY_IDENTITY_MISMATCH")
|
||||
if source.get("tag_object") != expected["tag_oid"]:
|
||||
raise RuntimeError("RUNTIME_SOURCE_REGISTRY_TAG_OBJECT_MISMATCH")
|
||||
version_name = f"{VERSION}.json"
|
||||
return {
|
||||
"runtime_registry_versions": [version_name],
|
||||
"attested_projection_versions": [version_name],
|
||||
"effective_registry_versions": [version_name],
|
||||
# The adapter can only inspect the deployed immutable runtime. It
|
||||
# must not claim convergence with mutable authoritative Git; the
|
||||
# executor performs that independent M/C read after deployment.
|
||||
"source_registry_converged": "not_checked",
|
||||
"source_registry_convergence_error": "executor_must_recheck_authoritative_git",
|
||||
"runtime_registry_converged": True,
|
||||
"runtime_bundle_contains_current_version": True,
|
||||
"runtime_bundle_contains_version": True,
|
||||
"registry_consistency_status": "RUNTIME_ONLY_VERIFIED",
|
||||
"runtime_source_registry_main_oid": source_main,
|
||||
"runtime_source_registry_sha256": digest,
|
||||
}
|
||||
|
||||
|
||||
def rollback(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Explicit rollback only; this adapter never invokes it automatically."""
|
||||
import subprocess
|
||||
|
||||
@@ -1,982 +0,0 @@
|
||||
"""Release registry visibility and source-convergence gates.
|
||||
|
||||
Two invariants are enforced:
|
||||
|
||||
1. listVersions may overlay exactly the current Production Baseline-attested
|
||||
projection onto the immutable source bundle. Arbitrary projection files
|
||||
are not surfaced.
|
||||
|
||||
2. release_metadata_adapter.baseline_apply may not finalize a production
|
||||
baseline unless the governed repository/main already contains a source
|
||||
versions/<version>.json entry matching the release identity.
|
||||
|
||||
The source convergence gate intentionally checks release identity, not
|
||||
post-deployment runtime fields. This allows the source entry to be committed
|
||||
before deployment while still preventing PRODUCTION_DEPLOYED from being
|
||||
written for an uncommitted release identity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_VERSION_RE = re.compile(r"^V[0-9]+R[0-9]+C[0-9]+(?:-[A-Za-z0-9_.-]+)?$")
|
||||
_OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
VERSION_RE = _VERSION_RE
|
||||
OID_RE = _OID_RE
|
||||
SOURCE_REGISTRY_SCHEMAS = frozenset(
|
||||
{"aios.source-registry.v1", "aios.production-registry.v2"}
|
||||
)
|
||||
SOURCE_REGISTRY_FIELDS = (
|
||||
"version",
|
||||
"tag_oid",
|
||||
"target_oid",
|
||||
"peeled_target_oid",
|
||||
"evidence_closure_oid",
|
||||
"approval_plan_id",
|
||||
)
|
||||
REPOSITORY_IDENTITY = "aios/aios"
|
||||
|
||||
DEFAULT_PROJECTION_ROOT = Path(
|
||||
os.getenv(
|
||||
"AIOS_GITOPS_REGISTRY_PROJECTION_ROOT",
|
||||
"/var/lib/aios-gitops/runtime/registry-projection",
|
||||
)
|
||||
)
|
||||
DEFAULT_BASELINE_FILE = Path(
|
||||
os.getenv(
|
||||
"AIOS_GITOPS_BASELINE_FILE",
|
||||
"/var/lib/aios-gitops-runtime-anchor/baseline/current.json",
|
||||
)
|
||||
)
|
||||
DEFAULT_MAIN_REF = os.getenv("AIOS_GITOPS_MAIN_REF", "refs/heads/main")
|
||||
DEFAULT_AUTHORITATIVE_GIT_DIR = Path("/data/aios-gitops/repo.git")
|
||||
DEFAULT_APPROVAL_AUTHORITY_ROOT = Path("/var/lib/aios-gitops/approval-authority")
|
||||
TEST_MODE_ENV = "AIOS_TEST_MODE"
|
||||
AUTHORITATIVE_GIT_ENV = "AIOS_GITOPS_AUTHORITATIVE_GIT_DIR"
|
||||
APPROVAL_AUTHORITY_ROOT_ENV = "AIOS_GITOPS_APPROVAL_AUTHORITY_ROOT"
|
||||
REPOSITORY_IDENTITY_ENV = "AIOS_GITOPS_REPOSITORY_IDENTITY"
|
||||
|
||||
|
||||
class RegistryConvergenceError(RuntimeError):
|
||||
"""Release registry/source convergence is not proven."""
|
||||
|
||||
|
||||
def _load_object(path: Path) -> dict[str, Any]:
|
||||
obj = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(obj, dict):
|
||||
raise RegistryConvergenceError(f"json_not_object:{path}")
|
||||
return obj
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _git_text(repo_git_dir: Path | str, *args: str) -> str:
|
||||
completed = subprocess.run(
|
||||
["git", f"--git-dir={Path(repo_git_dir)}", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=45,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise RegistryConvergenceError(
|
||||
completed.stderr.strip() or "authoritative_git_query_failed"
|
||||
)
|
||||
return completed.stdout.strip()
|
||||
|
||||
|
||||
def _git_bytes(repo_git_dir: Path | str, *args: str) -> bytes:
|
||||
completed = subprocess.run(
|
||||
["git", f"--git-dir={Path(repo_git_dir)}", *args],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=45,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise RegistryConvergenceError(
|
||||
completed.stderr.decode("utf-8", "replace").strip()
|
||||
or "authoritative_git_query_failed"
|
||||
)
|
||||
return completed.stdout
|
||||
|
||||
|
||||
def test_mode_enabled() -> bool:
|
||||
"""Return whether explicit dependency injection is allowed for tests."""
|
||||
return os.environ.get(TEST_MODE_ENV) == "1"
|
||||
|
||||
|
||||
def _configured_override(env_name: str, default: str, label: str) -> Path:
|
||||
if env_name in os.environ:
|
||||
if not test_mode_enabled():
|
||||
raise RegistryConvergenceError(f"{label}_env_override_forbidden")
|
||||
value = os.environ.get(env_name, "")
|
||||
if not value:
|
||||
raise RegistryConvergenceError(f"{label}_env_override_invalid")
|
||||
return Path(value)
|
||||
return Path(default)
|
||||
|
||||
|
||||
def configured_authoritative_git_dir() -> Path:
|
||||
return _configured_override(
|
||||
AUTHORITATIVE_GIT_ENV,
|
||||
str(DEFAULT_AUTHORITATIVE_GIT_DIR),
|
||||
"authoritative_repository",
|
||||
)
|
||||
|
||||
|
||||
def configured_repository_identity() -> str:
|
||||
if REPOSITORY_IDENTITY_ENV in os.environ:
|
||||
if not test_mode_enabled():
|
||||
raise RegistryConvergenceError("repository_identity_env_override_forbidden")
|
||||
value = os.environ.get(REPOSITORY_IDENTITY_ENV, "")
|
||||
if not value:
|
||||
raise RegistryConvergenceError("repository_identity_env_override_invalid")
|
||||
return value
|
||||
return REPOSITORY_IDENTITY
|
||||
|
||||
|
||||
def verify_authoritative_repository(
|
||||
repo_git_dir: Path | str,
|
||||
*,
|
||||
repository_identity: str = REPOSITORY_IDENTITY,
|
||||
expected_path: Path | str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Prove that a Git directory is the configured AIOS authority.
|
||||
|
||||
A repository that merely contains internally consistent objects is not
|
||||
sufficient. The resolved path must be the configured authority and the
|
||||
bare repository must advertise the expected Gitea repository identity.
|
||||
"""
|
||||
if repository_identity != REPOSITORY_IDENTITY:
|
||||
raise RegistryConvergenceError("authoritative_repository_identity_invalid")
|
||||
candidate = Path(repo_git_dir)
|
||||
configured = Path(expected_path) if expected_path is not None else configured_authoritative_git_dir()
|
||||
if test_mode_enabled() and expected_path is None and AUTHORITATIVE_GIT_ENV not in os.environ:
|
||||
# Explicit test mode may inject a temporary bare repository directly.
|
||||
# Production mode always compares against the fixed authority path.
|
||||
configured = candidate
|
||||
try:
|
||||
candidate_real = candidate.resolve(strict=True)
|
||||
configured_real = configured.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise RegistryConvergenceError("authoritative_repository_path_unavailable") from exc
|
||||
if candidate_real != configured_real:
|
||||
raise RegistryConvergenceError("authoritative_repository_path_mismatch")
|
||||
if not candidate_real.is_dir():
|
||||
raise RegistryConvergenceError("authoritative_repository_not_directory")
|
||||
if _git_text(candidate_real, "rev-parse", "--is-bare-repository") != "true":
|
||||
raise RegistryConvergenceError("authoritative_repository_not_bare")
|
||||
remotes = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={candidate_real}",
|
||||
"config",
|
||||
"--get-regexp",
|
||||
r"^remote\..*\.url$",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=45,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
urls = [line.split(None, 1)[1] for line in remotes.stdout.splitlines() if " " in line]
|
||||
identity_suffix = repository_identity.removesuffix(".git")
|
||||
identity_verified = any(
|
||||
url.rstrip("/").removesuffix(".git").replace("\\", "/").split(":")[-1]
|
||||
in {identity_suffix, "/" + identity_suffix}
|
||||
or url.rstrip("/").removesuffix(".git").replace("\\", "/").split(":")[-1].endswith(
|
||||
"/" + identity_suffix
|
||||
)
|
||||
for url in urls
|
||||
)
|
||||
if not test_mode_enabled() and not identity_verified:
|
||||
raise RegistryConvergenceError("authoritative_repository_identity_unverified")
|
||||
return {
|
||||
"repository_identity": repository_identity,
|
||||
"configured_path": str(configured_real),
|
||||
"resolved_path": str(candidate_real),
|
||||
}
|
||||
|
||||
|
||||
def resolve_source_registry_main_oid(
|
||||
repo_git_dir: Path | str,
|
||||
*,
|
||||
main_ref: str = DEFAULT_MAIN_REF,
|
||||
) -> str:
|
||||
"""Resolve the authoritative main ref to one immutable commit OID."""
|
||||
oid = _git_text(repo_git_dir, "rev-parse", "--verify", f"{main_ref}^{{commit}}")
|
||||
if not _OID_RE.fullmatch(oid):
|
||||
raise RegistryConvergenceError("source_registry_main_oid_invalid")
|
||||
if _git_text(repo_git_dir, "cat-file", "-t", oid) != "commit":
|
||||
raise RegistryConvergenceError("source_registry_main_oid_not_commit")
|
||||
return oid
|
||||
|
||||
|
||||
def source_registry_snapshot(
|
||||
repo_git_dir: Path | str,
|
||||
version: str,
|
||||
*,
|
||||
main_ref: str = DEFAULT_MAIN_REF,
|
||||
source_registry_main_oid: str | None = None,
|
||||
expected_tag_oid: str | None = None,
|
||||
expected_target_oid: str | None = None,
|
||||
expected_evidence_closure_oid: str | None = None,
|
||||
expected_approval_plan_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Read one source entry and its exact bytes from one immutable commit.
|
||||
|
||||
Callers building an artifact should resolve ``source_registry_main_oid``
|
||||
once and pass it back here. The function then never consults the mutable
|
||||
ref again, so validation and packaging use the same Git blob.
|
||||
"""
|
||||
if not isinstance(version, str) or not _VERSION_RE.fullmatch(version):
|
||||
raise RegistryConvergenceError("source_registry_version_invalid")
|
||||
main_oid = source_registry_main_oid or resolve_source_registry_main_oid(
|
||||
repo_git_dir,
|
||||
main_ref=main_ref,
|
||||
)
|
||||
if not _OID_RE.fullmatch(main_oid):
|
||||
raise RegistryConvergenceError("source_registry_main_oid_invalid")
|
||||
if _git_text(repo_git_dir, "cat-file", "-t", main_oid) != "commit":
|
||||
raise RegistryConvergenceError("source_registry_main_oid_not_commit")
|
||||
path = f"versions/{version}.json"
|
||||
try:
|
||||
raw = _git_bytes(repo_git_dir, "show", f"{main_oid}:{path}")
|
||||
except RegistryConvergenceError as exc:
|
||||
if "path" in str(exc).lower() or "does not exist" in str(exc).lower():
|
||||
raise RegistryConvergenceError(
|
||||
f"source_registry_entry_missing:{main_oid}:{path}"
|
||||
) from exc
|
||||
raise
|
||||
try:
|
||||
source = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise RegistryConvergenceError("source_registry_json_invalid") from exc
|
||||
record = validate_source_registry_record(
|
||||
source,
|
||||
expected_version=version,
|
||||
expected_tag_oid=expected_tag_oid,
|
||||
expected_target_oid=expected_target_oid,
|
||||
expected_evidence_closure_oid=expected_evidence_closure_oid,
|
||||
expected_approval_plan_id=expected_approval_plan_id,
|
||||
)
|
||||
return {
|
||||
"record": record,
|
||||
"bytes": raw,
|
||||
"source_registry_main_oid": main_oid,
|
||||
"source_registry_path": path,
|
||||
"source_registry_sha256": hashlib.sha256(raw).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def validate_source_registry_record(
|
||||
value: Any,
|
||||
*,
|
||||
expected_version: str | None = None,
|
||||
expected_tag_oid: str | None = None,
|
||||
expected_target_oid: str | None = None,
|
||||
expected_evidence_closure_oid: str | None = None,
|
||||
expected_approval_plan_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate the immutable identity carried by versions/<version>.json."""
|
||||
if not isinstance(value, dict):
|
||||
raise RegistryConvergenceError("source_registry_not_object")
|
||||
if value.get("schema") not in SOURCE_REGISTRY_SCHEMAS:
|
||||
raise RegistryConvergenceError("source_registry_schema_invalid")
|
||||
version = value.get("version")
|
||||
if not isinstance(version, str) or not _VERSION_RE.fullmatch(version):
|
||||
raise RegistryConvergenceError("source_registry_version_invalid")
|
||||
if expected_version is not None and version != expected_version:
|
||||
raise RegistryConvergenceError("source_registry_mismatch:version")
|
||||
if value.get("repository") != REPOSITORY_IDENTITY:
|
||||
raise RegistryConvergenceError("source_registry_repository_invalid")
|
||||
for field in ("tag_oid", "target_oid", "peeled_target_oid", "evidence_closure_oid"):
|
||||
item = value.get(field)
|
||||
if not isinstance(item, str) or not _OID_RE.fullmatch(item):
|
||||
raise RegistryConvergenceError(f"source_registry_{field}_invalid")
|
||||
if value["peeled_target_oid"] != value["target_oid"]:
|
||||
raise RegistryConvergenceError("source_registry_peeled_target_mismatch")
|
||||
approval = value.get("approval_plan_id")
|
||||
if not isinstance(approval, str) or not _PLAN_RE.fullmatch(approval):
|
||||
raise RegistryConvergenceError("source_registry_approval_plan_invalid")
|
||||
if value.get("tag_object", value["tag_oid"]) != value["tag_oid"]:
|
||||
raise RegistryConvergenceError("source_registry_tag_object_mismatch")
|
||||
expected = {
|
||||
"tag_oid": expected_tag_oid,
|
||||
"target_oid": expected_target_oid,
|
||||
"evidence_closure_oid": expected_evidence_closure_oid,
|
||||
"approval_plan_id": expected_approval_plan_id,
|
||||
}
|
||||
for field, wanted in expected.items():
|
||||
if wanted is not None and value.get(field) != wanted:
|
||||
raise RegistryConvergenceError(f"source_registry_mismatch:{field}")
|
||||
return dict(value)
|
||||
|
||||
|
||||
def build_source_registry_entry(
|
||||
*,
|
||||
version: str,
|
||||
tag_oid: str,
|
||||
target_oid: str,
|
||||
peeled_target_oid: str,
|
||||
evidence_closure_oid: str,
|
||||
approval_plan_id: str,
|
||||
repository: str = REPOSITORY_IDENTITY,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the pre-promotion source entry; it performs no Git or runtime write."""
|
||||
value = {
|
||||
"schema": "aios.source-registry.v1",
|
||||
"version": version,
|
||||
"repository": repository,
|
||||
"tag": version,
|
||||
"tag_oid": tag_oid,
|
||||
"tag_object": tag_oid,
|
||||
"target_oid": target_oid,
|
||||
"peeled_target_oid": peeled_target_oid,
|
||||
"evidence_closure_oid": evidence_closure_oid,
|
||||
"approval_plan_id": approval_plan_id,
|
||||
}
|
||||
return validate_source_registry_record(
|
||||
value,
|
||||
expected_version=version,
|
||||
expected_tag_oid=tag_oid,
|
||||
expected_target_oid=target_oid,
|
||||
expected_evidence_closure_oid=evidence_closure_oid,
|
||||
expected_approval_plan_id=approval_plan_id,
|
||||
)
|
||||
|
||||
|
||||
def verify_source_registry_git_identity(
|
||||
repo_git_dir: Path | str,
|
||||
record: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Prove that every Git identity in a source entry exists and is related."""
|
||||
record = validate_source_registry_record(record)
|
||||
version = record["version"]
|
||||
tag_oid = record["tag_oid"]
|
||||
target_oid = record["target_oid"]
|
||||
evidence_oid = record["evidence_closure_oid"]
|
||||
if _git_text(repo_git_dir, "cat-file", "-t", tag_oid) != "tag":
|
||||
raise RegistryConvergenceError("source_registry_tag_not_annotated")
|
||||
named_tag = _git_text(
|
||||
repo_git_dir,
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
f"refs/tags/{version}",
|
||||
)
|
||||
if named_tag != tag_oid:
|
||||
raise RegistryConvergenceError("source_registry_named_tag_mismatch")
|
||||
if _git_text(repo_git_dir, "cat-file", "-t", target_oid) != "commit":
|
||||
raise RegistryConvergenceError("source_registry_target_not_commit")
|
||||
peeled = _git_text(
|
||||
repo_git_dir,
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
f"{tag_oid}^{{commit}}",
|
||||
)
|
||||
if peeled != target_oid or record["peeled_target_oid"] != target_oid:
|
||||
raise RegistryConvergenceError("source_registry_tag_target_mismatch")
|
||||
if _git_text(repo_git_dir, "cat-file", "-t", evidence_oid) != "commit":
|
||||
raise RegistryConvergenceError("source_registry_evidence_not_commit")
|
||||
ancestor = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={Path(repo_git_dir)}",
|
||||
"merge-base",
|
||||
"--is-ancestor",
|
||||
target_oid,
|
||||
evidence_oid,
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=45,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
if ancestor.returncode != 0:
|
||||
raise RegistryConvergenceError("source_registry_evidence_lineage_invalid")
|
||||
return {
|
||||
"repository": REPOSITORY_IDENTITY,
|
||||
"version": version,
|
||||
"tag_oid": tag_oid,
|
||||
"tag_object_type": "tag",
|
||||
"target_oid": target_oid,
|
||||
"peeled_target_oid": peeled,
|
||||
"evidence_closure_oid": evidence_oid,
|
||||
"target_is_ancestor_of_evidence": True,
|
||||
}
|
||||
|
||||
|
||||
def verify_source_registry_main_convergence(
|
||||
repo_git_dir: Path | str,
|
||||
version: str,
|
||||
*,
|
||||
main_ref: str = DEFAULT_MAIN_REF,
|
||||
source_registry_main_oid: str | None = None,
|
||||
expected_tag_oid: str | None = None,
|
||||
expected_target_oid: str | None = None,
|
||||
expected_evidence_closure_oid: str | None = None,
|
||||
expected_approval_plan_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Prove that pinned M is still converged with current authoritative main C."""
|
||||
current_main_oid = resolve_source_registry_main_oid(repo_git_dir, main_ref=main_ref)
|
||||
source_main_oid = source_registry_main_oid or current_main_oid
|
||||
if not _OID_RE.fullmatch(source_main_oid):
|
||||
raise RegistryConvergenceError("source_registry_main_oid_invalid")
|
||||
ancestor = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={Path(repo_git_dir)}",
|
||||
"merge-base",
|
||||
"--is-ancestor",
|
||||
source_main_oid,
|
||||
current_main_oid,
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=45,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
if ancestor.returncode != 0:
|
||||
raise RegistryConvergenceError("source_registry_main_not_ancestor_of_current_main")
|
||||
pinned = source_registry_snapshot(
|
||||
repo_git_dir,
|
||||
version,
|
||||
source_registry_main_oid=source_main_oid,
|
||||
expected_tag_oid=expected_tag_oid,
|
||||
expected_target_oid=expected_target_oid,
|
||||
expected_evidence_closure_oid=expected_evidence_closure_oid,
|
||||
expected_approval_plan_id=expected_approval_plan_id,
|
||||
)
|
||||
current = source_registry_snapshot(
|
||||
repo_git_dir,
|
||||
version,
|
||||
source_registry_main_oid=current_main_oid,
|
||||
expected_tag_oid=expected_tag_oid,
|
||||
expected_target_oid=expected_target_oid,
|
||||
expected_evidence_closure_oid=expected_evidence_closure_oid,
|
||||
expected_approval_plan_id=expected_approval_plan_id,
|
||||
)
|
||||
if pinned["bytes"] != current["bytes"]:
|
||||
raise RegistryConvergenceError("source_registry_main_entry_changed_after_pin")
|
||||
return {
|
||||
"source_registry_main_oid": source_main_oid,
|
||||
"current_main_oid": current_main_oid,
|
||||
"source_registry_path": pinned["source_registry_path"],
|
||||
"source_registry_sha256": pinned["source_registry_sha256"],
|
||||
"current_main_source_registry_sha256": current["source_registry_sha256"],
|
||||
"source_registry_main_is_ancestor_of_current_main": True,
|
||||
"source_registry_entry_matches_current_main": True,
|
||||
}
|
||||
|
||||
|
||||
_APPROVAL_AUTHORITY_STATES = frozenset(
|
||||
{
|
||||
"APPROVED",
|
||||
"GITEA_API_CONFIRMED",
|
||||
"REMOTE_VERIFIED",
|
||||
"PRODUCTION_DEPLOYED",
|
||||
}
|
||||
)
|
||||
_APPROVAL_AUTHORITY_SCHEMAS = frozenset(
|
||||
{
|
||||
"aios.v2r2c1.tag-approval-request.v1",
|
||||
"aios.owner-release-approval.v1",
|
||||
"aios.release-binding-approval.v2",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def configured_approval_authority_root() -> Path:
|
||||
return _configured_override(
|
||||
APPROVAL_AUTHORITY_ROOT_ENV,
|
||||
str(DEFAULT_APPROVAL_AUTHORITY_ROOT),
|
||||
"approval_authority_root",
|
||||
)
|
||||
|
||||
|
||||
def _secure_approval_authority_path(
|
||||
authority_path: Path | str,
|
||||
version: str,
|
||||
) -> tuple[Path, Path]:
|
||||
root = configured_approval_authority_root()
|
||||
path = Path(authority_path)
|
||||
if path.is_dir():
|
||||
path = path / f"{version}.json"
|
||||
try:
|
||||
root_real = root.resolve(strict=True)
|
||||
path_real = path.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise RegistryConvergenceError("approval_authority_trusted_root_missing") from exc
|
||||
try:
|
||||
path_real.relative_to(root_real)
|
||||
except ValueError as exc:
|
||||
raise RegistryConvergenceError("approval_authority_untrusted_path") from exc
|
||||
metadata = path.lstat()
|
||||
if not stat.S_ISREG(metadata.st_mode) or path.is_symlink():
|
||||
raise RegistryConvergenceError("approval_authority_not_regular")
|
||||
if metadata.st_mode & 0o022:
|
||||
raise RegistryConvergenceError("approval_authority_permissions_invalid")
|
||||
if APPROVAL_AUTHORITY_ROOT_ENV not in os.environ and metadata.st_uid != 0:
|
||||
raise RegistryConvergenceError("approval_authority_owner_invalid")
|
||||
return path, root_real
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
|
||||
def _approval_is_revoked(root: Path, approval_plan_id: str, authority: dict[str, Any]) -> bool:
|
||||
if any(
|
||||
authority.get(name) is True
|
||||
for name in ("revoked", "invalidated", "revocation")
|
||||
):
|
||||
return True
|
||||
state = authority.get("state") or authority.get("status")
|
||||
if state in {"REVOKED", "INVALIDATED", "EXPIRED", "CANCELLED"}:
|
||||
return True
|
||||
for marker in (
|
||||
root / "revocations" / approval_plan_id,
|
||||
root / "revocations" / approval_plan_id.replace(":", "_"),
|
||||
):
|
||||
if marker.exists():
|
||||
return True
|
||||
expires_at = authority.get("expires_at")
|
||||
if isinstance(expires_at, str) and expires_at:
|
||||
try:
|
||||
instant = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise RegistryConvergenceError("approval_expiry_invalid") from exc
|
||||
if instant <= datetime.now(timezone.utc):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def verify_approval_authority(
|
||||
authority_path: Path | str,
|
||||
record: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Verify the existing release approval/publication receipt association.
|
||||
|
||||
The authority document is intentionally supplied by the governed caller;
|
||||
this helper never creates or rewrites an approval. The path must be under
|
||||
the configured canonical Owner-authority root and the document must use a
|
||||
known release-approval schema. A caller-supplied JSON file elsewhere is
|
||||
never an authority, even when its fields are internally consistent.
|
||||
"""
|
||||
path, trusted_root = _secure_approval_authority_path(authority_path, record["version"])
|
||||
authority = _load_object(path)
|
||||
schema = authority.get("schema")
|
||||
if schema not in _APPROVAL_AUTHORITY_SCHEMAS:
|
||||
raise RegistryConvergenceError("approval_authority_schema_invalid")
|
||||
if _approval_is_revoked(trusted_root, record["approval_plan_id"], authority):
|
||||
raise RegistryConvergenceError("approval_plan_revoked_or_invalidated")
|
||||
nested = authority.get("release_binding") or authority.get("release_request")
|
||||
if not isinstance(nested, dict):
|
||||
nested = authority.get("binding")
|
||||
if not isinstance(nested, dict):
|
||||
raise RegistryConvergenceError("approval_authority_binding_missing")
|
||||
expected = {
|
||||
"version": record["version"],
|
||||
"repository": REPOSITORY_IDENTITY,
|
||||
"tag_oid": record["tag_oid"],
|
||||
"target_oid": record["target_oid"],
|
||||
"peeled_target_oid": record["peeled_target_oid"],
|
||||
"evidence_closure_oid": record["evidence_closure_oid"],
|
||||
"approval_plan_id": record["approval_plan_id"],
|
||||
}
|
||||
if authority.get("approval_plan_id") != expected["approval_plan_id"]:
|
||||
raise RegistryConvergenceError("approval_plan_not_found_or_mismatched")
|
||||
state = authority.get("state") or authority.get("status")
|
||||
if state not in _APPROVAL_AUTHORITY_STATES and authority.get("approved") is not True:
|
||||
raise RegistryConvergenceError("approval_plan_not_approved")
|
||||
owner_approval = authority.get("owner_approval")
|
||||
if schema == "aios.v2r2c1.tag-approval-request.v1":
|
||||
if not isinstance(owner_approval, dict):
|
||||
raise RegistryConvergenceError("canonical_owner_approval_missing")
|
||||
if (
|
||||
owner_approval.get("approved") is not True
|
||||
or owner_approval.get("status") != "APPROVED"
|
||||
or owner_approval.get("approved_by") != "aiman"
|
||||
or owner_approval.get("approval_source") != "explicit_owner_approval_in_chat"
|
||||
):
|
||||
raise RegistryConvergenceError("canonical_owner_approval_invalid")
|
||||
expected_digest = hashlib.sha256(_canonical_json(owner_approval)).hexdigest()
|
||||
if authority.get("owner_approval_digest_sha256") != expected_digest:
|
||||
raise RegistryConvergenceError("canonical_owner_approval_digest_invalid")
|
||||
for field, wanted in expected.items():
|
||||
observed = nested.get(field)
|
||||
if field == "repository" and observed is None:
|
||||
observed = nested.get("repository_identity") or authority.get(
|
||||
"repository_identity"
|
||||
)
|
||||
if field == "peeled_target_oid" and observed is None:
|
||||
observed = (
|
||||
nested.get("gitea_peeled_target_oid")
|
||||
or authority.get("gitea_peeled_target_oid")
|
||||
or nested.get("remote_peeled_target_oid")
|
||||
or authority.get("remote_peeled_target_oid")
|
||||
)
|
||||
if observed != wanted:
|
||||
raise RegistryConvergenceError(f"approval_plan_release_binding_mismatch:{field}")
|
||||
if state == "GITEA_API_CONFIRMED":
|
||||
gitea_tag = authority.get("gitea_tag_oid")
|
||||
gitea_peeled = authority.get("gitea_peeled_target_oid")
|
||||
if gitea_tag != expected["tag_oid"] or gitea_peeled != expected["target_oid"]:
|
||||
raise RegistryConvergenceError("approval_plan_remote_tag_binding_mismatch")
|
||||
return {
|
||||
"path": str(path),
|
||||
"schema": schema,
|
||||
"state": state or "APPROVED",
|
||||
"approval_plan_id": expected["approval_plan_id"],
|
||||
"owner_approval_verified": schema == "aios.v2r2c1.tag-approval-request.v1",
|
||||
"release_binding_verified": True,
|
||||
}
|
||||
|
||||
|
||||
def source_registry_json(value: dict[str, Any]) -> bytes:
|
||||
"""Return the canonical bytes used by the governed file writer."""
|
||||
return (json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + "\n").encode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
|
||||
def _validated_current_projection(
|
||||
projection_root: Path,
|
||||
baseline_file: Path,
|
||||
) -> Path | None:
|
||||
"""Return only the exact projection attested by the current Baseline."""
|
||||
if not baseline_file.is_file():
|
||||
return None
|
||||
try:
|
||||
baseline = _load_object(baseline_file)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
version = baseline.get("version")
|
||||
if not isinstance(version, str) or not _VERSION_RE.fullmatch(version):
|
||||
return None
|
||||
if baseline.get("status") != "PRODUCTION_DEPLOYED":
|
||||
return None
|
||||
if baseline.get("production_deployed") is not True:
|
||||
return None
|
||||
|
||||
source = baseline.get("registry_source")
|
||||
digest = baseline.get("registry_digest")
|
||||
if not isinstance(source, str) or not isinstance(digest, str):
|
||||
return None
|
||||
|
||||
candidate = projection_root / f"{version}.json"
|
||||
try:
|
||||
if candidate.resolve() != Path(source).resolve():
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
if not candidate.is_file() or _sha256(candidate) != digest:
|
||||
return None
|
||||
|
||||
try:
|
||||
record = _load_object(candidate)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
try:
|
||||
validate_source_registry_record(
|
||||
record,
|
||||
expected_version=version,
|
||||
expected_tag_oid=baseline.get("tag_oid"),
|
||||
expected_target_oid=baseline.get("target_oid"),
|
||||
expected_evidence_closure_oid=baseline.get("evidence_closure_oid"),
|
||||
expected_approval_plan_id=baseline.get("approval_plan_id"),
|
||||
)
|
||||
except RegistryConvergenceError:
|
||||
return None
|
||||
if record.get("schema") != "aios.production-registry.v2":
|
||||
return None
|
||||
if record.get("status") != "PRODUCTION_DEPLOYED":
|
||||
return None
|
||||
if record.get("version") != version:
|
||||
return None
|
||||
if record.get("repository") != REPOSITORY_IDENTITY:
|
||||
return None
|
||||
if record.get("production_deployed") is not True:
|
||||
return None
|
||||
|
||||
tag_oid = record.get("tag_oid")
|
||||
target_oid = record.get("target_oid")
|
||||
if not isinstance(tag_oid, str) or not _OID_RE.fullmatch(tag_oid):
|
||||
return None
|
||||
if not isinstance(target_oid, str) or not _OID_RE.fullmatch(target_oid):
|
||||
return None
|
||||
if record.get("peeled_target_oid") != target_oid:
|
||||
return None
|
||||
|
||||
return candidate
|
||||
|
||||
|
||||
def list_registry_versions(
|
||||
workspace: Path | str,
|
||||
*,
|
||||
projection_root: Path = DEFAULT_PROJECTION_ROOT,
|
||||
baseline_file: Path = DEFAULT_BASELINE_FILE,
|
||||
) -> list[str]:
|
||||
"""Return the legacy display list; use the report for provenance."""
|
||||
return list_registry_versions_report(
|
||||
workspace,
|
||||
projection_root=projection_root,
|
||||
baseline_file=baseline_file,
|
||||
)["registry_versions"]
|
||||
|
||||
|
||||
def list_registry_versions_report(
|
||||
workspace: Path | str,
|
||||
*,
|
||||
projection_root: Path = DEFAULT_PROJECTION_ROOT,
|
||||
baseline_file: Path = DEFAULT_BASELINE_FILE,
|
||||
repo_git_dir: Path | str | None = None,
|
||||
main_ref: str = DEFAULT_MAIN_REF,
|
||||
) -> dict[str, Any]:
|
||||
"""Return version visibility with runtime/projection provenance.
|
||||
|
||||
The display list intentionally retains the historical projection overlay
|
||||
for API compatibility. The explicit booleans make that overlay
|
||||
non-authoritative: a projection-only version has
|
||||
``runtime_bundle_contains_version == false`` and can never be reported as
|
||||
converged.
|
||||
"""
|
||||
ws = Path(workspace)
|
||||
versions_root = ws / "versions"
|
||||
runtime_names = {
|
||||
path.name
|
||||
for path in versions_root.iterdir()
|
||||
if path.is_file()
|
||||
and not path.is_symlink()
|
||||
and path.suffix == ".json"
|
||||
and _VERSION_RE.fullmatch(path.stem)
|
||||
} if versions_root.is_dir() else set()
|
||||
current = _validated_current_projection(projection_root, baseline_file)
|
||||
attested_version = current.stem if current is not None else None
|
||||
runtime_bundle_contains = bool(
|
||||
attested_version is not None
|
||||
and f"{attested_version}.json" in runtime_names
|
||||
)
|
||||
consistency_status = "NO_ATTESTED_PROJECTION"
|
||||
source_converged: bool | str = "unknown"
|
||||
source_error: str | None = None
|
||||
runtime_converged = False
|
||||
projection_record: dict[str, Any] | None = None
|
||||
if current is not None:
|
||||
try:
|
||||
projection_record = _load_object(current)
|
||||
except (OSError, RegistryConvergenceError, json.JSONDecodeError) as exc:
|
||||
source_error = f"projection_invalid:{type(exc).__name__}"
|
||||
if repo_git_dir is None:
|
||||
source_converged = "unknown"
|
||||
source_error = "authoritative_repository_unavailable"
|
||||
elif projection_record is not None:
|
||||
try:
|
||||
verify_authoritative_repository(repo_git_dir)
|
||||
verify_source_registry_main_convergence(
|
||||
repo_git_dir,
|
||||
attested_version or "",
|
||||
main_ref=main_ref,
|
||||
expected_tag_oid=projection_record.get("tag_oid"),
|
||||
expected_target_oid=projection_record.get("target_oid"),
|
||||
expected_evidence_closure_oid=projection_record.get(
|
||||
"evidence_closure_oid"
|
||||
),
|
||||
expected_approval_plan_id=projection_record.get("approval_plan_id"),
|
||||
)
|
||||
source_converged = True
|
||||
except RegistryConvergenceError as exc:
|
||||
detail = str(exc)
|
||||
if detail.startswith("authoritative_repository_"):
|
||||
source_converged = "unknown"
|
||||
source_error = (
|
||||
"authoritative_repository_unavailable"
|
||||
if detail.endswith("path_unavailable")
|
||||
else detail
|
||||
)
|
||||
else:
|
||||
source_converged = False
|
||||
source_error = detail
|
||||
if current is not None and not runtime_bundle_contains:
|
||||
consistency_status = "RUNTIME_ARTIFACT_RECONCILIATION_REQUIRED"
|
||||
elif current is not None and runtime_bundle_contains:
|
||||
runtime_path = versions_root / current.name
|
||||
try:
|
||||
projection_record = projection_record or _load_object(current)
|
||||
runtime_record = _load_object(runtime_path)
|
||||
identity_fields = (
|
||||
"version",
|
||||
"repository",
|
||||
"tag_oid",
|
||||
"target_oid",
|
||||
"peeled_target_oid",
|
||||
"evidence_closure_oid",
|
||||
"approval_plan_id",
|
||||
)
|
||||
runtime_converged = all(
|
||||
runtime_record.get(field) == projection_record.get(field)
|
||||
for field in identity_fields
|
||||
)
|
||||
if not runtime_converged:
|
||||
consistency_status = "RUNTIME_IDENTITY_MISMATCH"
|
||||
except (OSError, RegistryConvergenceError, json.JSONDecodeError):
|
||||
consistency_status = "RUNTIME_ENTRY_INVALID"
|
||||
runtime_converged = False
|
||||
if current is not None and runtime_bundle_contains and runtime_converged:
|
||||
if source_converged is True:
|
||||
consistency_status = "FULLY_CONSISTENT"
|
||||
elif source_converged == "unknown":
|
||||
consistency_status = "SOURCE_GIT_NOT_CHECKED"
|
||||
else:
|
||||
consistency_status = "SOURCE_REGISTRY_NOT_CONVERGED"
|
||||
elif current is not None and not runtime_bundle_contains:
|
||||
if source_converged is False:
|
||||
consistency_status = "SOURCE_REGISTRY_NOT_CONVERGED"
|
||||
else:
|
||||
consistency_status = "RUNTIME_ARTIFACT_RECONCILIATION_REQUIRED"
|
||||
names = set(runtime_names)
|
||||
if current is not None:
|
||||
names.add(current.name)
|
||||
effective = sorted(names)
|
||||
projection_versions = [attested_version + ".json"] if attested_version else []
|
||||
return {
|
||||
"registry_versions": effective,
|
||||
"effective_registry_versions": effective,
|
||||
"runtime_registry_versions": sorted(runtime_names),
|
||||
"attested_projection_versions": projection_versions,
|
||||
"attested_projection_version": attested_version,
|
||||
"source_registry_converged": source_converged,
|
||||
"source_registry_convergence_error": source_error,
|
||||
"runtime_registry_converged": runtime_converged,
|
||||
"runtime_bundle_contains_current_version": runtime_bundle_contains,
|
||||
"runtime_bundle_contains_version": runtime_bundle_contains,
|
||||
"registry_consistency_status": consistency_status,
|
||||
}
|
||||
|
||||
|
||||
def assert_runtime_registry_convergence(
|
||||
workspace: Path | str,
|
||||
version: str,
|
||||
*,
|
||||
projection_root: Path = DEFAULT_PROJECTION_ROOT,
|
||||
baseline_file: Path = DEFAULT_BASELINE_FILE,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when a projection-only version is visible but unpackaged."""
|
||||
report = list_registry_versions_report(
|
||||
workspace,
|
||||
projection_root=projection_root,
|
||||
baseline_file=baseline_file,
|
||||
)
|
||||
expected_name = f"{version}.json"
|
||||
if (
|
||||
report.get("attested_projection_version") == version
|
||||
and report.get("runtime_bundle_contains_version") is not True
|
||||
):
|
||||
raise RegistryConvergenceError("runtime_bundle_missing_source_registry")
|
||||
if expected_name not in report.get("runtime_registry_versions", []):
|
||||
raise RegistryConvergenceError("runtime_bundle_missing_source_registry")
|
||||
if report.get("source_registry_converged") is not True:
|
||||
raise RegistryConvergenceError("source_registry_not_converged_or_not_checked")
|
||||
if report.get("registry_consistency_status") not in {"FULLY_CONSISTENT", "PASS"}:
|
||||
raise RegistryConvergenceError(
|
||||
f"runtime_registry_consistency:{report.get('registry_consistency_status')}"
|
||||
)
|
||||
return report
|
||||
|
||||
|
||||
def source_registry_from_main(
|
||||
repo_git_dir: Path | str,
|
||||
version: str,
|
||||
*,
|
||||
main_ref: str = DEFAULT_MAIN_REF,
|
||||
source_registry_main_oid: str | None = None,
|
||||
expected_tag_oid: str | None = None,
|
||||
expected_target_oid: str | None = None,
|
||||
expected_evidence_closure_oid: str | None = None,
|
||||
expected_approval_plan_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Read and validate the canonical source entry from the supplied main ref."""
|
||||
try:
|
||||
return source_registry_snapshot(
|
||||
repo_git_dir,
|
||||
version,
|
||||
main_ref=main_ref,
|
||||
source_registry_main_oid=source_registry_main_oid,
|
||||
expected_tag_oid=expected_tag_oid,
|
||||
expected_target_oid=expected_target_oid,
|
||||
expected_evidence_closure_oid=expected_evidence_closure_oid,
|
||||
expected_approval_plan_id=expected_approval_plan_id,
|
||||
)["record"]
|
||||
except RegistryConvergenceError as exc:
|
||||
if str(exc).startswith("fatal:") or "path" in str(exc).lower():
|
||||
raise RegistryConvergenceError(
|
||||
f"source_registry_entry_missing:{source_registry_main_oid or main_ref}:versions/{version}.json"
|
||||
) from exc
|
||||
raise
|
||||
|
||||
|
||||
def assert_source_registry_convergence(
|
||||
registry_path: Path | str,
|
||||
*,
|
||||
repo_git_dir: Path | str,
|
||||
main_ref: str = DEFAULT_MAIN_REF,
|
||||
source_registry_main_oid: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Require governed main to contain a source entry for the release identity.
|
||||
|
||||
The source entry is intentionally required to match only fields that are
|
||||
knowable and governable before production deployment. Post-deployment
|
||||
fields such as production_service/port/status timestamps are not required.
|
||||
"""
|
||||
registry_path = Path(registry_path)
|
||||
repo_git_dir = Path(repo_git_dir)
|
||||
verify_authoritative_repository(repo_git_dir)
|
||||
record = _load_object(registry_path)
|
||||
|
||||
version = record.get("version")
|
||||
tag_oid = record.get("tag_oid")
|
||||
target_oid = record.get("target_oid")
|
||||
|
||||
try:
|
||||
validate_source_registry_record(
|
||||
record,
|
||||
expected_version=version,
|
||||
expected_tag_oid=tag_oid,
|
||||
expected_target_oid=target_oid,
|
||||
expected_evidence_closure_oid=record.get("evidence_closure_oid"),
|
||||
expected_approval_plan_id=record.get("approval_plan_id"),
|
||||
)
|
||||
except RegistryConvergenceError as exc:
|
||||
raise RegistryConvergenceError(f"registry_identity_invalid:{exc}") from exc
|
||||
convergence = verify_source_registry_main_convergence(
|
||||
repo_git_dir,
|
||||
version,
|
||||
main_ref=main_ref,
|
||||
source_registry_main_oid=source_registry_main_oid,
|
||||
expected_tag_oid=tag_oid,
|
||||
expected_target_oid=target_oid,
|
||||
expected_evidence_closure_oid=record["evidence_closure_oid"],
|
||||
expected_approval_plan_id=record["approval_plan_id"],
|
||||
)
|
||||
return {
|
||||
**convergence,
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# External agent ingress. TLS termination is assumed on the enclosing server.
|
||||
# Control endpoints (/v1/agents/register, POST /v1/tasks, verify/cancel/events)
|
||||
# are intentionally NOT exposed here; the AIOS control plane reaches them on
|
||||
# loopback with its server-side credential.
|
||||
|
||||
location = /agent-tasks/health {
|
||||
rewrite ^/agent-tasks/(.*)$ /$1 break;
|
||||
proxy_pass http://127.0.0.1:18940;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /agent-tasks/v1/tasks/poll {
|
||||
limit_except POST { deny all; }
|
||||
rewrite ^/agent-tasks/(.*)$ /$1 break;
|
||||
proxy_pass http://127.0.0.1:18940;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 1m;
|
||||
proxy_read_timeout 35s;
|
||||
}
|
||||
|
||||
location ~ ^/agent-tasks/v1/tasks/[A-Za-z0-9][A-Za-z0-9._:-]{2,179}/(claim|heartbeat|result)$ {
|
||||
limit_except POST { deny all; }
|
||||
rewrite ^/agent-tasks/(.*)$ /$1 break;
|
||||
proxy_pass http://127.0.0.1:18940;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 1m;
|
||||
proxy_read_timeout 35s;
|
||||
}
|
||||
|
||||
location ~ ^/agent-tasks/v1/tasks/[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$ {
|
||||
limit_except GET { deny all; }
|
||||
rewrite ^/agent-tasks/(.*)$ /$1 break;
|
||||
proxy_pass http://127.0.0.1:18940;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 35s;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
[Unit]
|
||||
Description=AIOS Online Agent Task API
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios-agent-task
|
||||
Group=aios-agent-task
|
||||
WorkingDirectory=@RUNTIME_ROOT@
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
Environment=PYTHONPATH=@RUNTIME_ROOT@/app
|
||||
Environment=AIOS_AGENT_TASK_DB=/var/lib/aios-agent-task/state.sqlite3
|
||||
Environment=AIOS_AGENT_TASK_HOST=127.0.0.1
|
||||
Environment=AIOS_AGENT_TASK_PORT=18940
|
||||
Environment=AIOS_AGENT_TASK_CONTROL_TOKEN_FILE=/run/credentials/aios-agent-task-api.service/control_token
|
||||
LoadCredential=control_token:/etc/aios-gitops/agent-task-control.token
|
||||
ExecStart=/usr/bin/python3 @RUNTIME_ROOT@/app/aios_agent_task_api.py
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
ReadOnlyPaths=@RUNTIME_ROOT@
|
||||
ReadWritePaths=/var/lib/aios-agent-task
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,7 @@
|
||||
# Existing model API drop-in. Install only with the governed runtime package.
|
||||
[Service]
|
||||
SupplementaryGroups=gitops aios-executor-exchange aios-autonomous-agent-worker
|
||||
Environment=AIOS_OWNER_AUTH_BROKER_SOCKET=/run/aios-owner-authorization-broker/broker.sock
|
||||
Environment=AIOS_OWNER_AUTH_BROKER_ACCOUNT=chatgpt-governed-implementer-singlehost
|
||||
Environment=AIOS_OWNER_AUTH_BROKER_CALLER=chatgpt-governed-implementer-singlehost
|
||||
Environment=AIOS_OWNER_AUTH_BROKER_SCOPE=release:candidate
|
||||
@@ -28,6 +28,9 @@ Environment=AIOS_GITOPS_MODEL_API_SERVICE=aios-gitops-model-api-v2r2c1-staging.s
|
||||
Environment=AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
||||
Environment=AIOS_SILENT_RELEASE_STATE_ROOT=/var/lib/aios-gitops/model-api-staging/silent-release
|
||||
Environment=AIOS_BRIDGE_V2_URL=http://127.0.0.1:18892
|
||||
Environment=AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940
|
||||
Environment=AIOS_AGENT_TASK_CONTROL_TOKEN_FILE=/run/credentials/aios-gitops-model-api-v2r2c1-staging.service/agent_task_control_token
|
||||
LoadCredential=agent_task_control_token:/etc/aios-gitops/agent-task-control.token
|
||||
Environment=AIOS_GITOPS_HOST=127.0.0.1
|
||||
Environment=AIOS_GITOPS_PORT=18893
|
||||
Environment=PYTHONPATH=@RUNTIME_ROOT@/app:@RUNTIME_ROOT@/runtime-agent-os
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
[Unit]
|
||||
Description=AIOS durable Owner authorization broker
|
||||
After=network.target aios-chatgpt-bridge-v2-staging.service
|
||||
Wants=aios-chatgpt-bridge-v2-staging.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=aios-executor-exchange
|
||||
RuntimeDirectory=aios-owner-authorization-broker
|
||||
RuntimeDirectoryMode=0750
|
||||
StateDirectory=aios-owner-authorization-broker
|
||||
StateDirectoryMode=0750
|
||||
LoadCredential=agent_task_control_token:/etc/aios-gitops/agent-task-control.token
|
||||
Environment=AIOS_AGENT_TASK_CONTROL_TOKEN_FILE=%d/agent_task_control_token
|
||||
Environment=AIOS_OWNER_DURABLE_AUTHORIZATION_POLICY=/etc/aios-gitops/owner-durable-authorization.json
|
||||
Environment=AIOS_OWNER_DURABLE_AUTHORIZATION_REVOCATIONS=/etc/aios-gitops/owner-durable-authorization-revocations.json
|
||||
Environment=AIOS_OWNER_AUTH_BROKER_ACCOUNT=chatgpt-governed-implementer-singlehost
|
||||
Environment=AIOS_OWNER_AUTH_BROKER_ALLOWED_USERS=aios-autonomous-agent-worker,aiman
|
||||
Environment=AIOS_OWNER_AUTH_BROKER_SOCKET=%t/aios-owner-authorization-broker/broker.sock
|
||||
ExecStart=/usr/bin/python3 -B @RUNTIME_ROOT@/active-runtime/app/aios_durable_owner_authorization.py
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectClock=yes
|
||||
LockPersonality=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=127.0.0.0/8
|
||||
ReadOnlyPaths=/etc/aios-gitops/owner-durable-authorization.json
|
||||
ReadOnlyPaths=/etc/aios-gitops/owner-durable-authorization-revocations.json
|
||||
ReadOnlyPaths=@RUNTIME_ROOT@
|
||||
InaccessiblePaths=/data/aios-gitops /home
|
||||
UMask=0007
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,88 @@
|
||||
# Caller admission projection and rehydration
|
||||
|
||||
This runbook covers the exact durable Owner policy and the exact API caller:
|
||||
|
||||
- caller: \`chatgpt-governed-implementer-singlehost\`
|
||||
- scope: \`release:candidate\`
|
||||
- policy source: \`/etc/aios-gitops/owner-durable-authorization.json\`
|
||||
- revocation source: \`/etc/aios-gitops/owner-durable-authorization-revocations.json\`
|
||||
|
||||
## Admission source
|
||||
|
||||
The model API first authenticates the existing caller token from the static
|
||||
registry. For this exact caller and exact release scope, static authentication
|
||||
is not sufficient. The model API calls the existing Unix-socket Owner broker.
|
||||
The broker reloads and validates the durable policy on every projection request,
|
||||
including account, capability, optional expiry, and revocation.
|
||||
|
||||
Other callers retain their existing static-scope behavior. The target caller
|
||||
does not receive a permanent \`release:candidate\` entry in the static registry,
|
||||
and it is never treated as a release-worker account.
|
||||
|
||||
## Projection lifecycle and ACK semantics
|
||||
|
||||
The broker records only non-secret projection metadata in its existing durable
|
||||
state database. The state sequence is:
|
||||
|
||||
\`PENDING\` -> \`ACKED\`
|
||||
|
||||
The ACK is produced by the broker's server-side projection consumer after the
|
||||
exact policy and request binding have passed validation. The public response
|
||||
contains projection state, effective scope, policy digest, expiry and
|
||||
rehydration state. The short-lived credential material is returned only in a
|
||||
private Unix-socket response header to the model API process. It is not written
|
||||
to the response body, logs, diagnostics, Git, task metadata or terminal output.
|
||||
|
||||
## Credential rehydration, expiry and revocation
|
||||
|
||||
The credential projection is generated in memory for a short TTL and is
|
||||
discarded after the model API admission check. The durable row stores no raw
|
||||
credential and no bearer token. After a broker or model API restart, the next
|
||||
admission request creates a new short-lived credential from the durable
|
||||
projection metadata; this is reported as \`RECOVERED\`.
|
||||
|
||||
Every request reloads the policy. Missing, malformed, expired or revoked policy
|
||||
fails closed. A caller mismatch, scope mismatch, invalid projection state,
|
||||
broker outage, missing credential header or unexpected secret field also fails
|
||||
closed. No static scope fallback is allowed for the target caller.
|
||||
|
||||
## Diagnosis
|
||||
|
||||
Use the read-only API with the caller's existing credential:
|
||||
|
||||
\`GET /api/caller-capabilities\`
|
||||
|
||||
The response is safe to display and contains:
|
||||
|
||||
- \`caller_identity\`
|
||||
- \`admitted\`
|
||||
- \`effective_scopes\`
|
||||
- \`projection_state\`
|
||||
- \`credential_state\`
|
||||
- \`rehydration_state\`
|
||||
- \`secret_exposed=false\`
|
||||
|
||||
It never contains a bearer, token, credential value or credential hash.
|
||||
|
||||
For a release candidate request, the model API also binds the exact
|
||||
\`source_commit\` and \`version\` to the durable policy before invoking the
|
||||
trusted release service. The caller cannot supply release evidence fields.
|
||||
|
||||
## Failure states and troubleshooting
|
||||
|
||||
- \`PROJECTION_BROKER_UNAVAILABLE\`: verify the broker unit, socket path and
|
||||
the model API supplementary groups; do not add a static scope.
|
||||
- \`OWNER_GRANT_REVOKED\` or \`POLICY_EXPIRED\`: restore a valid Owner policy
|
||||
through governed policy management; do not edit broker state.
|
||||
- \`CALLER_MISMATCH\` or \`SCOPE_MISMATCH\`: verify the authenticated account and
|
||||
requested exact scope.
|
||||
- \`PROJECTION_STATE_INVALID\`: inspect the durable projection through the
|
||||
broker's read-only diagnostics and fail closed until it is reconciled.
|
||||
- \`CREDENTIAL_REHYDRATION_MISSING\`: restart only the related control-plane
|
||||
service after capturing pre-state and then repeat the read-only diagnosis.
|
||||
- \`trusted candidate builder is not configured\`: Gate A admission is working,
|
||||
but the separate existing TrustedCandidateBuilder runtime must be restored
|
||||
before Gate B can create a candidate.
|
||||
|
||||
No Owner Approval, Deployment, Host Seed, Host Activation, Projector,
|
||||
continuation task, Tag or Baseline action is part of this path.
|
||||
@@ -0,0 +1,295 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: AIOS Online Agent Task API
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Durable online task distribution for OpenClaw, Hermes, and other external
|
||||
agents. Agent credentials and lease tokens are bearer credentials and must
|
||||
never be committed to Git.
|
||||
servers:
|
||||
- url: /agent-tasks
|
||||
paths:
|
||||
/health:
|
||||
get:
|
||||
operationId: agentTaskHealth
|
||||
responses:
|
||||
"200":
|
||||
description: Service health
|
||||
/v1/agents/register:
|
||||
post:
|
||||
operationId: registerOnlineAgent
|
||||
security:
|
||||
- bearerAuth: []
|
||||
description: Control-plane only. Returns the agent token once.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RegisterAgentRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Registered
|
||||
/v1/tasks:
|
||||
post:
|
||||
operationId: createOnlineAgentTask
|
||||
security:
|
||||
- bearerAuth: []
|
||||
description: Control-plane only.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateTaskRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Task created
|
||||
/v1/tasks/poll:
|
||||
post:
|
||||
operationId: pollOnlineAgentTasks
|
||||
security:
|
||||
- bearerAuth: []
|
||||
description: Agent operation. Returns capability-compatible queued tasks.
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 20
|
||||
responses:
|
||||
"200":
|
||||
description: Available tasks
|
||||
/v1/tasks/{task_id}:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaskId"
|
||||
get:
|
||||
operationId: getOnlineAgentTaskDirect
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: Task status
|
||||
/v1/tasks/{task_id}/claim:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaskId"
|
||||
post:
|
||||
operationId: claimOnlineAgentTask
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
lease_seconds:
|
||||
type: integer
|
||||
minimum: 30
|
||||
maximum: 3600
|
||||
default: 300
|
||||
responses:
|
||||
"200":
|
||||
description: >
|
||||
Claim succeeded. The lease_token is returned once and must be kept
|
||||
in runtime memory/secret storage, never Git.
|
||||
"409":
|
||||
description: Already claimed or otherwise not claimable
|
||||
/v1/tasks/{task_id}/heartbeat:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaskId"
|
||||
post:
|
||||
operationId: heartbeatOnlineAgentTask
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HeartbeatRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Lease renewed
|
||||
/v1/tasks/{task_id}/result:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaskId"
|
||||
post:
|
||||
operationId: submitOnlineAgentResult
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ResultRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Result accepted into VERIFYING
|
||||
/v1/tasks/{task_id}/verify:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaskId"
|
||||
post:
|
||||
operationId: verifyOnlineAgentTaskDirect
|
||||
security:
|
||||
- bearerAuth: []
|
||||
description: Control-plane only.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/VerifyRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Verification decision applied
|
||||
/v1/tasks/{task_id}/cancel:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaskId"
|
||||
post:
|
||||
operationId: cancelOnlineAgentTaskDirect
|
||||
security:
|
||||
- bearerAuth: []
|
||||
description: Control-plane only.
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
reason:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Cancelled or already terminal
|
||||
/v1/tasks/{task_id}/events:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaskId"
|
||||
get:
|
||||
operationId: getOnlineAgentTaskEventsDirect
|
||||
security:
|
||||
- bearerAuth: []
|
||||
description: Control-plane only.
|
||||
responses:
|
||||
"200":
|
||||
description: Audit event list
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
parameters:
|
||||
TaskId:
|
||||
name: task_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$"
|
||||
schemas:
|
||||
RegisterAgentRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [agent_id, agent_type, agent_version, capabilities]
|
||||
properties:
|
||||
agent_id:
|
||||
type: string
|
||||
agent_type:
|
||||
type: string
|
||||
examples: [openclaw]
|
||||
agent_version:
|
||||
type: string
|
||||
capabilities:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
CreateTaskRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- goal
|
||||
- instructions
|
||||
- success_criteria
|
||||
- idempotency_key
|
||||
properties:
|
||||
task_id:
|
||||
type: string
|
||||
goal:
|
||||
type: string
|
||||
instructions:
|
||||
type: string
|
||||
success_criteria:
|
||||
type: array
|
||||
items: {}
|
||||
required_capabilities:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
constraints:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
deadline:
|
||||
type: string
|
||||
format: date-time
|
||||
priority:
|
||||
type: integer
|
||||
minimum: -100
|
||||
maximum: 100
|
||||
default: 0
|
||||
max_attempts:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 20
|
||||
default: 3
|
||||
idempotency_key:
|
||||
type: string
|
||||
HeartbeatRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [lease_token]
|
||||
properties:
|
||||
lease_token:
|
||||
type: string
|
||||
lease_seconds:
|
||||
type: integer
|
||||
minimum: 30
|
||||
maximum: 3600
|
||||
default: 300
|
||||
progress:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
ResultRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [lease_token, idempotency_key, result]
|
||||
properties:
|
||||
lease_token:
|
||||
type: string
|
||||
idempotency_key:
|
||||
type: string
|
||||
result:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
VerifyRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [decision]
|
||||
properties:
|
||||
decision:
|
||||
type: string
|
||||
enum: [SUCCEEDED, NEEDS_REWORK, FAILED]
|
||||
verification:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,326 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: AIOS ChatGPT Governed Write and Agent Task Extension
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Merge these paths and schemas into docs/gitops/openapi-chatgpt-actions.yaml.
|
||||
This extension deliberately exposes governed Bridge v2 operations rather
|
||||
than reopening legacy AIOS_GITOPS_WRITES_ENABLED.
|
||||
paths:
|
||||
/api/governed-write-capabilities:
|
||||
get:
|
||||
operationId: getGovernedWriteCapabilities
|
||||
responses:
|
||||
"200":
|
||||
description: Governed and legacy write capability status
|
||||
/api/control-plane-snapshot:
|
||||
get:
|
||||
operationId: getControlPlaneSnapshot
|
||||
responses:
|
||||
"200":
|
||||
description: Non-sensitive control-plane snapshot
|
||||
/createGovernedTask:
|
||||
post:
|
||||
operationId: createGovernedTask
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateGovernedTaskRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Governed task binding
|
||||
/acquireWriterLease:
|
||||
post:
|
||||
operationId: acquireWriterLease
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/WriterLeaseRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Writer lease binding
|
||||
/commitFileGoverned:
|
||||
post:
|
||||
operationId: commitFileGoverned
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CommitFileGovernedRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Durable GitOps job receipt
|
||||
/commitFilesGoverned:
|
||||
post:
|
||||
operationId: commitFilesGoverned
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CommitFilesGovernedRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Durable GitOps job receipt
|
||||
/getGitOpsJob:
|
||||
post:
|
||||
operationId: getGitOpsJob
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [job_id]
|
||||
properties:
|
||||
job_id: {type: string}
|
||||
responses:
|
||||
"200":
|
||||
description: Job receipt
|
||||
/resumeGitOpsJob:
|
||||
post:
|
||||
operationId: resumeGitOpsJob
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [job_id, expected_revision]
|
||||
properties:
|
||||
job_id: {type: string}
|
||||
expected_revision: {type: integer}
|
||||
responses:
|
||||
"200":
|
||||
description: Resumed job receipt
|
||||
/reconcileGitOpsJob:
|
||||
post:
|
||||
operationId: reconcileGitOpsJob
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [job_id, expected_revision]
|
||||
properties:
|
||||
job_id: {type: string}
|
||||
expected_revision: {type: integer}
|
||||
responses:
|
||||
"200":
|
||||
description: Reconciliation receipt
|
||||
/createOnlineAgentTask:
|
||||
post:
|
||||
operationId: createOnlineAgentTask
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateOnlineAgentTaskRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Online task created
|
||||
"201":
|
||||
description: Online task created
|
||||
/registerOnlineAgent:
|
||||
post:
|
||||
operationId: registerOnlineAgent
|
||||
x-openai-isConsequential: true
|
||||
description: >
|
||||
Control operation. The returned agent token is secret material and must
|
||||
be delivered through runtime secret storage, never Git.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [agent_id, agent_type, agent_version, capabilities]
|
||||
properties:
|
||||
agent_id: {type: string}
|
||||
agent_type: {type: string}
|
||||
agent_version: {type: string}
|
||||
capabilities:
|
||||
type: array
|
||||
items: {type: string}
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
responses:
|
||||
"201":
|
||||
description: Agent registered
|
||||
/api/online-agent-task:
|
||||
get:
|
||||
operationId: getOnlineAgentTask
|
||||
parameters:
|
||||
- name: task_id
|
||||
in: query
|
||||
required: true
|
||||
schema: {type: string}
|
||||
responses:
|
||||
"200":
|
||||
description: Task status
|
||||
/api/online-agent-task-events:
|
||||
get:
|
||||
operationId: getOnlineAgentTaskEvents
|
||||
parameters:
|
||||
- name: task_id
|
||||
in: query
|
||||
required: true
|
||||
schema: {type: string}
|
||||
responses:
|
||||
"200":
|
||||
description: Task events
|
||||
/verifyOnlineAgentTask:
|
||||
post:
|
||||
operationId: verifyOnlineAgentTask
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [task_id, decision]
|
||||
properties:
|
||||
task_id: {type: string}
|
||||
decision:
|
||||
type: string
|
||||
enum: [SUCCEEDED, NEEDS_REWORK, FAILED]
|
||||
verification:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
responses:
|
||||
"200":
|
||||
description: Verification applied
|
||||
/cancelOnlineAgentTask:
|
||||
post:
|
||||
operationId: cancelOnlineAgentTask
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [task_id]
|
||||
properties:
|
||||
task_id: {type: string}
|
||||
reason: {type: string}
|
||||
responses:
|
||||
"200":
|
||||
description: Task cancelled
|
||||
components:
|
||||
schemas:
|
||||
Oid40:
|
||||
type: string
|
||||
pattern: "^[0-9a-f]{40}$"
|
||||
CreateGovernedTaskRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
[task_id, repository, base_oid, target_branch, risk, description, idempotency_key]
|
||||
properties:
|
||||
task_id: {type: string}
|
||||
repository: {type: string, const: aios/aios}
|
||||
base_oid: {$ref: "#/components/schemas/Oid40"}
|
||||
target_branch:
|
||||
type: string
|
||||
pattern: "^agent/"
|
||||
risk:
|
||||
type: string
|
||||
enum: [low, medium, high, engineering]
|
||||
description: {type: string}
|
||||
idempotency_key: {type: string}
|
||||
expected_branch_oid: {$ref: "#/components/schemas/Oid40"}
|
||||
generation: {type: integer, minimum: 1}
|
||||
WriterLeaseRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
[schema, task_id, repository, branch_mode, base_oid, expected_branch_oid,
|
||||
target_branch, generation, lease_seconds]
|
||||
properties:
|
||||
schema: {type: string}
|
||||
task_id: {type: string}
|
||||
repository: {type: string, const: aios/aios}
|
||||
branch_mode:
|
||||
type: string
|
||||
enum: [CREATE_NEW, CONTINUE_EXISTING]
|
||||
base_oid: {$ref: "#/components/schemas/Oid40"}
|
||||
expected_branch_oid: {$ref: "#/components/schemas/Oid40"}
|
||||
target_branch: {type: string, pattern: "^agent/"}
|
||||
generation: {type: integer, minimum: 1}
|
||||
lease_seconds: {type: integer, minimum: 1, maximum: 86400}
|
||||
CommitFileGovernedRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
[task_id, repository, expected_branch_oid, target_branch, generation,
|
||||
idempotency_key, path, content_base64, message]
|
||||
properties:
|
||||
task_id: {type: string}
|
||||
repository: {type: string, const: aios/aios}
|
||||
expected_branch_oid: {$ref: "#/components/schemas/Oid40"}
|
||||
target_branch: {type: string, pattern: "^agent/"}
|
||||
generation: {type: integer, minimum: 1}
|
||||
idempotency_key: {type: string}
|
||||
path: {type: string}
|
||||
content_base64: {type: string}
|
||||
message: {type: string}
|
||||
CommitFilesGovernedRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
[task_id, repository, expected_branch_oid, target_branch, generation,
|
||||
idempotency_key, files, message]
|
||||
properties:
|
||||
task_id: {type: string}
|
||||
repository: {type: string, const: aios/aios}
|
||||
expected_branch_oid: {$ref: "#/components/schemas/Oid40"}
|
||||
target_branch: {type: string, pattern: "^agent/"}
|
||||
generation: {type: integer, minimum: 1}
|
||||
idempotency_key: {type: string}
|
||||
files:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Base64 encoded file bytes
|
||||
message: {type: string}
|
||||
CreateOnlineAgentTaskRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [goal, instructions, success_criteria, idempotency_key]
|
||||
properties:
|
||||
task_id: {type: string}
|
||||
goal: {type: string}
|
||||
instructions: {type: string}
|
||||
success_criteria:
|
||||
type: array
|
||||
items: {}
|
||||
required_capabilities:
|
||||
type: array
|
||||
items: {type: string}
|
||||
constraints:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
deadline: {type: string, format: date-time}
|
||||
priority: {type: integer, minimum: -100, maximum: 100}
|
||||
max_attempts: {type: integer, minimum: 1, maximum: 20}
|
||||
idempotency_key: {type: string}
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"schema": "aios.source-registry-reconciliation.v1",
|
||||
"version": "V2R2C1",
|
||||
"root_cause": "PROMOTION_STATE_MACHINE_FINALIZED_BEFORE_SOURCE_REGISTRY_CONVERGENCE",
|
||||
"main_before": "3244f0f17f8845592ac94d585f5ebd65693c9f29",
|
||||
"tag_oid": "07fc2746b688f5f243f29b39de39f32edc3dd9d2",
|
||||
"target_oid": "998d30304787e31e5ae607c0bd86b5edb2b0e5ad",
|
||||
"projection_sha256": "924dfc0ec58afc4e31638f6538e551c5bc8858f804cdbee5af7dce6eba7c90fe",
|
||||
"evidence_closure_oid": "a8b906d1bc24088c19647e98de6a679268f6d0a2",
|
||||
"approval_plan_id": "sha256:c0df52008ae7f64cec5ee2d28947aa7b892066c670ab8295e4549140b1a7500e",
|
||||
"remote_tag_verified": true,
|
||||
"remote_target_verified": true,
|
||||
"gitea_api_verified_by_owner_acceptance": true,
|
||||
"source_entry_before": "missing",
|
||||
"source_entry_added": "versions/V2R2C1.json",
|
||||
"source_entry_is_exact_attested_projection": true,
|
||||
"release_manifest_added": false,
|
||||
"release_manifest_reason": "Historical release-manifest schema differs; no canonical V2R2C1 source-manifest generator was proven. Do not synthesize one by copying old release values.",
|
||||
"runtime_backing": "immutable WS/versions bundle",
|
||||
"runtime_mutated": false,
|
||||
"tag_rewritten": false,
|
||||
"baseline_modified": false,
|
||||
"production_modified": false
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"execution_id": "exec_d75446452723",
|
||||
"user_id": "user1",
|
||||
"visible_scope": "group1",
|
||||
"entry_agent": "openclaw",
|
||||
"path": [
|
||||
"openclaw",
|
||||
"hermes",
|
||||
"codex"
|
||||
],
|
||||
"used_hermes": true,
|
||||
"closed_by": "codex",
|
||||
"duration_ms": 0,
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 200,
|
||||
"total_tokens": 300,
|
||||
"status": "completed",
|
||||
"started_at": "2026-08-09T17:13:34.996470+00:00",
|
||||
"ended_at": "2026-08-09T17:13:34.996489+00:00"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"task_id": "task-command_watcher-CMD-test", "title": "CMD-test", "assigned_by": "command-watcher", "executor": "openclaw", "source": "command_watcher", "status": "pending", "priority": "normal", "created_at": "2026-08-09T17:13:47.285193+00:00", "updated_at": "2026-08-09T17:13:47.285198+00:00", "progress": {"percent": 5, "current_step": "pending", "completed_steps": [], "remaining_steps": [], "blocked_reason": null}, "notification": {"required": true, "sent": false, "channel": null, "sent_at": null}, "evidence": ["ops/openclaw/CMD-test.md"], "metadata": {"bucket": "active"}}
|
||||
{"task_id": "task-agent_execution-e1", "title": "Agent execution e1", "assigned_by": "hermes", "executor": "hermes", "source": "agent_execution", "status": "completed", "priority": "normal", "created_at": "2026-08-09T17:13:47.285822+00:00", "updated_at": "2026-08-09T17:13:47.285826+00:00", "progress": {"percent": 100, "current_step": "completed", "completed_steps": [], "remaining_steps": [], "blocked_reason": null}, "notification": {"required": true, "sent": false, "channel": null, "sent_at": null}, "evidence": ["e1"], "metadata": {"execution_id": "e1", "entry_agent": "hermes", "status": "completed"}}
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small CLI used by OpenClaw/Hermes operators to validate AIOS online tasks."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
def read_token(value: str | None, path: str | None) -> str:
|
||||
if value:
|
||||
return value.strip()
|
||||
if path:
|
||||
return Path(path).read_text(encoding="utf-8").strip()
|
||||
raise SystemExit("token or token-file is required")
|
||||
|
||||
|
||||
def request(base: str, method: str, path: str, token: str, body=None):
|
||||
payload = None if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||||
req = Request(
|
||||
base.rstrip("/") + path,
|
||||
data=payload,
|
||||
method=method,
|
||||
headers={
|
||||
"Authorization": "Bearer " + token,
|
||||
"Accept": "application/json",
|
||||
**({"Content-Type": "application/json"} if payload is not None else {}),
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urlopen(req, timeout=30) as response:
|
||||
return response.status, json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as exc:
|
||||
raw = exc.read()
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except Exception:
|
||||
value = {"error": "HTTP_" + str(exc.code)}
|
||||
return exc.code, value
|
||||
except (URLError, TimeoutError) as exc:
|
||||
raise SystemExit("network_error:" + type(exc).__name__) from exc
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--base", required=True)
|
||||
ap.add_argument("--token")
|
||||
ap.add_argument("--token-file")
|
||||
sub = ap.add_subparsers(dest="command", required=True)
|
||||
|
||||
p = sub.add_parser("poll")
|
||||
p.add_argument("--limit", type=int, default=20)
|
||||
|
||||
p = sub.add_parser("claim")
|
||||
p.add_argument("task_id")
|
||||
p.add_argument("--lease-seconds", type=int, default=300)
|
||||
|
||||
p = sub.add_parser("heartbeat")
|
||||
p.add_argument("task_id")
|
||||
p.add_argument("--lease-token", required=True)
|
||||
p.add_argument("--lease-seconds", type=int, default=300)
|
||||
p.add_argument("--progress-json", default="{}")
|
||||
|
||||
p = sub.add_parser("result")
|
||||
p.add_argument("task_id")
|
||||
p.add_argument("--lease-token", required=True)
|
||||
p.add_argument("--idempotency-key", required=True)
|
||||
p.add_argument("--result-json", required=True)
|
||||
|
||||
p = sub.add_parser("status")
|
||||
p.add_argument("task_id")
|
||||
|
||||
args = ap.parse_args()
|
||||
token = read_token(args.token, args.token_file)
|
||||
|
||||
if args.command == "poll":
|
||||
status, value = request(args.base, "POST", "/v1/tasks/poll", token, {"limit": args.limit})
|
||||
elif args.command == "claim":
|
||||
status, value = request(
|
||||
args.base, "POST", f"/v1/tasks/{args.task_id}/claim", token,
|
||||
{"lease_seconds": args.lease_seconds},
|
||||
)
|
||||
elif args.command == "heartbeat":
|
||||
status, value = request(
|
||||
args.base, "POST", f"/v1/tasks/{args.task_id}/heartbeat", token,
|
||||
{
|
||||
"lease_token": args.lease_token,
|
||||
"lease_seconds": args.lease_seconds,
|
||||
"progress": json.loads(args.progress_json),
|
||||
},
|
||||
)
|
||||
elif args.command == "result":
|
||||
status, value = request(
|
||||
args.base, "POST", f"/v1/tasks/{args.task_id}/result", token,
|
||||
{
|
||||
"lease_token": args.lease_token,
|
||||
"idempotency_key": args.idempotency_key,
|
||||
"result": json.loads(args.result_json),
|
||||
},
|
||||
)
|
||||
else:
|
||||
status, value = request(args.base, "GET", f"/v1/tasks/{args.task_id}", token)
|
||||
|
||||
print(json.dumps({"http_status": status, "response": value}, ensure_ascii=False, indent=2))
|
||||
return 0 if 200 <= status < 300 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end online task feasibility smoke test.
|
||||
|
||||
Runs through the public agent path plus control operations:
|
||||
create -> poll -> claim -> heartbeat -> result -> verify -> readback.
|
||||
It never performs Git, tag, Baseline, or production mutations.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
def token(path: Path) -> str:
|
||||
value = path.read_text(encoding="utf-8").strip()
|
||||
if len(value) < 32:
|
||||
raise SystemExit("credential_invalid:" + str(path))
|
||||
return value
|
||||
|
||||
|
||||
def call(base: str, method: str, route: str, credential: str, body=None):
|
||||
payload = None if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||||
headers = {
|
||||
"Authorization": "Bearer " + credential,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if payload is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = Request(base.rstrip("/") + route, data=payload, headers=headers, method=method)
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
return response.status, json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as exc:
|
||||
try:
|
||||
value = json.loads(exc.read().decode("utf-8"))
|
||||
except Exception:
|
||||
value = {"error": "HTTP_" + str(exc.code)}
|
||||
return exc.code, value
|
||||
except (URLError, TimeoutError) as exc:
|
||||
raise SystemExit("network_error:" + type(exc).__name__) from exc
|
||||
|
||||
|
||||
def require(step: str, status: int, value, expected_status: str | None = None):
|
||||
if not 200 <= status < 300:
|
||||
raise SystemExit(f"{step}_failed:http={status}:body={json.dumps(value, ensure_ascii=False)}")
|
||||
if expected_status and value.get("status") != expected_status:
|
||||
raise SystemExit(
|
||||
f"{step}_state_failed:expected={expected_status}:"
|
||||
f"observed={value.get('status')}"
|
||||
)
|
||||
print(json.dumps({"step": step, "http": status, "response": value}, ensure_ascii=False))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--control-base", required=True,
|
||||
help="Loopback/model-control base that exposes /v1 control routes")
|
||||
parser.add_argument("--agent-base", required=True,
|
||||
help="Public agent base, e.g. https://host/agent-tasks")
|
||||
parser.add_argument("--control-token-file", required=True, type=Path)
|
||||
parser.add_argument("--agent-token-file", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
control = token(args.control_token_file)
|
||||
agent = token(args.agent_token_file)
|
||||
task_id = "TASK-ONLINE-SMOKE-" + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + secrets.token_hex(3)
|
||||
create_key = "smoke-create:" + task_id
|
||||
result_key = "smoke-result:" + task_id
|
||||
|
||||
s, v = call(
|
||||
args.control_base, "POST", "/v1/tasks", control,
|
||||
{
|
||||
"task_id": task_id,
|
||||
"goal": "Validate AIOS online task distribution end to end",
|
||||
"instructions": (
|
||||
"Do not mutate Git or production. Return a structured result "
|
||||
"containing marker ONLINE_TASK_FLOW_OK."
|
||||
),
|
||||
"success_criteria": [
|
||||
{"field": "marker", "equals": "ONLINE_TASK_FLOW_OK"},
|
||||
{"field": "agent_observed_task_id", "equals": task_id},
|
||||
],
|
||||
"required_capabilities": ["online-task-smoke"],
|
||||
"constraints": {
|
||||
"git_mutation": False,
|
||||
"production_mutation": False,
|
||||
"network_scope": "AIOS task endpoint only",
|
||||
},
|
||||
"priority": 100,
|
||||
"max_attempts": 3,
|
||||
"idempotency_key": create_key,
|
||||
},
|
||||
)
|
||||
require("create", s, v, "QUEUED")
|
||||
|
||||
s, v = call(args.agent_base, "POST", "/v1/tasks/poll", agent, {"limit": 100})
|
||||
require("poll", s, v)
|
||||
if task_id not in [item.get("task_id") for item in v.get("tasks", [])]:
|
||||
raise SystemExit("poll_failed:created_task_not_visible")
|
||||
|
||||
s, v = call(
|
||||
args.agent_base, "POST", f"/v1/tasks/{task_id}/claim", agent,
|
||||
{"lease_seconds": 120},
|
||||
)
|
||||
require("claim", s, v, "CLAIMED")
|
||||
lease = v.get("lease_token")
|
||||
if not lease:
|
||||
raise SystemExit("claim_failed:lease_token_missing")
|
||||
|
||||
s, v = call(
|
||||
args.agent_base, "POST", f"/v1/tasks/{task_id}/heartbeat", agent,
|
||||
{
|
||||
"lease_token": lease,
|
||||
"lease_seconds": 120,
|
||||
"progress": {"stage": "executing", "percent": 50},
|
||||
},
|
||||
)
|
||||
require("heartbeat", s, v, "RUNNING")
|
||||
|
||||
s, v = call(
|
||||
args.agent_base, "POST", f"/v1/tasks/{task_id}/result", agent,
|
||||
{
|
||||
"lease_token": lease,
|
||||
"idempotency_key": result_key,
|
||||
"result": {
|
||||
"status": "completed",
|
||||
"marker": "ONLINE_TASK_FLOW_OK",
|
||||
"agent_observed_task_id": task_id,
|
||||
"summary": "Agent received, claimed, heartbeated and returned the online task.",
|
||||
"artifacts": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
require("result", s, v, "VERIFYING")
|
||||
|
||||
# Re-submit the identical result to prove idempotency.
|
||||
s2, v2 = call(
|
||||
args.agent_base, "POST", f"/v1/tasks/{task_id}/result", agent,
|
||||
{
|
||||
"lease_token": lease,
|
||||
"idempotency_key": result_key,
|
||||
"result": {
|
||||
"status": "completed",
|
||||
"marker": "ONLINE_TASK_FLOW_OK",
|
||||
"agent_observed_task_id": task_id,
|
||||
"summary": "Agent received, claimed, heartbeated and returned the online task.",
|
||||
"artifacts": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
require("result_idempotent_replay", s2, v2, "VERIFYING")
|
||||
if v2.get("idempotent") is not True:
|
||||
raise SystemExit("result_idempotency_failed")
|
||||
|
||||
s, v = call(
|
||||
args.control_base, "POST", f"/v1/tasks/{task_id}/verify", control,
|
||||
{
|
||||
"decision": "SUCCEEDED",
|
||||
"verification": {
|
||||
"marker_match": "PASS",
|
||||
"task_id_match": "PASS",
|
||||
"online_round_trip": "PASS",
|
||||
},
|
||||
},
|
||||
)
|
||||
require("verify", s, v, "SUCCEEDED")
|
||||
|
||||
s, v = call(args.control_base, "GET", f"/v1/tasks/{task_id}", control)
|
||||
require("final_control_readback", s, v, "SUCCEEDED")
|
||||
|
||||
# The executing Agent must also be able to observe the final terminal state.
|
||||
s, v = call(args.agent_base, "GET", f"/v1/tasks/{task_id}", agent)
|
||||
require("final_agent_readback", s, v, "SUCCEEDED")
|
||||
|
||||
print(json.dumps({
|
||||
"status": "ONLINE_TASK_FLOW_VERIFIED",
|
||||
"task_id": task_id,
|
||||
"final_state": v.get("status"),
|
||||
"force_push_used": False,
|
||||
"tag_rewritten": False,
|
||||
"direct_main_push": False,
|
||||
"production_changed": False,
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -32,36 +32,18 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument("--tree-oid", required=True)
|
||||
parser.add_argument("--output-directory", required=True, type=Path)
|
||||
parser.add_argument("--name", default="aios-autonomous-release-candidate")
|
||||
parser.add_argument("--release-version")
|
||||
parser.add_argument("--release-binding-json", type=Path)
|
||||
parser.add_argument("--artifact-mode", choices=("release", "general"))
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
release_binding = (
|
||||
json.loads(args.release_binding_json.read_text(encoding="utf-8"))
|
||||
if args.release_binding_json is not None
|
||||
else None
|
||||
)
|
||||
if release_binding is not None and not isinstance(release_binding, dict):
|
||||
raise ValueError("release binding JSON must be an object")
|
||||
output = args.output_directory
|
||||
output.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
if output.is_symlink():
|
||||
raise RuntimeError("artifact output directory symlink rejected")
|
||||
os.chmod(output, output.stat().st_mode & 0o750)
|
||||
builder = ArtifactBuilder()
|
||||
built = builder.build_from_directory(
|
||||
args.source_oid,
|
||||
args.tree_oid,
|
||||
args.source,
|
||||
release_version=args.release_version,
|
||||
release_binding=release_binding,
|
||||
release_mode=args.release_version is not None,
|
||||
artifact_mode=args.artifact_mode,
|
||||
)
|
||||
built = builder.build_from_directory(args.source_oid, args.tree_oid, args.source)
|
||||
builder.verify(built)
|
||||
artifact_path = output / f"{args.name}.tar"
|
||||
manifest_path = output / f"{args.name}.manifest.json"
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build deterministic immutable staging artifact for AIOS Agent Task API."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
|
||||
REQUIRED = (
|
||||
"active-runtime/app/aios_agent_task_protocol.py",
|
||||
"active-runtime/app/aios_agent_task_api.py",
|
||||
"docs/gitops/openapi-agent-task-v1.yaml",
|
||||
)
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--source-root", required=True, type=Path)
|
||||
parser.add_argument("--output-root", required=True, type=Path)
|
||||
parser.add_argument("--source-oid", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
oid = args.source_oid.strip().lower()
|
||||
if len(oid) != 40 or any(ch not in "0123456789abcdef" for ch in oid):
|
||||
raise SystemExit("source oid must be exact 40 hex")
|
||||
source = args.source_root.resolve(strict=True)
|
||||
output = args.output_root.resolve()
|
||||
for relative in REQUIRED:
|
||||
if not (source / relative).is_file():
|
||||
raise SystemExit("required source missing: " + relative)
|
||||
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
archive = output / f"aios-agent-task-api-{oid}.tar.gz"
|
||||
manifest = {
|
||||
"schema": "aios.agent-task-artifact.v1",
|
||||
"source_oid": oid,
|
||||
"repository_identity": "aios/aios",
|
||||
"deployment_state": "STAGING",
|
||||
"runtime_mode": "IMMUTABLE_STAGING",
|
||||
"entrypoint": "app/aios_agent_task_api.py",
|
||||
"listen": "127.0.0.1:18940",
|
||||
"plaintext_credentials_in_artifact": False,
|
||||
"included_paths": list(REQUIRED),
|
||||
}
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="agent-task-build-", dir=str(output)) as temp:
|
||||
stage = Path(temp) / "runtime"
|
||||
for relative in REQUIRED:
|
||||
src = source / relative
|
||||
if relative.startswith("active-runtime/"):
|
||||
dest = stage / Path(relative).relative_to("active-runtime")
|
||||
else:
|
||||
dest = stage / relative
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(src.read_bytes())
|
||||
(stage / "manifest.json").write_text(
|
||||
json.dumps(manifest, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with tarfile.open(archive, "w:gz", format=tarfile.PAX_FORMAT) as tar:
|
||||
for path in sorted(stage.rglob("*")):
|
||||
arcname = str(path.relative_to(stage.parent)).replace("\\", "/")
|
||||
info = tar.gettarinfo(str(path), arcname=arcname)
|
||||
info.mtime = 0
|
||||
info.uid = info.gid = 0
|
||||
info.uname = info.gname = "root"
|
||||
if path.is_file():
|
||||
with path.open("rb") as fh:
|
||||
tar.addfile(info, fh)
|
||||
else:
|
||||
tar.addfile(info)
|
||||
|
||||
receipt = {
|
||||
**manifest,
|
||||
"artifact_path": str(archive),
|
||||
"artifact_sha256": sha256(archive),
|
||||
}
|
||||
receipt_path = archive.with_suffix(archive.suffix + ".receipt.json")
|
||||
receipt_path.write_text(
|
||||
json.dumps(receipt, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(json.dumps(receipt, sort_keys=True, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,124 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate one canonical source entry for a governed non-main change.
|
||||
|
||||
This utility only creates the file in the caller's worktree. It never commits,
|
||||
pushes, changes main, rewrites a tag, or updates a production projection. The
|
||||
caller must submit the resulting file through the existing governed writer and
|
||||
approval flow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
APP = Path(__file__).resolve().parents[1] / "active-runtime" / "app"
|
||||
sys.path.insert(0, str(APP))
|
||||
|
||||
from version_registry import ( # noqa: E402
|
||||
build_source_registry_entry,
|
||||
configured_authoritative_git_dir,
|
||||
configured_repository_identity,
|
||||
source_registry_json,
|
||||
validate_source_registry_record,
|
||||
verify_approval_authority,
|
||||
verify_authoritative_repository,
|
||||
verify_source_registry_git_identity,
|
||||
)
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
value = argparse.ArgumentParser()
|
||||
value.add_argument("--output", required=True, type=Path)
|
||||
value.add_argument("--version", required=True)
|
||||
value.add_argument("--tag-oid", required=True)
|
||||
value.add_argument("--target-oid", required=True)
|
||||
value.add_argument("--peeled-target-oid", required=True)
|
||||
value.add_argument("--evidence-closure-oid", required=True)
|
||||
value.add_argument("--approval-plan-id", required=True)
|
||||
value.add_argument(
|
||||
"--repository",
|
||||
type=Path,
|
||||
default=configured_authoritative_git_dir(),
|
||||
help="authoritative bare repository; required for release generation",
|
||||
)
|
||||
value.add_argument(
|
||||
"--approval-authority",
|
||||
type=Path,
|
||||
default=(
|
||||
Path(os.environ["AIOS_GITOPS_APPROVAL_AUTHORITY"])
|
||||
if os.environ.get("AIOS_GITOPS_APPROVAL_AUTHORITY")
|
||||
else None
|
||||
),
|
||||
help="canonical approval/publication receipt for this release request",
|
||||
)
|
||||
value.add_argument(
|
||||
"--repository-identity",
|
||||
default=configured_repository_identity(),
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parser().parse_args()
|
||||
if args.approval_authority is None:
|
||||
raise ValueError("canonical approval authority is required")
|
||||
verify_authoritative_repository(
|
||||
args.repository,
|
||||
repository_identity=args.repository_identity,
|
||||
)
|
||||
tag_oid = args.tag_oid.lower()
|
||||
target_oid = args.target_oid.lower()
|
||||
peeled_target_oid = args.peeled_target_oid.lower()
|
||||
evidence_closure_oid = args.evidence_closure_oid.lower()
|
||||
expected = build_source_registry_entry(
|
||||
version=args.version,
|
||||
tag_oid=tag_oid,
|
||||
target_oid=target_oid,
|
||||
peeled_target_oid=peeled_target_oid,
|
||||
evidence_closure_oid=evidence_closure_oid,
|
||||
approval_plan_id=args.approval_plan_id,
|
||||
repository=args.repository_identity,
|
||||
)
|
||||
verify_source_registry_git_identity(args.repository, expected)
|
||||
verify_approval_authority(args.approval_authority, expected)
|
||||
destination = args.output.resolve()
|
||||
if destination.name != f"{args.version}.json" or destination.parent.name != "versions":
|
||||
raise ValueError("output must be versions/<version>.json")
|
||||
payload = source_registry_json(expected)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
if destination.is_symlink():
|
||||
raise ValueError("existing source registry path must not be a symlink")
|
||||
if destination.exists() and not destination.is_file():
|
||||
raise ValueError("existing source registry path must be a regular file")
|
||||
if destination.exists():
|
||||
current = json.loads(destination.read_text(encoding="utf-8"))
|
||||
validate_source_registry_record(
|
||||
current,
|
||||
expected_version=args.version,
|
||||
expected_tag_oid=tag_oid,
|
||||
expected_target_oid=target_oid,
|
||||
expected_evidence_closure_oid=evidence_closure_oid,
|
||||
expected_approval_plan_id=args.approval_plan_id,
|
||||
)
|
||||
print(json.dumps({"status": "EXISTING_IDENTITY_VERIFIED", "path": str(destination)}))
|
||||
return 0
|
||||
descriptor = os.open(
|
||||
destination,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
|
||||
0o640,
|
||||
)
|
||||
try:
|
||||
os.write(descriptor, payload)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
print(json.dumps({"status": "GENERATED", "path": str(destination)}))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+1
-200
@@ -8,7 +8,6 @@ import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import stat
|
||||
import tarfile
|
||||
from typing import Mapping
|
||||
@@ -76,30 +75,6 @@ class BuiltArtifact:
|
||||
manifest: ArtifactManifest
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReleaseIdentityBinding:
|
||||
"""Immutable release identity required for a release artifact build."""
|
||||
|
||||
version: str
|
||||
repository: str
|
||||
tag_oid: str
|
||||
target_oid: str
|
||||
evidence_closure_oid: str
|
||||
approval_plan_id: str
|
||||
peeled_target_oid: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
return {
|
||||
"version": self.version,
|
||||
"repository": self.repository,
|
||||
"tag_oid": self.tag_oid,
|
||||
"target_oid": self.target_oid,
|
||||
"peeled_target_oid": self.peeled_target_oid or self.target_oid,
|
||||
"evidence_closure_oid": self.evidence_closure_oid,
|
||||
"approval_plan_id": self.approval_plan_id,
|
||||
}
|
||||
|
||||
|
||||
class ArtifactBuilder:
|
||||
MAX_FILE_BYTES = 8 * 1024 * 1024
|
||||
MAX_ARTIFACT_BYTES = 64 * 1024 * 1024
|
||||
@@ -117,188 +92,14 @@ class ArtifactBuilder:
|
||||
if not is_git_oid(value):
|
||||
raise ValueError(f"{name} must be a full lowercase Git OID")
|
||||
|
||||
@staticmethod
|
||||
def _binding_dict(
|
||||
binding: ReleaseIdentityBinding | Mapping[str, str],
|
||||
) -> dict[str, str]:
|
||||
if isinstance(binding, ReleaseIdentityBinding):
|
||||
return binding.as_dict()
|
||||
values = {str(key): str(value) for key, value in binding.items()}
|
||||
values.setdefault("peeled_target_oid", values.get("target_oid", ""))
|
||||
return values
|
||||
|
||||
@classmethod
|
||||
def _validate_source_registry(
|
||||
cls,
|
||||
root: Path,
|
||||
release_version: str,
|
||||
release_binding: ReleaseIdentityBinding | Mapping[str, str] | None = None,
|
||||
*,
|
||||
release_mode: bool = False,
|
||||
) -> dict[str, str]:
|
||||
if not re.fullmatch(r"V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?", release_version):
|
||||
raise ReleaseBlocked(
|
||||
"ARTIFACT_SOURCE_REGISTRY_INVALID", "release version is not canonical"
|
||||
)
|
||||
path = root / "versions" / f"{release_version}.json"
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ReleaseBlocked(
|
||||
"ARTIFACT_SOURCE_REGISTRY_MISSING",
|
||||
f"artifact source is missing versions/{release_version}.json",
|
||||
)
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ReleaseBlocked(
|
||||
"ARTIFACT_SOURCE_REGISTRY_INVALID",
|
||||
f"artifact source registry cannot be read: {path}",
|
||||
) from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ReleaseBlocked(
|
||||
"ARTIFACT_SOURCE_REGISTRY_INVALID", "source registry is not an object"
|
||||
)
|
||||
if value.get("version") != release_version or value.get("repository") != "aios/aios":
|
||||
raise ReleaseBlocked(
|
||||
"ARTIFACT_SOURCE_REGISTRY_IDENTITY_MISMATCH",
|
||||
"source registry version or repository does not match the artifact",
|
||||
)
|
||||
if value.get("schema") not in {
|
||||
"aios.source-registry.v1",
|
||||
"aios.production-registry.v2",
|
||||
}:
|
||||
raise ReleaseBlocked(
|
||||
"ARTIFACT_SOURCE_REGISTRY_INVALID", "source registry schema is invalid"
|
||||
)
|
||||
for field in ("tag_oid", "target_oid", "peeled_target_oid", "evidence_closure_oid"):
|
||||
item = value.get(field)
|
||||
if not isinstance(item, str) or not re.fullmatch(r"[0-9a-f]{40}", item):
|
||||
raise ReleaseBlocked(
|
||||
"ARTIFACT_SOURCE_REGISTRY_INVALID",
|
||||
f"source registry field is invalid: {field}",
|
||||
)
|
||||
if value["peeled_target_oid"] != value["target_oid"]:
|
||||
raise ReleaseBlocked(
|
||||
"ARTIFACT_SOURCE_REGISTRY_IDENTITY_MISMATCH",
|
||||
"source registry peeled target differs from target",
|
||||
)
|
||||
if not isinstance(value.get("approval_plan_id"), str) or not re.fullmatch(
|
||||
r"sha256:[0-9a-f]{64}", value["approval_plan_id"]
|
||||
):
|
||||
raise ReleaseBlocked(
|
||||
"ARTIFACT_SOURCE_REGISTRY_INVALID", "source registry approval plan is invalid"
|
||||
)
|
||||
if release_mode and release_binding is None:
|
||||
raise ReleaseBlocked(
|
||||
"ARTIFACT_RELEASE_BINDING_REQUIRED",
|
||||
"release artifact builds require an expected release identity",
|
||||
)
|
||||
if release_binding is not None:
|
||||
expected = cls._binding_dict(release_binding)
|
||||
required = (
|
||||
"version",
|
||||
"repository",
|
||||
"tag_oid",
|
||||
"target_oid",
|
||||
"peeled_target_oid",
|
||||
"evidence_closure_oid",
|
||||
"approval_plan_id",
|
||||
)
|
||||
if any(not expected.get(name) for name in required):
|
||||
raise ReleaseBlocked(
|
||||
"ARTIFACT_RELEASE_BINDING_INVALID",
|
||||
"expected release identity is incomplete",
|
||||
)
|
||||
for name in ("tag_oid", "target_oid", "peeled_target_oid", "evidence_closure_oid"):
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", expected[name]):
|
||||
raise ReleaseBlocked(
|
||||
"ARTIFACT_RELEASE_BINDING_INVALID",
|
||||
f"expected release identity field is invalid: {name}",
|
||||
)
|
||||
if not re.fullmatch(r"sha256:[0-9a-f]{64}", expected["approval_plan_id"]):
|
||||
raise ReleaseBlocked(
|
||||
"ARTIFACT_RELEASE_BINDING_INVALID",
|
||||
"expected release identity approval plan is invalid",
|
||||
)
|
||||
for name in required:
|
||||
if value.get(name) != expected[name]:
|
||||
raise ReleaseBlocked(
|
||||
"ARTIFACT_SOURCE_REGISTRY_IDENTITY_MISMATCH",
|
||||
f"source registry does not match expected release identity: {name}",
|
||||
)
|
||||
return {
|
||||
"version": str(value["version"]),
|
||||
"repository": str(value["repository"]),
|
||||
"tag_oid": str(value["tag_oid"]),
|
||||
"target_oid": str(value["target_oid"]),
|
||||
"peeled_target_oid": str(value["peeled_target_oid"]),
|
||||
"evidence_closure_oid": str(value["evidence_closure_oid"]),
|
||||
"approval_plan_id": str(value["approval_plan_id"]),
|
||||
}
|
||||
|
||||
def build_from_directory(
|
||||
self,
|
||||
source_oid: str,
|
||||
tree_oid: str,
|
||||
source: str | Path,
|
||||
release_version: str | None = None,
|
||||
release_binding: ReleaseIdentityBinding | Mapping[str, str] | None = None,
|
||||
release_mode: bool | None = None,
|
||||
artifact_mode: str | None = None,
|
||||
self, source_oid: str, tree_oid: str, source: str | Path
|
||||
) -> BuiltArtifact:
|
||||
root = Path(source)
|
||||
if root.is_symlink() or not root.is_dir():
|
||||
raise ReleaseBlocked(
|
||||
"ARTIFACT_SOURCE_LINK_REJECTED", "artifact source must be a real directory"
|
||||
)
|
||||
if artifact_mode not in {None, "release", "general"}:
|
||||
raise ValueError("artifact_mode must be release, general, or omitted")
|
||||
if artifact_mode == "release" and release_mode is False:
|
||||
raise ValueError("artifact_mode=release conflicts with release_mode=False")
|
||||
if artifact_mode == "general" and release_mode is True:
|
||||
raise ValueError("artifact_mode=general conflicts with release_mode=True")
|
||||
|
||||
explicit_release_version = release_version is not None
|
||||
detected_canonical_version = False
|
||||
if release_version is None:
|
||||
version_file = root / "VERSION"
|
||||
if version_file.is_file() and not version_file.is_symlink():
|
||||
release_version = version_file.read_text(encoding="utf-8").strip()
|
||||
detected_canonical_version = bool(
|
||||
re.fullmatch(r"V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?", release_version)
|
||||
)
|
||||
elif re.fullmatch(r"V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?", release_version):
|
||||
detected_canonical_version = True
|
||||
|
||||
binding_expected: dict[str, str] | None = None
|
||||
if release_binding is not None:
|
||||
binding_expected = self._binding_dict(release_binding)
|
||||
if not binding_expected.get("version"):
|
||||
raise ReleaseBlocked(
|
||||
"ARTIFACT_RELEASE_BINDING_INVALID",
|
||||
"release identity binding must include version",
|
||||
)
|
||||
release_version = release_version or binding_expected["version"]
|
||||
|
||||
release_requested = bool(
|
||||
explicit_release_version
|
||||
or detected_canonical_version
|
||||
or release_binding is not None
|
||||
or release_mode is True
|
||||
or artifact_mode == "release"
|
||||
)
|
||||
if artifact_mode == "general" and release_requested:
|
||||
raise ReleaseBlocked(
|
||||
"ARTIFACT_GENERAL_MODE_RELEASE_VERSION",
|
||||
"general artifacts cannot contain canonical release metadata",
|
||||
)
|
||||
if release_requested:
|
||||
release_mode = True
|
||||
self._validate_source_registry(
|
||||
root,
|
||||
release_version or "",
|
||||
release_binding,
|
||||
release_mode=True,
|
||||
)
|
||||
files: list[ArtifactFile] = []
|
||||
for directory, dirnames, filenames in os.walk(root, followlinks=False):
|
||||
current = Path(directory)
|
||||
|
||||
+1
-17
@@ -61,9 +61,6 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
artifact_build.add_argument("--source-oid", required=True)
|
||||
artifact_build.add_argument("--tree-oid", required=True)
|
||||
artifact_build.add_argument("--output-directory", required=True, type=Path)
|
||||
artifact_build.add_argument("--release-version")
|
||||
artifact_build.add_argument("--release-binding-json", type=Path)
|
||||
artifact_build.add_argument("--artifact-mode", choices=("release", "general"))
|
||||
artifact_verify = artifact_sub.add_parser("verify")
|
||||
artifact_verify.add_argument("--artifact", required=True, type=Path)
|
||||
artifact_verify.add_argument("--manifest", required=True, type=Path)
|
||||
@@ -148,20 +145,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if args.command == "artifact":
|
||||
builder = ArtifactBuilder()
|
||||
if args.artifact_command == "build":
|
||||
release_binding = (
|
||||
load_json(args.release_binding_json)
|
||||
if args.release_binding_json is not None
|
||||
else None
|
||||
)
|
||||
built = builder.build_from_directory(
|
||||
args.source_oid,
|
||||
args.tree_oid,
|
||||
args.source,
|
||||
release_version=args.release_version,
|
||||
release_binding=release_binding,
|
||||
release_mode=args.release_version is not None,
|
||||
artifact_mode=args.artifact_mode,
|
||||
)
|
||||
built = builder.build_from_directory(args.source_oid, args.tree_oid, args.source)
|
||||
builder.verify(built)
|
||||
artifact_path = args.output_directory / "candidate.tar"
|
||||
manifest_path = args.output_directory / "manifest.json"
|
||||
|
||||
@@ -1,35 +1,11 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_autonomous_release_candidate.artifact import (
|
||||
ArtifactBuilder,
|
||||
ArtifactFile,
|
||||
BuiltArtifact,
|
||||
ReleaseIdentityBinding,
|
||||
)
|
||||
from aios_autonomous_release_candidate.artifact import ArtifactBuilder, ArtifactFile, BuiltArtifact
|
||||
from aios_autonomous_release_candidate.errors import EvidenceIncomplete, ReleaseBlocked
|
||||
|
||||
|
||||
def _source_registry(root: Path) -> None:
|
||||
target = "2" * 40
|
||||
value = {
|
||||
"schema": "aios.source-registry.v1",
|
||||
"version": "V2R2C1",
|
||||
"repository": "aios/aios",
|
||||
"tag_oid": "1" * 40,
|
||||
"tag_object": "1" * 40,
|
||||
"target_oid": target,
|
||||
"peeled_target_oid": target,
|
||||
"evidence_closure_oid": "3" * 40,
|
||||
"approval_plan_id": "sha256:" + "4" * 64,
|
||||
}
|
||||
path = root / "versions" / "V2R2C1.json"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(json.dumps(value) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def test_artifact_is_deterministic_and_reverified() -> None:
|
||||
builder = ArtifactBuilder()
|
||||
files = (
|
||||
@@ -56,143 +32,6 @@ def test_source_tree_rejects_symlink_and_hardlink(tmp_path: Path) -> None:
|
||||
ArtifactBuilder().build_from_directory("a" * 40, "b" * 40, root)
|
||||
|
||||
|
||||
def test_release_source_tree_requires_source_registry_entry(tmp_path: Path) -> None:
|
||||
root = tmp_path / "source"
|
||||
root.mkdir()
|
||||
(root / "VERSION").write_text("V2R2C1\n", encoding="utf-8")
|
||||
with pytest.raises(ReleaseBlocked, match="ARTIFACT_SOURCE_REGISTRY_MISSING"):
|
||||
ArtifactBuilder().build_from_directory("a" * 40, "b" * 40, root)
|
||||
|
||||
|
||||
def test_canonical_version_without_binding_fails_closed(tmp_path: Path) -> None:
|
||||
root = tmp_path / "source"
|
||||
root.mkdir()
|
||||
(root / "VERSION").write_text("V2R2C1\n", encoding="utf-8")
|
||||
_source_registry(root)
|
||||
with pytest.raises(ReleaseBlocked, match="ARTIFACT_RELEASE_BINDING_REQUIRED"):
|
||||
ArtifactBuilder().build_from_directory("a" * 40, "b" * 40, root)
|
||||
|
||||
|
||||
def test_release_source_tree_requires_expected_release_identity(tmp_path: Path) -> None:
|
||||
root = tmp_path / "source"
|
||||
root.mkdir()
|
||||
(root / "VERSION").write_text("V2R2C1\n", encoding="utf-8")
|
||||
_source_registry(root)
|
||||
binding = ReleaseIdentityBinding(
|
||||
version="V2R2C1",
|
||||
repository="aios/aios",
|
||||
tag_oid="1" * 40,
|
||||
target_oid="2" * 40,
|
||||
evidence_closure_oid="3" * 40,
|
||||
approval_plan_id="sha256:" + "4" * 64,
|
||||
)
|
||||
built = ArtifactBuilder().build_from_directory(
|
||||
"a" * 40,
|
||||
"b" * 40,
|
||||
root,
|
||||
release_binding=binding,
|
||||
)
|
||||
ArtifactBuilder().verify(built)
|
||||
|
||||
|
||||
def test_canonical_version_with_malformed_binding_fails_closed(tmp_path: Path) -> None:
|
||||
root = tmp_path / "source"
|
||||
root.mkdir()
|
||||
(root / "VERSION").write_text("V2R2C1\n", encoding="utf-8")
|
||||
_source_registry(root)
|
||||
with pytest.raises(ReleaseBlocked, match="ARTIFACT_RELEASE_BINDING_INVALID"):
|
||||
ArtifactBuilder().build_from_directory(
|
||||
"a" * 40,
|
||||
"b" * 40,
|
||||
root,
|
||||
release_binding={
|
||||
"version": "V2R2C1",
|
||||
"repository": "aios/aios",
|
||||
"tag_oid": "not-an-oid",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_explicit_general_artifact_without_release_version_passes(tmp_path: Path) -> None:
|
||||
root = tmp_path / "source"
|
||||
root.mkdir()
|
||||
(root / "README.txt").write_text("general candidate\n", encoding="utf-8")
|
||||
built = ArtifactBuilder().build_from_directory(
|
||||
"a" * 40,
|
||||
"b" * 40,
|
||||
root,
|
||||
artifact_mode="general",
|
||||
)
|
||||
assert "README.txt" in built.manifest.files
|
||||
|
||||
|
||||
def test_explicit_general_mode_rejects_canonical_version(tmp_path: Path) -> None:
|
||||
root = tmp_path / "source"
|
||||
root.mkdir()
|
||||
(root / "VERSION").write_text("V2R2C1\n", encoding="utf-8")
|
||||
_source_registry(root)
|
||||
with pytest.raises(ReleaseBlocked, match="ARTIFACT_GENERAL_MODE_RELEASE_VERSION"):
|
||||
ArtifactBuilder().build_from_directory(
|
||||
"a" * 40,
|
||||
"b" * 40,
|
||||
root,
|
||||
artifact_mode="general",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,wrong",
|
||||
[
|
||||
("repository", "other/repository"),
|
||||
("version", "V2R2C2"),
|
||||
("tag_oid", "9" * 40),
|
||||
("target_oid", "8" * 40),
|
||||
("peeled_target_oid", "7" * 40),
|
||||
("evidence_closure_oid", "6" * 40),
|
||||
("approval_plan_id", "sha256:" + "5" * 64),
|
||||
],
|
||||
)
|
||||
def test_release_source_tree_rejects_every_identity_mismatch(
|
||||
tmp_path: Path, field: str, wrong: str
|
||||
) -> None:
|
||||
root = tmp_path / "source"
|
||||
root.mkdir()
|
||||
(root / "VERSION").write_text("V2R2C1\n", encoding="utf-8")
|
||||
_source_registry(root)
|
||||
binding = {
|
||||
"version": "V2R2C1",
|
||||
"repository": "aios/aios",
|
||||
"tag_oid": "1" * 40,
|
||||
"target_oid": "2" * 40,
|
||||
"peeled_target_oid": "2" * 40,
|
||||
"evidence_closure_oid": "3" * 40,
|
||||
"approval_plan_id": "sha256:" + "4" * 64,
|
||||
}
|
||||
binding[field] = wrong
|
||||
with pytest.raises(ReleaseBlocked, match="ARTIFACT_SOURCE_REGISTRY"):
|
||||
ArtifactBuilder().build_from_directory(
|
||||
"a" * 40,
|
||||
"b" * 40,
|
||||
root,
|
||||
release_binding=binding,
|
||||
)
|
||||
|
||||
|
||||
def test_release_mode_without_binding_fails_closed(tmp_path: Path) -> None:
|
||||
root = tmp_path / "source"
|
||||
root.mkdir()
|
||||
(root / "VERSION").write_text("V2R2C1\n", encoding="utf-8")
|
||||
_source_registry(root)
|
||||
with pytest.raises(ReleaseBlocked, match="ARTIFACT_RELEASE_BINDING_REQUIRED"):
|
||||
ArtifactBuilder().build_from_directory(
|
||||
"a" * 40,
|
||||
"b" * 40,
|
||||
root,
|
||||
release_version="V2R2C1",
|
||||
release_mode=True,
|
||||
)
|
||||
|
||||
|
||||
def test_artifact_rejects_secret_markers() -> None:
|
||||
with pytest.raises(ReleaseBlocked, match="ARTIFACT_SECRET_MARKER"):
|
||||
ArtifactBuilder().build(
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_model_api_exposes_governed_writer_without_legacy_global_enable():
|
||||
source = (ROOT / "active-runtime/app/aios_model_api_staging.py").read_text(encoding="utf-8")
|
||||
assert "'/createGovernedTask': ('/v2/governed-task'" in source
|
||||
assert "'/commitFilesGoverned': ('/v2/commit-files'" in source
|
||||
assert "path == '/api/governed-write-capabilities'" in source
|
||||
assert "AIOS_GITOPS_WRITES_ENABLED" not in source or "legacy_global_writes_enabled" in source
|
||||
assert "'writes_enabled': False" in source
|
||||
assert "bridge_v2_json" in source
|
||||
|
||||
|
||||
def test_model_api_routes_online_task_control_through_loopback_credential():
|
||||
source = (ROOT / "active-runtime/app/aios_model_api_staging.py").read_text(encoding="utf-8")
|
||||
assert "AIOS_AGENT_TASK_API_URL" in source
|
||||
assert "AIOS_AGENT_TASK_CONTROL_TOKEN_FILE" in source
|
||||
assert "/createOnlineAgentTask" in source
|
||||
assert "/verifyOnlineAgentTask" in source
|
||||
assert "/cancelOnlineAgentTask" in source
|
||||
assert "agent-task:control" in source
|
||||
|
||||
|
||||
def test_online_task_service_keeps_control_endpoints_off_public_nginx():
|
||||
nginx = (ROOT / "deploy/nginx/aios-agent-task-api.conf.example").read_text(encoding="utf-8")
|
||||
assert "/agent-tasks/v1/tasks/poll" in nginx
|
||||
assert "(claim|heartbeat|result)" in nginx
|
||||
assert "/v1/agents/register" not in nginx.replace(
|
||||
"# Control endpoints (/v1/agents/register, POST /v1/tasks, verify/cancel/events)", ""
|
||||
)
|
||||
|
||||
|
||||
def test_online_task_systemd_uses_runtime_credential_file():
|
||||
unit = (ROOT / "deploy/systemd/aios-agent-task-api.service").read_text(encoding="utf-8")
|
||||
assert "LoadCredential=control_token:" in unit
|
||||
assert "/run/credentials/aios-agent-task-api.service/control_token" in unit
|
||||
assert "ReadWritePaths=/var/lib/aios-agent-task" in unit
|
||||
assert "127.0.0.1" in unit
|
||||
|
||||
|
||||
def test_control_plane_snapshot_is_local_aggregate_not_missing_bridge_route():
|
||||
source = (ROOT / "active-runtime/app/aios_model_api_staging.py").read_text(encoding="utf-8")
|
||||
assert "def control_plane_snapshot(self):" in source
|
||||
assert "'snapshot_source': 'model-api-local-aggregate'" in source
|
||||
assert "return self.control_plane_snapshot()" in source
|
||||
assert "bridge_v2_json('/v2/control-plane-snapshot'" not in source
|
||||
@@ -0,0 +1,317 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_agent_task_protocol import TaskProtocolError, TaskStore
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self) -> None:
|
||||
self.value = datetime(2026, 8, 9, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
def __call__(self) -> datetime:
|
||||
return self.value
|
||||
|
||||
def advance(self, seconds: int) -> None:
|
||||
self.value += timedelta(seconds=seconds)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path: Path):
|
||||
clock = Clock()
|
||||
store = TaskStore(tmp_path / "tasks.sqlite3", now=clock, token_pepper="test-pepper")
|
||||
a1 = store.register_agent(
|
||||
agent_id="openclaw-1",
|
||||
agent_type="openclaw",
|
||||
agent_version="test",
|
||||
capabilities=["shell", "git", "python"],
|
||||
)
|
||||
a2 = store.register_agent(
|
||||
agent_id="hermes-1",
|
||||
agent_type="hermes",
|
||||
agent_version="test",
|
||||
capabilities=["shell", "git", "python"],
|
||||
)
|
||||
return store, clock, a1, a2
|
||||
|
||||
|
||||
def create_task(store: TaskStore, task_id: str = "TASK-ONLINE-001"):
|
||||
return store.create_task(
|
||||
task_id=task_id,
|
||||
goal="Create a deterministic validation artifact",
|
||||
instructions="Write validation.txt containing ONLINE_TASK_OK.",
|
||||
success_criteria=[
|
||||
{"type": "file_exists", "path": "validation.txt"},
|
||||
{"type": "contains", "path": "validation.txt", "value": "ONLINE_TASK_OK"},
|
||||
],
|
||||
required_capabilities=["shell"],
|
||||
constraints={"network": "optional"},
|
||||
metadata={"suite": "online-task"},
|
||||
priority=10,
|
||||
max_attempts=3,
|
||||
idempotency_key="create:" + task_id,
|
||||
)
|
||||
|
||||
|
||||
def test_full_flow_requires_server_verification(env):
|
||||
store, _clock, a1, _a2 = env
|
||||
create_task(store)
|
||||
|
||||
polled = store.poll_tasks(agent_id=a1["agent_id"])
|
||||
assert [item["task_id"] for item in polled] == ["TASK-ONLINE-001"]
|
||||
|
||||
claim = store.claim_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_seconds=300,
|
||||
)
|
||||
assert claim["status"] == "CLAIMED"
|
||||
assert claim["lease_token"].startswith("lease_")
|
||||
|
||||
running = store.heartbeat(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token=claim["lease_token"],
|
||||
progress={"percent": 50},
|
||||
lease_seconds=300,
|
||||
)
|
||||
assert running["status"] == "RUNNING"
|
||||
|
||||
submitted = store.submit_result(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token=claim["lease_token"],
|
||||
idempotency_key="result:1",
|
||||
result={
|
||||
"status": "completed",
|
||||
"summary": "Created validation.txt",
|
||||
"artifacts": [{"path": "validation.txt", "sha256": "0" * 64}],
|
||||
},
|
||||
)
|
||||
assert submitted["status"] == "VERIFYING"
|
||||
assert submitted["status"] != "SUCCEEDED"
|
||||
|
||||
verified = store.verify_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
decision="SUCCEEDED",
|
||||
verification={"checks": {"content": "PASS"}},
|
||||
)
|
||||
assert verified["status"] == "SUCCEEDED"
|
||||
|
||||
|
||||
def test_atomic_claim_allows_only_one_agent(env):
|
||||
store, _clock, a1, a2 = env
|
||||
create_task(store)
|
||||
outcomes: list[tuple[str, str]] = []
|
||||
barrier = threading.Barrier(2)
|
||||
|
||||
def runner(agent_id: str) -> None:
|
||||
barrier.wait()
|
||||
try:
|
||||
store.claim_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=agent_id,
|
||||
lease_seconds=300,
|
||||
)
|
||||
outcomes.append((agent_id, "CLAIMED"))
|
||||
except TaskProtocolError as exc:
|
||||
outcomes.append((agent_id, exc.code))
|
||||
|
||||
t1 = threading.Thread(target=runner, args=(a1["agent_id"],))
|
||||
t2 = threading.Thread(target=runner, args=(a2["agent_id"],))
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join()
|
||||
t2.join()
|
||||
|
||||
assert sum(result == "CLAIMED" for _, result in outcomes) == 1
|
||||
assert sum(result in {"TASK_NOT_CLAIMABLE", "TASK_CLAIM_CONFLICT"} for _, result in outcomes) == 1
|
||||
|
||||
|
||||
def test_expired_lease_is_requeued_and_reclaimable(env):
|
||||
store, clock, a1, a2 = env
|
||||
create_task(store)
|
||||
first = store.claim_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_seconds=30,
|
||||
)
|
||||
assert first["attempt"] == 1
|
||||
|
||||
clock.advance(31)
|
||||
available = store.poll_tasks(agent_id=a2["agent_id"])
|
||||
assert available[0]["status"] == "QUEUED"
|
||||
|
||||
second = store.claim_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a2["agent_id"],
|
||||
lease_seconds=30,
|
||||
)
|
||||
assert second["attempt"] == 2
|
||||
assert second["claimed_by"] == a2["agent_id"]
|
||||
|
||||
|
||||
def test_wrong_lease_token_is_rejected(env):
|
||||
store, _clock, a1, _a2 = env
|
||||
create_task(store)
|
||||
store.claim_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_seconds=300,
|
||||
)
|
||||
with pytest.raises(TaskProtocolError, match="TASK_LEASE_TOKEN_INVALID"):
|
||||
store.heartbeat(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token="lease_wrong",
|
||||
progress={},
|
||||
)
|
||||
|
||||
|
||||
def test_result_submission_is_idempotent(env):
|
||||
store, _clock, a1, _a2 = env
|
||||
create_task(store)
|
||||
claim = store.claim_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_seconds=300,
|
||||
)
|
||||
payload = {"status": "completed", "summary": "ok"}
|
||||
first = store.submit_result(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token=claim["lease_token"],
|
||||
idempotency_key="result:same",
|
||||
result=payload,
|
||||
)
|
||||
second = store.submit_result(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token=claim["lease_token"],
|
||||
idempotency_key="result:same",
|
||||
result=payload,
|
||||
)
|
||||
assert first["status"] == "VERIFYING"
|
||||
assert second["status"] == "VERIFYING"
|
||||
assert second["idempotent"] is True
|
||||
|
||||
with pytest.raises(TaskProtocolError, match="IDEMPOTENCY_CONFLICT"):
|
||||
store.submit_result(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token=claim["lease_token"],
|
||||
idempotency_key="result:same",
|
||||
result={"status": "completed", "summary": "different"},
|
||||
)
|
||||
|
||||
|
||||
def test_needs_rework_returns_to_queue(env):
|
||||
store, _clock, a1, _a2 = env
|
||||
create_task(store)
|
||||
claim = store.claim_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_seconds=300,
|
||||
)
|
||||
store.submit_result(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token=claim["lease_token"],
|
||||
idempotency_key="result:1",
|
||||
result={"status": "completed"},
|
||||
)
|
||||
result = store.verify_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
decision="NEEDS_REWORK",
|
||||
verification={"reason": "success criteria not met"},
|
||||
)
|
||||
assert result["status"] == "QUEUED"
|
||||
assert result["claimed_by"] is None
|
||||
|
||||
|
||||
def test_capability_filter(env):
|
||||
store, _clock, a1, _a2 = env
|
||||
store.create_task(
|
||||
task_id="TASK-GPU-001",
|
||||
goal="GPU task",
|
||||
instructions="Run GPU workload",
|
||||
success_criteria=[],
|
||||
required_capabilities=["gpu"],
|
||||
idempotency_key="create:gpu",
|
||||
)
|
||||
assert store.poll_tasks(agent_id=a1["agent_id"]) == []
|
||||
|
||||
|
||||
def test_create_task_idempotency_conflict(env):
|
||||
store, _clock, _a1, _a2 = env
|
||||
first = create_task(store)
|
||||
replay = create_task(store)
|
||||
assert first["task_id"] == replay["task_id"]
|
||||
assert replay["idempotent"] is True
|
||||
|
||||
with pytest.raises(TaskProtocolError, match="IDEMPOTENCY_CONFLICT"):
|
||||
store.create_task(
|
||||
task_id="TASK-ONLINE-002",
|
||||
goal="different",
|
||||
instructions="different",
|
||||
success_criteria=[],
|
||||
idempotency_key="create:TASK-ONLINE-001",
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_success_retains_executing_agent_for_final_readback(env):
|
||||
store, _clock, a1, _a2 = env
|
||||
create_task(store, "TASK-TERMINAL-READBACK")
|
||||
claim = store.claim_task(
|
||||
task_id="TASK-TERMINAL-READBACK",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_seconds=120,
|
||||
)
|
||||
store.submit_result(
|
||||
task_id="TASK-TERMINAL-READBACK",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token=claim["lease_token"],
|
||||
idempotency_key="terminal-result",
|
||||
result={"status": "completed"},
|
||||
)
|
||||
final = store.verify_task(
|
||||
task_id="TASK-TERMINAL-READBACK",
|
||||
decision="SUCCEEDED",
|
||||
verification={"criteria": "PASS"},
|
||||
)
|
||||
assert final["status"] == "SUCCEEDED"
|
||||
assert final["claimed_by"] == a1["agent_id"]
|
||||
|
||||
|
||||
def test_needs_rework_releases_agent_ownership_for_reclaim(env):
|
||||
store, _clock, a1, a2 = env
|
||||
create_task(store, "TASK-REWORK-OWNERSHIP")
|
||||
claim = store.claim_task(
|
||||
task_id="TASK-REWORK-OWNERSHIP",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_seconds=120,
|
||||
)
|
||||
store.submit_result(
|
||||
task_id="TASK-REWORK-OWNERSHIP",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token=claim["lease_token"],
|
||||
idempotency_key="rework-owner-result",
|
||||
result={"status": "completed"},
|
||||
)
|
||||
requeued = store.verify_task(
|
||||
task_id="TASK-REWORK-OWNERSHIP",
|
||||
decision="NEEDS_REWORK",
|
||||
verification={"criteria": "FAIL"},
|
||||
)
|
||||
assert requeued["status"] == "QUEUED"
|
||||
assert requeued["claimed_by"] is None
|
||||
reclaimed = store.claim_task(
|
||||
task_id="TASK-REWORK-OWNERSHIP",
|
||||
agent_id=a2["agent_id"],
|
||||
lease_seconds=120,
|
||||
)
|
||||
assert reclaimed["claimed_by"] == a2["agent_id"]
|
||||
@@ -158,7 +158,7 @@ def test_execute_orders_registry_before_baseline_and_baseline_last(
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"approved_release_executor.verify_artifact",
|
||||
lambda binding, **kwargs: {"verified": True},
|
||||
lambda binding: {"verified": True},
|
||||
)
|
||||
|
||||
calls = []
|
||||
|
||||
@@ -73,7 +73,6 @@ class AuthoritativeGitContextTests(unittest.TestCase):
|
||||
json.dumps({"branch": "patch/context"}), encoding="utf-8"
|
||||
)
|
||||
self.env = {
|
||||
"AIOS_TEST_MODE": "1",
|
||||
"AIOS_GITOPS_AUTHORITATIVE_GIT_DIR": str(self.bare),
|
||||
"AIOS_GITOPS_REPOSITORY_IDENTITY": "aios/aios",
|
||||
}
|
||||
|
||||
@@ -76,7 +76,6 @@ def test_http_route_contract_is_complete():
|
||||
expected = {
|
||||
"/v2/runtime", "/v2/capabilities", "/v2/governed-task",
|
||||
"/v2/writer-lease", "/v2/commit-file", "/v2/commit-files",
|
||||
"/v2/branch/publish",
|
||||
"/v2/job", "/v2/job/resume", "/v2/job/reconcile",
|
||||
"/v2/tag/approval-request",
|
||||
"/v2/tag/publish",
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP = ROOT / "active-runtime" / "app"
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"aios_durable_owner_authorization",
|
||||
APP / "aios_durable_owner_authorization.py",
|
||||
)
|
||||
BROKER_MODULE = importlib.util.module_from_spec(SPEC)
|
||||
import sys
|
||||
sys.modules[SPEC.name] = BROKER_MODULE
|
||||
assert SPEC.loader is not None
|
||||
SPEC.loader.exec_module(BROKER_MODULE)
|
||||
|
||||
AuthorizationError = BROKER_MODULE.AuthorizationError
|
||||
Broker = BROKER_MODULE.Broker
|
||||
Constraints = BROKER_MODULE.Constraints
|
||||
DurablePolicy = BROKER_MODULE.DurablePolicy
|
||||
ReceiptStore = BROKER_MODULE.ReceiptStore
|
||||
Upstream = BROKER_MODULE.Upstream
|
||||
|
||||
CALLER = "chatgpt-governed-implementer-singlehost"
|
||||
SCOPE = "release:candidate"
|
||||
SOURCE = "257c7a8df433f75b364bcaf69cbbc32e8c2e5d38"
|
||||
|
||||
|
||||
def policy(*, expires_at=None, capabilities=None):
|
||||
return DurablePolicy(
|
||||
grant_id="grant-caller-projection-v1",
|
||||
owner_identity="owner-durable-policy",
|
||||
account=CALLER,
|
||||
capabilities=frozenset(capabilities or {SCOPE}),
|
||||
issued_at=datetime.now(timezone.utc) - timedelta(minutes=1),
|
||||
not_before=datetime.now(timezone.utc) - timedelta(minutes=1),
|
||||
persistent_until_revoked=True,
|
||||
constraints=Constraints(
|
||||
repository="aios/aios",
|
||||
account=CALLER,
|
||||
allowed_target_prefix="agent/",
|
||||
release_version_prefix="V2R2C",
|
||||
max_task_risk="engineering",
|
||||
),
|
||||
policy_sha256="a" * 64,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
|
||||
def make_broker(root):
|
||||
return Broker(
|
||||
policy_path=root / "policy.json",
|
||||
revocation_path=root / "revocations.json",
|
||||
store=ReceiptStore(root / "receipts.sqlite3"),
|
||||
upstream=Upstream("http://127.0.0.1:1", "not-used"),
|
||||
expected_account=CALLER,
|
||||
expected_peer_user="test-peer",
|
||||
allowed_peer_users=frozenset({"test-peer"}),
|
||||
)
|
||||
|
||||
|
||||
class CallerProjectionTests(unittest.TestCase):
|
||||
def project(self, broker, request=None):
|
||||
return broker.project_caller(
|
||||
CALLER,
|
||||
SCOPE,
|
||||
request or {"source_commit": SOURCE, "version": "V2R2C2"},
|
||||
idempotency_key="caller-projection-test-v1",
|
||||
)
|
||||
|
||||
def test_positive_projection_ack_and_effective_scope(self):
|
||||
broker = make_broker(Path(tempfile.mkdtemp()))
|
||||
with patch.object(broker, "policy", return_value=policy()):
|
||||
value = self.project(broker)
|
||||
credential = value.pop("_credential")
|
||||
self.assertGreaterEqual(len(credential), 32)
|
||||
self.assertEqual("ACKED", value["projection_state"])
|
||||
self.assertEqual("REHYDRATED", value["credential_state"])
|
||||
self.assertEqual([SCOPE], value["effective_scopes"])
|
||||
self.assertFalse(value["secret_exposed"])
|
||||
|
||||
def test_secret_material_is_not_durable(self):
|
||||
root = Path(tempfile.mkdtemp())
|
||||
broker = make_broker(root)
|
||||
with patch.object(broker, "policy", return_value=policy()):
|
||||
value = self.project(broker)
|
||||
credential = value.pop("_credential")
|
||||
durable = (root / "receipts.sqlite3").read_bytes()
|
||||
self.assertNotIn(credential.encode(), durable)
|
||||
self.assertNotIn("credential_material", durable.decode("utf-8", "ignore"))
|
||||
self.assertFalse(value["secret_exposed"])
|
||||
|
||||
def test_caller_mismatch_is_denied(self):
|
||||
broker = make_broker(Path(tempfile.mkdtemp()))
|
||||
with self.assertRaises(AuthorizationError) as caught:
|
||||
broker.project_caller(
|
||||
"other-caller",
|
||||
SCOPE,
|
||||
{},
|
||||
idempotency_key="caller-projection-test-v1",
|
||||
)
|
||||
self.assertEqual("CALLER_MISMATCH", caught.exception.code)
|
||||
|
||||
def test_scope_mismatch_is_denied(self):
|
||||
broker = make_broker(Path(tempfile.mkdtemp()))
|
||||
with self.assertRaises(AuthorizationError) as caught:
|
||||
broker.project_caller(
|
||||
CALLER,
|
||||
"release:deploy",
|
||||
{},
|
||||
idempotency_key="caller-projection-test-v1",
|
||||
)
|
||||
self.assertEqual("SCOPE_MISMATCH", caught.exception.code)
|
||||
|
||||
def test_policy_missing_revoked_and_expired_fail_closed(self):
|
||||
broker = make_broker(Path(tempfile.mkdtemp()))
|
||||
for code in ("POLICY_FILE_INVALID", "OWNER_GRANT_REVOKED", "POLICY_EXPIRED"):
|
||||
with self.subTest(code=code):
|
||||
with patch.object(
|
||||
broker,
|
||||
"policy",
|
||||
side_effect=AuthorizationError(code, 403),
|
||||
):
|
||||
with self.assertRaises(AuthorizationError) as caught:
|
||||
self.project(broker)
|
||||
self.assertEqual(code, caught.exception.code)
|
||||
|
||||
def test_invalid_projection_state_fails_closed(self):
|
||||
with self.assertRaises(AuthorizationError) as caught:
|
||||
Broker.validate_caller_projection(
|
||||
{
|
||||
"caller": CALLER,
|
||||
"scope": SCOPE,
|
||||
"projection_state": "CORRUPT",
|
||||
"credential_state": "REHYDRATED",
|
||||
"rehydration_state": "RECOVERED",
|
||||
"effective_scopes": [SCOPE],
|
||||
"secret_exposed": False,
|
||||
}
|
||||
)
|
||||
self.assertEqual("PROJECTION_STATE_INVALID", caught.exception.code)
|
||||
|
||||
def test_restart_rehydrates_from_durable_metadata(self):
|
||||
root = Path(tempfile.mkdtemp())
|
||||
first = make_broker(root)
|
||||
with patch.object(first, "policy", return_value=policy()):
|
||||
initial = self.project(first)
|
||||
first_credential = initial.pop("_credential")
|
||||
second = make_broker(root)
|
||||
with patch.object(second, "policy", return_value=policy()):
|
||||
recovered = self.project(second)
|
||||
second_credential = recovered.pop("_credential")
|
||||
self.assertNotEqual(first_credential, second_credential)
|
||||
self.assertEqual("RECOVERED", recovered["rehydration_state"])
|
||||
self.assertEqual("ACKED", recovered["projection_state"])
|
||||
|
||||
def test_expired_policy_loader_rejects(self):
|
||||
root = Path(tempfile.mkdtemp())
|
||||
policy_path = root / "policy.json"
|
||||
payload = {
|
||||
"schema": "aios.owner-durable-authorization.v1",
|
||||
"grant_id": "grant-caller-projection-v1",
|
||||
"owner_identity": "owner-durable-policy",
|
||||
"account": CALLER,
|
||||
"capabilities": [SCOPE],
|
||||
"issued_at": "2026-08-22T00:00:00Z",
|
||||
"not_before": "2026-08-22T00:00:00Z",
|
||||
"expires_at": "2026-08-22T01:00:00Z",
|
||||
"persistent_until_revoked": True,
|
||||
"constraints": {
|
||||
"repository": "aios/aios",
|
||||
"allowed_target_prefix": "agent/",
|
||||
"release_version_prefix": "V2R2C",
|
||||
"max_task_risk": "engineering",
|
||||
},
|
||||
}
|
||||
policy_path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
with patch.object(BROKER_MODULE, "validate_root_file", lambda path, **kwargs: path):
|
||||
with self.assertRaises(AuthorizationError) as caught:
|
||||
DurablePolicy.load(policy_path)
|
||||
self.assertEqual("POLICY_EXPIRED", caught.exception.code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
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))
|
||||
|
||||
try:
|
||||
import orchestrator.action_monitor
|
||||
except ModuleNotFoundError:
|
||||
_orchestrator = types.ModuleType("orchestrator")
|
||||
_action_monitor = types.ModuleType("orchestrator.action_monitor")
|
||||
_action_monitor.ActionMonitor = type("ActionMonitor", (), {})
|
||||
_orchestrator.__path__ = []
|
||||
sys.modules["orchestrator"] = _orchestrator
|
||||
sys.modules["orchestrator.action_monitor"] = _action_monitor
|
||||
|
||||
import aios_durable_owner_authorization as broker_api
|
||||
import aios_model_api_staging as model_api
|
||||
|
||||
|
||||
CALLER = "chatgpt-governed-implementer-singlehost"
|
||||
SCOPE = "release:candidate"
|
||||
|
||||
|
||||
def _invoke_capabilities(
|
||||
headers,
|
||||
*,
|
||||
auth_result=None,
|
||||
auth_side_effect=None,
|
||||
projection_client=None,
|
||||
):
|
||||
handler = object.__new__(model_api.H)
|
||||
handler.headers = headers
|
||||
responses = []
|
||||
handler.sendj = lambda value, code=200: responses.append((value, code))
|
||||
if auth_side_effect is not None:
|
||||
auth_patch = patch.object(
|
||||
model_api, "authenticate", side_effect=auth_side_effect
|
||||
)
|
||||
else:
|
||||
auth_patch = patch.object(
|
||||
model_api, "authenticate", return_value=auth_result
|
||||
)
|
||||
client = (
|
||||
projection_client
|
||||
if projection_client is not None
|
||||
else model_api.CALLER_PROJECTION_CLIENT
|
||||
)
|
||||
with auth_patch, patch.object(model_api, "CALLER_PROJECTION_CLIENT", client):
|
||||
model_api.H.caller_capabilities(handler)
|
||||
assert len(responses) == 1
|
||||
return responses[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"headers",
|
||||
[
|
||||
{},
|
||||
{"Authorization": "Bearer invalid"},
|
||||
{"Authorization": "Bearer unknown"},
|
||||
{"X-AIOS-Token": "rec-none"},
|
||||
],
|
||||
)
|
||||
def test_anonymous_invalid_unknown_and_rec_none_are_structured_401(headers):
|
||||
payload, status = _invoke_capabilities(headers, auth_result=None)
|
||||
assert status == 401
|
||||
assert payload == {"error": "unauthorized", "hint": "Bearer token required"}
|
||||
assert "Traceback" not in json.dumps(payload)
|
||||
|
||||
|
||||
def test_authenticate_exception_is_fail_closed_without_socket_abort():
|
||||
payload, status = _invoke_capabilities(
|
||||
{"Authorization": "Bearer malformed"},
|
||||
auth_side_effect=RuntimeError("malformed"),
|
||||
)
|
||||
assert status == 401
|
||||
assert payload["error"] == "unauthorized"
|
||||
assert "Traceback" not in json.dumps(payload)
|
||||
|
||||
|
||||
def test_authorized_capability_introspection_remains_secret_free():
|
||||
row = {
|
||||
"account": CALLER,
|
||||
"role": "user",
|
||||
"scopes": ["agent-task:control", "branch:create", "file:write"],
|
||||
}
|
||||
result = SimpleNamespace(
|
||||
public={
|
||||
"projection_state": "ACKED",
|
||||
"credential_state": "ACTIVE",
|
||||
"rehydration_state": "REHYDRATED",
|
||||
"effective_scopes": [SCOPE],
|
||||
},
|
||||
credential_loaded=True,
|
||||
)
|
||||
|
||||
class _ProjectionClient:
|
||||
def rehydrate(self, caller, scope, request):
|
||||
assert caller == CALLER
|
||||
assert scope == SCOPE
|
||||
assert request == {}
|
||||
return result
|
||||
|
||||
payload, status = _invoke_capabilities(
|
||||
{"Authorization": "Bearer valid"},
|
||||
auth_result=row,
|
||||
projection_client=_ProjectionClient(),
|
||||
)
|
||||
assert status == 200
|
||||
assert payload["caller_identity"] == CALLER
|
||||
assert payload["admitted"] is True
|
||||
assert SCOPE in payload["effective_scopes"]
|
||||
assert payload["projection_state"] == "ACKED"
|
||||
assert payload["credential_state"] == "ACTIVE"
|
||||
assert payload["rehydration_state"] == "REHYDRATED"
|
||||
assert payload["secret_exposed"] is False
|
||||
assert "_credential" not in json.dumps(payload)
|
||||
|
||||
|
||||
class _FakeBroker:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def project_caller(self, caller, scope, request, *, idempotency_key):
|
||||
self.calls.append((caller, scope, request, idempotency_key))
|
||||
return {
|
||||
"caller": caller,
|
||||
"scope": scope,
|
||||
"projection_state": "ACKED",
|
||||
"credential_state": "ACTIVE",
|
||||
"rehydration_state": "REHYDRATED",
|
||||
"effective_scopes": [SCOPE],
|
||||
"_credential": "x" * 40,
|
||||
}
|
||||
|
||||
|
||||
def _dispatch_broker(path="/v1/project-caller", *, peer=True, account=True):
|
||||
broker = _FakeBroker()
|
||||
handler = object.__new__(broker_api.Handler)
|
||||
handler.path = path
|
||||
handler.broker = broker
|
||||
handler.peer_authorized = lambda: peer
|
||||
handler.account_authorized = lambda: account
|
||||
handler.body = lambda: {
|
||||
"caller": CALLER,
|
||||
"scope": SCOPE,
|
||||
"idempotency_key": "runtime-regression-test-20260823",
|
||||
"request": {
|
||||
"repository": "aios/aios",
|
||||
"target_branch": "agent/runtime-regression-test",
|
||||
"release_version": "V2R2C2",
|
||||
},
|
||||
}
|
||||
responses = []
|
||||
handler.send_json = lambda value, status, internal_credential=None: responses.append(
|
||||
(value, status, internal_credential)
|
||||
)
|
||||
broker_api.Handler.do_POST(handler)
|
||||
return responses, broker
|
||||
|
||||
|
||||
def test_existing_project_caller_route_reaches_handler_without_public_secret():
|
||||
responses, broker = _dispatch_broker()
|
||||
assert len(responses) == 1
|
||||
value, status, internal_credential = responses[0]
|
||||
assert status == 200
|
||||
assert broker.calls
|
||||
assert broker.calls[0][0:2] == (CALLER, SCOPE)
|
||||
assert internal_credential == "x" * 40
|
||||
assert "_credential" not in value
|
||||
assert "x" * 40 not in json.dumps(value)
|
||||
|
||||
|
||||
def test_unauthorized_peer_is_denied_and_unregistered_route_is_not_added():
|
||||
responses, broker = _dispatch_broker(peer=False)
|
||||
assert responses[0][1] == 403
|
||||
assert broker.calls == []
|
||||
|
||||
responses, broker = _dispatch_broker(path="/v1/not-registered")
|
||||
assert responses[0][1] == 404
|
||||
assert broker.calls == []
|
||||
@@ -1,166 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_existing_branch_publisher import ExistingBranchPublicationState, ExistingBranchPublisher
|
||||
from gitea_tag_publication import GiteaConfigurationError, GiteaTagClient, InvalidReleaseVersion, PublicationRequest, TagPublisher, validate_version
|
||||
from signer_contract_compatibility import run_compatible_signer
|
||||
|
||||
|
||||
class Authority:
|
||||
def __init__(self, oid: str):
|
||||
self.oid = oid
|
||||
def read_ref(self, ref: str) -> str | None:
|
||||
return self.oid if ref.startswith("refs/heads/") else None
|
||||
|
||||
|
||||
class Remote:
|
||||
def __init__(self, oid: str | None = None, api_oid: str | None = None, visible: bool = True):
|
||||
self.oid = oid
|
||||
self.api_oid = api_oid
|
||||
self.visible = visible
|
||||
self.pushes: list[tuple[str, str | None, str]] = []
|
||||
def repository_identity(self) -> str:
|
||||
return "aios/aios"
|
||||
def read_git_ref(self, branch: str) -> str | None:
|
||||
return self.oid
|
||||
def read_api_branch(self, branch: str) -> str | None:
|
||||
return self.api_oid
|
||||
def commit_visible(self, oid: str) -> bool:
|
||||
return self.visible
|
||||
def push_exact(self, branch: str, expected: str | None, new: str) -> None:
|
||||
assert expected is None
|
||||
self.pushes.append((branch, expected, new))
|
||||
self.oid = new
|
||||
self.api_oid = new
|
||||
self.visible = True
|
||||
|
||||
|
||||
def request(oid: str, key: str = "gate-closure-test-0001") -> dict[str, str]:
|
||||
return {
|
||||
"repository": "aios/aios",
|
||||
"branch": "agent/gate-closure-test",
|
||||
"expected_branch_oid": oid,
|
||||
"owner_approval_reference": "owner-authorization:test",
|
||||
"idempotency_key": key,
|
||||
}
|
||||
|
||||
|
||||
def test_existing_branch_publisher_creates_exact_missing_remote_and_replays_idempotently(tmp_path: Path):
|
||||
oid = "a" * 40
|
||||
remote = Remote()
|
||||
publisher = ExistingBranchPublisher(Authority(oid), remote, tmp_path / "state")
|
||||
first = publisher.publish(request(oid))
|
||||
assert first["publication_state"] == ExistingBranchPublicationState.COMPLETED.value
|
||||
assert remote.pushes == [("agent/gate-closure-test", None, oid)]
|
||||
second = publisher.publish(request(oid))
|
||||
assert second["publication_state"] == ExistingBranchPublicationState.COMPLETED.value
|
||||
assert second["idempotent"] is True
|
||||
|
||||
|
||||
def test_existing_branch_publisher_conflict_is_fail_closed(tmp_path: Path):
|
||||
oid = "a" * 40
|
||||
remote = Remote(oid="b" * 40, api_oid="b" * 40)
|
||||
result = ExistingBranchPublisher(Authority(oid), remote, tmp_path / "state").publish(request(oid, "gate-closure-test-0002"))
|
||||
assert result["publication_state"] == ExistingBranchPublicationState.MANUAL_OWNER_REVIEW_REQUIRED.value
|
||||
assert remote.pushes == []
|
||||
|
||||
|
||||
def test_existing_branch_publisher_rejects_main_and_wrong_authority_oid(tmp_path: Path):
|
||||
oid = "a" * 40
|
||||
with pytest.raises(Exception, match="BRANCH_NAMESPACE_REJECTED"):
|
||||
ExistingBranchPublisher(Authority(oid), Remote(), tmp_path / "state").publish({**request(oid), "branch": "main", "idempotency_key": "gate-closure-test-0003"})
|
||||
with pytest.raises(Exception, match="AUTHORITY_BRANCH_OID_MISMATCH"):
|
||||
ExistingBranchPublisher(Authority("b" * 40), Remote(), tmp_path / "state").publish(request(oid, "gate-closure-test-0004"))
|
||||
|
||||
|
||||
def test_signer_broker_path_has_no_direct_root_signer_call():
|
||||
class FakeBroker:
|
||||
def self_test(self):
|
||||
return None
|
||||
def sign(self, payload_kind, payload):
|
||||
return b"signature", "test-broker-key"
|
||||
request_doc = {
|
||||
"schema": "aios.task-scoped-writer-grant-request.v1",
|
||||
"task_id": "gate-closure-signer-unit",
|
||||
"repository": "aios/aios",
|
||||
"base": {"ref": "refs/heads/main", "oid": "a" * 40},
|
||||
"target": {"branch": "agent/gate-closure-signer-unit"},
|
||||
"owner_authorization": {"identity": "aiman", "reference": "owner-authorization:test"},
|
||||
"authorization": {
|
||||
"production_change_authorized": False,
|
||||
"tag_change_authorized": False,
|
||||
"baseline_change_authorized": False,
|
||||
"main_write_authorized": False,
|
||||
},
|
||||
"allowed_operations": ["create_worktree"],
|
||||
"denied_operations": ["direct_main_push", "force_push"],
|
||||
"one_time_use": True,
|
||||
"not_before": "2026-08-10T00:00:00Z",
|
||||
"expires_at": "2026-08-10T00:00:00Z",
|
||||
"nonce": "unit-nonce",
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
grant = root / "grant"
|
||||
grant.mkdir()
|
||||
(grant / "grant-request.json").write_text(json.dumps(request_doc), encoding="utf-8")
|
||||
(grant / "approval-plan.json").write_text(json.dumps({"approval_plan_id": "sha256:" + "a" * 64}), encoding="utf-8")
|
||||
with patch("signer_contract_compatibility._supports_deployed_signer", return_value=True):
|
||||
result = run_compatible_signer(["unused-signer-command"], grant, root / "result.json", broker=FakeBroker())
|
||||
assert result["signing_key_identity"] == "test-broker-key"
|
||||
|
||||
|
||||
def test_gitea_configuration_has_explicit_error_codes():
|
||||
with pytest.raises(GiteaConfigurationError, match="GITEA_API_URL_MISSING"):
|
||||
GiteaTagClient.from_authoritative_environment({})
|
||||
with pytest.raises(GiteaConfigurationError, match="GITEA_REPOSITORY_MISSING"):
|
||||
GiteaTagClient.from_authoritative_environment({"AIOS_GITOPS_GITEA_API_URL": "https://gitea.invalid/api/v1"})
|
||||
with pytest.raises(GiteaConfigurationError, match="GITEA_TOKEN_REFERENCE_MISSING"):
|
||||
GiteaTagClient.from_authoritative_environment({"AIOS_GITOPS_GITEA_API_URL": "https://gitea.invalid/api/v1", "AIOS_GITOPS_GITEA_REPOSITORY": "aios/aios"})
|
||||
assert validate_version("V2R2C1") == "V2R2C1"
|
||||
with pytest.raises(InvalidReleaseVersion, match="invalid release version"):
|
||||
validate_version("not-a-release")
|
||||
|
||||
|
||||
|
||||
def test_release_consistency_reports_tag_target_mismatch(tmp_path: Path):
|
||||
target = "a" * 40
|
||||
evidence = "b" * 40
|
||||
tag_oid = "c" * 40
|
||||
mismatched_target = "d" * 40
|
||||
request = PublicationRequest("V2R2C1", target, evidence, "approval-plan:test")
|
||||
(tmp_path / "baseline").mkdir()
|
||||
(tmp_path / "versions").mkdir()
|
||||
common = {
|
||||
"version": request.version,
|
||||
"target_oid": request.target_oid,
|
||||
"evidence_closure_oid": request.evidence_closure_oid,
|
||||
"approval_plan_id": request.approval_plan_id,
|
||||
"tag_oid": tag_oid,
|
||||
}
|
||||
(tmp_path / "baseline/current.json").write_text(
|
||||
json.dumps({**common, "current_baseline": request.version}), encoding="utf-8"
|
||||
)
|
||||
(tmp_path / "versions/V2R2C1.json").write_text(
|
||||
json.dumps({**common, "registered": True, "enabled": True}), encoding="utf-8"
|
||||
)
|
||||
verified = {
|
||||
**common,
|
||||
"remote_tag_oid": tag_oid,
|
||||
"remote_peeled_target_oid": mismatched_target,
|
||||
"gitea_tag_oid": tag_oid,
|
||||
"gitea_peeled_target_oid": target,
|
||||
"gitea_listing_visible": True,
|
||||
"repository_identity": "aios/aios",
|
||||
}
|
||||
result = TagPublisher(tmp_path, object(), object(), "gitea").get_release_consistency(
|
||||
request, verified=verified
|
||||
)
|
||||
assert result["status"] == "FAIL"
|
||||
assert result["error"] == "TAG_TARGET_MISMATCH"
|
||||
@@ -1,120 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
APP = Path(__file__).resolve().parents[1] / "active-runtime" / "app"
|
||||
sys.path.insert(0, str(APP))
|
||||
|
||||
import governed_production_release as release # noqa: E402
|
||||
from version_registry import build_source_registry_entry, source_registry_json # noqa: E402
|
||||
|
||||
|
||||
def _git(work: Path, *args: str) -> str:
|
||||
return subprocess.run(
|
||||
["git", "-C", str(work), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def _commit(work: Path, message: str) -> str:
|
||||
subprocess.run(["git", "-C", str(work), "add", "-A"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "commit", "-qm", message], check=True)
|
||||
return _git(work, "rev-parse", "HEAD")
|
||||
|
||||
|
||||
def test_artifact_uses_pinned_source_registry_commit_when_main_advances(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
if shutil.which("zstd") is None:
|
||||
pytest.skip("zstd is required for governed artifact integration")
|
||||
work = tmp_path / "work"
|
||||
bare = tmp_path / "repo.git"
|
||||
subprocess.run(["git", "init", "-q", "-b", "main", str(work)], check=True)
|
||||
_git(work, "config", "user.name", "test")
|
||||
_git(work, "config", "user.email", "test@example.invalid")
|
||||
(work / "active-runtime/app").mkdir(parents=True)
|
||||
(work / "active-runtime/app/deployment_state.py").write_text("state = 'candidate'\n")
|
||||
(work / "active-runtime/app/v2r1c6_privacy_tools.py").write_text("def main(): return 0\n")
|
||||
target_oid = _commit(work, "release target")
|
||||
subprocess.run(
|
||||
["git", "-C", str(work), "tag", "-a", "V2R2C1", target_oid, "-m", "release"],
|
||||
check=True,
|
||||
)
|
||||
tag_oid = _git(work, "rev-parse", "refs/tags/V2R2C1")
|
||||
(work / "evidence.txt").write_text("evidence\n")
|
||||
evidence_oid = _commit(work, "evidence closure")
|
||||
record = build_source_registry_entry(
|
||||
version="V2R2C1",
|
||||
tag_oid=tag_oid,
|
||||
target_oid=target_oid,
|
||||
peeled_target_oid=target_oid,
|
||||
evidence_closure_oid=evidence_oid,
|
||||
approval_plan_id="sha256:" + "4" * 64,
|
||||
)
|
||||
source_path = work / "versions/V2R2C1.json"
|
||||
source_path.parent.mkdir(parents=True)
|
||||
source_path.write_bytes(source_registry_json(record))
|
||||
main_oid = _commit(work, "source registry convergence")
|
||||
subprocess.run(["git", "clone", "-q", "--bare", str(work), str(bare)], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "remote", "add", "authority", str(bare)], check=True)
|
||||
monkeypatch.setenv("AIOS_TEST_MODE", "1")
|
||||
monkeypatch.setenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", str(bare))
|
||||
|
||||
monkeypatch.setattr(release, "TAG_OID", tag_oid)
|
||||
monkeypatch.setattr(release, "TARGET_OID", target_oid)
|
||||
monkeypatch.setattr(release, "EVIDENCE_CLOSURE_OID", evidence_oid)
|
||||
monkeypatch.setattr(release, "APPROVAL_PLAN_ID", record["approval_plan_id"])
|
||||
binding = release.ReleaseBinding(
|
||||
tag_oid=tag_oid,
|
||||
target_oid=target_oid,
|
||||
evidence_closure_oid=evidence_oid,
|
||||
approval_plan_id=record["approval_plan_id"],
|
||||
)
|
||||
|
||||
original_payload = release._payload_file
|
||||
advanced = False
|
||||
|
||||
def advance_after_first_payload(git_dir: Path, oid: str, path: str) -> bytes:
|
||||
nonlocal advanced
|
||||
result = original_payload(git_dir, oid, path)
|
||||
if not advanced and path == "active-runtime/app/deployment_state.py":
|
||||
updated = dict(record)
|
||||
updated["review_marker"] = "main-advanced-after-validation"
|
||||
source_path.write_bytes(source_registry_json(updated))
|
||||
new_main = _commit(work, "advance main after source validation")
|
||||
subprocess.run(["git", "-C", str(work), "push", "-q", "authority", "main"], check=True)
|
||||
assert new_main != main_oid
|
||||
advanced = True
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(release, "_payload_file", advance_after_first_payload)
|
||||
receipt = release.build_production_artifact(bare, binding, tmp_path / "artifacts")
|
||||
assert advanced is True
|
||||
assert receipt["release_target_oid"] == target_oid
|
||||
assert receipt["source_registry_main_oid"] == main_oid
|
||||
assert receipt["source_registry_path"] == "versions/V2R2C1.json"
|
||||
|
||||
expected_bytes = subprocess.run(
|
||||
["git", f"--git-dir={bare}", "show", f"{main_oid}:versions/V2R2C1.json"],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
).stdout
|
||||
assert receipt["source_registry_sha256"] == __import__("hashlib").sha256(expected_bytes).hexdigest()
|
||||
compressed = Path(receipt["artifact"]).read_bytes()
|
||||
raw_tar = subprocess.run(["zstd", "-dc", "-"], input=compressed, capture_output=True, check=True).stdout
|
||||
with tarfile.open(fileobj=io.BytesIO(raw_tar), mode="r:") as archive:
|
||||
packaged = archive.extractfile("payload/versions/V2R2C1.json").read()
|
||||
metadata = json.loads(archive.extractfile("payload/BUILD-METADATA.json").read())
|
||||
assert packaged == expected_bytes
|
||||
assert metadata["source_registry_main_oid"] == main_oid
|
||||
assert metadata["source_registry_sha256"] == receipt["source_registry_sha256"]
|
||||
@@ -25,18 +25,6 @@ def test_model_api_staging_unit_is_non_production_and_read_only():
|
||||
assert 'production' not in unit.lower().replace('model-api-v2r2c1-staging', '')
|
||||
|
||||
|
||||
def test_model_api_source_convergence_never_falls_back_to_generic_git_dir():
|
||||
source = (ROOT / 'active-runtime' / 'app' / 'aios_model_api_staging.py').read_text(encoding='utf-8')
|
||||
assert "repo_git_dir=AUTHORITATIVE_GIT_DIR or GIT_DIR" not in source
|
||||
assert "repo_git_dir=AUTHORITATIVE_GIT_DIR" in source
|
||||
|
||||
|
||||
def test_production_adapter_never_claims_source_git_convergence():
|
||||
source = (ROOT / 'active-runtime' / 'app' / 'v2r2c1_production_adapter.py').read_text(encoding='utf-8')
|
||||
assert '"source_registry_converged": True' not in source
|
||||
assert '"source_registry_converged": "not_checked"' in source
|
||||
|
||||
|
||||
def test_model_api_builder_manifest_contract():
|
||||
builder = (ROOT / 'scripts' / 'build_v2r2c1_model_api_staging.py').read_text(encoding='utf-8')
|
||||
assert 'aios.v2r2c1.model-api-staging-manifest.v1' in builder
|
||||
|
||||
@@ -47,8 +47,6 @@ def old_baseline(path: Path) -> str:
|
||||
|
||||
|
||||
def test_baseline_record_uses_real_previous_production_baseline(tmp_path, monkeypatch):
|
||||
# This test isolates previous-baseline semantics; convergence is tested separately.
|
||||
monkeypatch.setattr(adapter, "assert_source_registry_convergence", lambda *a, **kw: None)
|
||||
baseline = tmp_path / "baseline.json"
|
||||
previous_sha = old_baseline(baseline)
|
||||
registry_root = tmp_path / "versions"
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import threading
|
||||
|
||||
from aios_signer_broker_service import (
|
||||
REQUEST_SCHEMA,
|
||||
RESPONSE_SCHEMA,
|
||||
SignerBrokerPolicy,
|
||||
SignerBrokerService,
|
||||
SignerBrokerServiceError,
|
||||
)
|
||||
|
||||
|
||||
class FakeRunner:
|
||||
def __init__(self, failure: str | None = None):
|
||||
self.failure = failure
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def self_test(self) -> None:
|
||||
with self.lock:
|
||||
self.calls.append(("self_test", ""))
|
||||
if self.failure:
|
||||
raise SignerBrokerServiceError(self.failure)
|
||||
|
||||
def sign(self, payload_kind: str, payload: bytes) -> tuple[bytes, str]:
|
||||
with self.lock:
|
||||
self.calls.append(("sign", payload_kind))
|
||||
if self.failure:
|
||||
raise SignerBrokerServiceError(self.failure)
|
||||
return b"signature:" + payload, "test-broker-key"
|
||||
|
||||
|
||||
def make_policy(tmp_path: Path, *, allowed_uids: frozenset[int] = frozenset({1000})) -> SignerBrokerPolicy:
|
||||
return SignerBrokerPolicy(
|
||||
socket_path=tmp_path / "signer.sock",
|
||||
signer_path=Path("/usr/local/libexec/aios-grant-signer-adapter"),
|
||||
audit_path=tmp_path / "audit.jsonl",
|
||||
state_root=tmp_path / "state",
|
||||
allowed_uids=allowed_uids,
|
||||
)
|
||||
|
||||
|
||||
def sign_request(payload: bytes = b"canonical payload") -> dict[str, str]:
|
||||
return {
|
||||
"schema": REQUEST_SCHEMA,
|
||||
"operation": "sign",
|
||||
"payload_kind": "approval_plan",
|
||||
"payload_sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"payload_base64": base64.b64encode(payload).decode("ascii"),
|
||||
}
|
||||
|
||||
|
||||
def read_audit(path: Path) -> list[dict[str, object]]:
|
||||
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
|
||||
|
||||
|
||||
def test_authorized_request_and_self_test_emit_metadata_only_audit(tmp_path: Path):
|
||||
service = SignerBrokerService(make_policy(tmp_path), FakeRunner())
|
||||
self_test = service.handle_request({"schema": REQUEST_SCHEMA, "operation": "self_test"}, 1000)
|
||||
signed = service.handle_request(sign_request(b"secret-shaped canonical input"), 1000)
|
||||
assert self_test == {"schema": RESPONSE_SCHEMA, "status": "PASS", "operation": "self_test"}
|
||||
assert signed["status"] == "PASS"
|
||||
assert base64.b64decode(signed["signature_base64"]) == b"signature:secret-shaped canonical input"
|
||||
assert signed["key_id"] == "test-broker-key"
|
||||
audit_text = (tmp_path / "audit.jsonl").read_text(encoding="utf-8")
|
||||
assert "secret-shaped canonical input" not in audit_text
|
||||
assert "payload_base64" not in audit_text
|
||||
allowed = {"schema", "timestamp", "peer_uid", "operation", "status", "error_code", "payload_sha256"}
|
||||
assert all(set(event) <= allowed for event in read_audit(tmp_path / "audit.jsonl"))
|
||||
|
||||
|
||||
def test_unauthorized_caller_is_rejected_before_signer_use(tmp_path: Path):
|
||||
runner = FakeRunner()
|
||||
service = SignerBrokerService(make_policy(tmp_path), runner)
|
||||
result = service.handle_request(sign_request(), 1001)
|
||||
assert result["status"] == "FAIL"
|
||||
assert result["error_code"] == "CALLER_UNAUTHORIZED"
|
||||
assert runner.calls == []
|
||||
|
||||
|
||||
def test_arbitrary_command_and_invalid_operation_are_rejected(tmp_path: Path):
|
||||
runner = FakeRunner()
|
||||
service = SignerBrokerService(make_policy(tmp_path), runner)
|
||||
injected = {"schema": REQUEST_SCHEMA, "operation": "sign", "command": ["/bin/sh", "-c", "id"]}
|
||||
invalid = {"schema": REQUEST_SCHEMA, "operation": "run", "command": ["id"]}
|
||||
assert service.handle_request(injected, 1000)["error_code"] == "REQUEST_FIELDS_REJECTED"
|
||||
assert service.handle_request(invalid, 1000)["error_code"] == "SIGNER_OPERATION_REJECTED"
|
||||
assert runner.calls == []
|
||||
|
||||
|
||||
def test_invalid_payload_and_hash_fail_closed(tmp_path: Path):
|
||||
service = SignerBrokerService(make_policy(tmp_path), FakeRunner())
|
||||
invalid = sign_request()
|
||||
invalid["payload_base64"] = "not-base64"
|
||||
assert service.handle_request(invalid, 1000)["error_code"] == "SIGNER_PAYLOAD_INVALID"
|
||||
mismatch = sign_request()
|
||||
mismatch["payload_sha256"] = "0" * 64
|
||||
assert service.handle_request(mismatch, 1000)["error_code"] == "SIGNER_PAYLOAD_HASH_MISMATCH"
|
||||
|
||||
|
||||
def test_signer_unavailable_and_timeout_are_stable_failures(tmp_path: Path):
|
||||
for code in ("SIGNER_UNAVAILABLE", "SIGNER_TIMEOUT"):
|
||||
result = SignerBrokerService(make_policy(tmp_path / code), FakeRunner(code)).handle_request(
|
||||
{"schema": REQUEST_SCHEMA, "operation": "self_test"}, 1000
|
||||
)
|
||||
assert result == {"schema": RESPONSE_SCHEMA, "status": "FAIL", "error_code": code}
|
||||
|
||||
|
||||
def test_concurrent_calls_are_safe_and_audited(tmp_path: Path):
|
||||
service = SignerBrokerService(make_policy(tmp_path), FakeRunner())
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
results = list(pool.map(lambda index: service.handle_request(sign_request(f"payload-{index}".encode()), 1000), range(24)))
|
||||
assert all(result["status"] == "PASS" for result in results)
|
||||
assert len(read_audit(tmp_path / "audit.jsonl")) == 24
|
||||
@@ -20,7 +20,6 @@ from signer_contract_compatibility import ( # noqa: E402
|
||||
canonical_json_bytes,
|
||||
deployed_signer_help,
|
||||
run_compatible_signer,
|
||||
run_signer_self_test,
|
||||
resolve_ref_oid,
|
||||
run_compatible_validator,
|
||||
sha256_json,
|
||||
@@ -114,13 +113,9 @@ class SignerContractCompatibilityTests(unittest.TestCase):
|
||||
help_result = subprocess.run([signer, "sign", "--help"], capture_output=True, text=True, check=False)
|
||||
self.assertEqual(help_result.returncode, 0)
|
||||
self.assertTrue(deployed_signer_help(help_result.stdout, help_result.stderr))
|
||||
try:
|
||||
broker_result = run_signer_self_test()
|
||||
except SignerContractError as exc:
|
||||
if str(exc) == "SIGNER_BROKER_UNAVAILABLE":
|
||||
self.skipTest("signer broker is not deployed in this engineering phase")
|
||||
raise
|
||||
self.assertEqual(broker_result["status"], "PASS")
|
||||
self_test = subprocess.run([signer, "self-test"], capture_output=True, text=True, check=False)
|
||||
self.assertEqual(self_test.returncode, 0)
|
||||
self.assertIn("SIGNER_SELF_TEST=PASS", self_test.stdout)
|
||||
|
||||
def test_resolve_ref_oid_uses_full_synthetic_commit_and_fails_closed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
|
||||
@@ -1,392 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
|
||||
import pytest
|
||||
|
||||
from approved_release_executor import (
|
||||
Binding,
|
||||
ManualOwnerReviewRequired,
|
||||
ReleaseBlocked,
|
||||
ReconciliationRequired,
|
||||
deploy_step,
|
||||
execute,
|
||||
tag_step,
|
||||
verify_artifact,
|
||||
verify_authorization,
|
||||
)
|
||||
|
||||
|
||||
def _binding(tmp_path: Path, **overrides) -> Binding:
|
||||
artifact = tmp_path / "artifact.tar.zst"
|
||||
artifact.write_bytes(b"x")
|
||||
values = {
|
||||
"version": "VTEST",
|
||||
"target_oid": "a" * 40,
|
||||
"evidence_closure_oid": "b" * 40,
|
||||
"artifact_sha256": "c" * 64,
|
||||
"approval_plan_id": "sha256:" + "d" * 64,
|
||||
"artifact_path": str(artifact),
|
||||
"artifact_size": 1,
|
||||
"production_service": "test.service",
|
||||
"production_port": 18927,
|
||||
}
|
||||
values.update(overrides)
|
||||
return Binding(**values)
|
||||
|
||||
|
||||
def _authorization(binding: Binding) -> dict[str, object]:
|
||||
result: dict[str, object] = {
|
||||
"accepted": True,
|
||||
"binding_replaced": False,
|
||||
"version": binding.version,
|
||||
"target_oid": binding.target_oid,
|
||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||
"artifact_sha256": binding.artifact_sha256,
|
||||
"approval_plan_id": binding.approval_plan_id,
|
||||
}
|
||||
if binding.tag_oid is not None:
|
||||
result.update(
|
||||
{
|
||||
"binding_schema": binding.binding_schema or "aios.release-binding.v2",
|
||||
"tag_oid": binding.tag_oid,
|
||||
}
|
||||
)
|
||||
if binding.source_registry_main_oid is not None:
|
||||
result.update(
|
||||
{
|
||||
"source_registry_main_oid": binding.source_registry_main_oid,
|
||||
"source_registry_path": binding.source_registry_path
|
||||
or f"versions/{binding.version}.json",
|
||||
"source_registry_sha256": binding.source_registry_sha256,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def test_legacy_v2r2c1_style_authorization_remains_compatible(tmp_path: Path) -> None:
|
||||
binding = _binding(tmp_path)
|
||||
assert verify_authorization(_authorization(binding), binding)["accepted"] is True
|
||||
|
||||
|
||||
def test_future_binding_requires_explicit_tag_oid(tmp_path: Path) -> None:
|
||||
binding = _binding(
|
||||
tmp_path,
|
||||
version="V2R2C2",
|
||||
tag_oid="1" * 40,
|
||||
binding_schema="aios.release-binding.v2",
|
||||
)
|
||||
assert verify_authorization(_authorization(binding), binding)["tag_oid"] == "1" * 40
|
||||
wrong = _authorization(binding)
|
||||
wrong["tag_oid"] = "2" * 40
|
||||
with pytest.raises(ManualOwnerReviewRequired, match="tag_oid"):
|
||||
verify_authorization(wrong, binding)
|
||||
|
||||
|
||||
def test_future_same_name_different_tag_object_requires_manual_review(monkeypatch, tmp_path: Path) -> None:
|
||||
binding = _binding(
|
||||
tmp_path,
|
||||
version="V2R2C2",
|
||||
tag_oid="1" * 40,
|
||||
binding_schema="aios.release-binding.v2",
|
||||
)
|
||||
|
||||
def fake(_command, _request):
|
||||
return {
|
||||
"status": "APPLIED_VERIFIED",
|
||||
"version": binding.version,
|
||||
"target_oid": binding.target_oid,
|
||||
"artifact_sha256": binding.artifact_sha256,
|
||||
"tag_oid": "2" * 40,
|
||||
"peeled_target_oid": binding.target_oid,
|
||||
}
|
||||
|
||||
monkeypatch.setattr("approved_release_executor.run_adapter", fake)
|
||||
with pytest.raises(ManualOwnerReviewRequired, match="same-name tag object"):
|
||||
tag_step(["/tag"], binding)
|
||||
|
||||
|
||||
def test_executor_rechecks_source_registry_digest_and_build_provenance(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
if shutil.which("zstd") is None:
|
||||
pytest.skip("zstd is required for archive verification")
|
||||
source = {
|
||||
"schema": "aios.source-registry.v1",
|
||||
"version": "V2R2C2",
|
||||
"repository": "aios/aios",
|
||||
"tag": "V2R2C2",
|
||||
"tag_oid": "1" * 40,
|
||||
"tag_object": "1" * 40,
|
||||
"target_oid": "2" * 40,
|
||||
"peeled_target_oid": "2" * 40,
|
||||
"evidence_closure_oid": "3" * 40,
|
||||
"approval_plan_id": "sha256:" + "4" * 64,
|
||||
}
|
||||
source_bytes = (json.dumps(source, ensure_ascii=False, sort_keys=True, indent=2) + "\n").encode()
|
||||
source_digest = hashlib.sha256(source_bytes).hexdigest()
|
||||
repo_work = tmp_path / "authority-work"
|
||||
repo_bare = tmp_path / "authority.git"
|
||||
(repo_work / "versions").mkdir(parents=True)
|
||||
(repo_work / "versions" / "V2R2C2.json").write_bytes(source_bytes)
|
||||
subprocess.run(["git", "init", "-q", str(repo_work)], check=True)
|
||||
subprocess.run(["git", "-C", str(repo_work), "branch", "-M", "main"], check=True)
|
||||
subprocess.run(["git", "-C", str(repo_work), "config", "user.name", "test"], check=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo_work), "config", "user.email", "test@example.invalid"],
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(["git", "-C", str(repo_work), "add", "versions"], check=True)
|
||||
subprocess.run(["git", "-C", str(repo_work), "commit", "-qm", "source"], check=True)
|
||||
source_main_oid = subprocess.run(
|
||||
["git", "-C", str(repo_work), "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
subprocess.run(["git", "clone", "-q", "--bare", str(repo_work), str(repo_bare)], check=True)
|
||||
subprocess.run(
|
||||
["git", f"--git-dir={repo_bare}", "config", "remote.gitea.url", "https://g.cnnetmkt.top/aios/aios.git"],
|
||||
check=True,
|
||||
)
|
||||
monkeypatch.setenv("AIOS_TEST_MODE", "1")
|
||||
monkeypatch.setenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", str(repo_bare))
|
||||
metadata = {
|
||||
"release_target_oid": "2" * 40,
|
||||
"source_registry_main_oid": source_main_oid,
|
||||
"source_registry_path": "versions/V2R2C2.json",
|
||||
"source_registry_sha256": source_digest,
|
||||
}
|
||||
tar_path = tmp_path / "release.tar"
|
||||
with tarfile.open(tar_path, "w") as archive:
|
||||
for name, content in (
|
||||
("payload/versions/V2R2C2.json", source_bytes),
|
||||
("payload/BUILD-METADATA.json", (json.dumps(metadata, sort_keys=True) + "\n").encode()),
|
||||
):
|
||||
info = tarfile.TarInfo(name)
|
||||
info.size = len(content)
|
||||
archive.addfile(info, io.BytesIO(content))
|
||||
artifact = tmp_path / "release.tar.zst"
|
||||
subprocess.run(["zstd", "-q", "-19", "-f", str(tar_path), "-o", str(artifact)], check=True)
|
||||
binding = _binding(
|
||||
tmp_path,
|
||||
version="V2R2C2",
|
||||
tag_oid="1" * 40,
|
||||
binding_schema="aios.release-binding.v2",
|
||||
target_oid="2" * 40,
|
||||
evidence_closure_oid="3" * 40,
|
||||
approval_plan_id="sha256:" + "4" * 64,
|
||||
source_registry_main_oid=source_main_oid,
|
||||
source_registry_path="versions/V2R2C2.json",
|
||||
source_registry_sha256=source_digest,
|
||||
artifact_path=str(artifact),
|
||||
artifact_size=artifact.stat().st_size,
|
||||
artifact_sha256=hashlib.sha256(artifact.read_bytes()).hexdigest(),
|
||||
)
|
||||
result = verify_artifact(binding)
|
||||
assert result["source_registry_sha256"] == source_digest
|
||||
assert result["build_metadata_provenance_verified"] is True
|
||||
|
||||
bad_metadata = dict(metadata)
|
||||
bad_metadata["source_registry_sha256"] = "0" * 64
|
||||
bad_tar = tmp_path / "bad-metadata.tar"
|
||||
with tarfile.open(bad_tar, "w") as archive:
|
||||
for name, content in (
|
||||
("payload/versions/V2R2C2.json", source_bytes),
|
||||
("payload/BUILD-METADATA.json", (json.dumps(bad_metadata, sort_keys=True) + "\n").encode()),
|
||||
):
|
||||
info = tarfile.TarInfo(name)
|
||||
info.size = len(content)
|
||||
archive.addfile(info, io.BytesIO(content))
|
||||
bad_artifact = tmp_path / "bad-metadata.tar.zst"
|
||||
subprocess.run(["zstd", "-q", "-19", "-f", str(bad_tar), "-o", str(bad_artifact)], check=True)
|
||||
bad_binding = _binding(
|
||||
tmp_path,
|
||||
version="V2R2C2",
|
||||
tag_oid="1" * 40,
|
||||
binding_schema="aios.release-binding.v2",
|
||||
target_oid="2" * 40,
|
||||
evidence_closure_oid="3" * 40,
|
||||
approval_plan_id="sha256:" + "4" * 64,
|
||||
source_registry_main_oid=source_main_oid,
|
||||
source_registry_path="versions/V2R2C2.json",
|
||||
source_registry_sha256=source_digest,
|
||||
artifact_path=str(bad_artifact),
|
||||
artifact_size=bad_artifact.stat().st_size,
|
||||
artifact_sha256=hashlib.sha256(bad_artifact.read_bytes()).hexdigest(),
|
||||
)
|
||||
with pytest.raises(ReleaseBlocked, match="BUILD-METADATA"):
|
||||
verify_artifact(bad_binding)
|
||||
|
||||
subprocess.run(["git", "-C", str(repo_work), "remote", "add", "origin", str(repo_bare)], check=True)
|
||||
changed = dict(source)
|
||||
changed["review_marker"] = "changed-after-M"
|
||||
changed_bytes = (json.dumps(changed, ensure_ascii=False, sort_keys=True, indent=2) + "\n").encode()
|
||||
(repo_work / "versions" / "V2R2C2.json").write_bytes(changed_bytes)
|
||||
subprocess.run(["git", "-C", str(repo_work), "add", "versions"], check=True)
|
||||
subprocess.run(["git", "-C", str(repo_work), "commit", "-qm", "change-source"], check=True)
|
||||
subprocess.run(["git", "-C", str(repo_work), "push", "-q", "origin", "HEAD:main"], check=True)
|
||||
with pytest.raises(ManualOwnerReviewRequired, match="current Authority main source registry"):
|
||||
verify_artifact(binding)
|
||||
|
||||
|
||||
def test_executor_requires_runtime_bundle_convergence_for_provenance_binding(tmp_path: Path):
|
||||
binding = _binding(
|
||||
tmp_path,
|
||||
version="V2R2C2",
|
||||
tag_oid="1" * 40,
|
||||
binding_schema="aios.release-binding.v2",
|
||||
source_registry_main_oid="5" * 40,
|
||||
source_registry_path="versions/V2R2C2.json",
|
||||
source_registry_sha256="6" * 64,
|
||||
)
|
||||
|
||||
def fake_missing(_command, _request):
|
||||
return {
|
||||
"status": "ACTIVE_VERIFIED",
|
||||
"version": binding.version,
|
||||
"target_oid": binding.target_oid,
|
||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||
"artifact_sha256": binding.artifact_sha256,
|
||||
"approval_plan_id": binding.approval_plan_id,
|
||||
"deployment_state": "ACTIVE",
|
||||
"production_deployed": True,
|
||||
}
|
||||
|
||||
monkeypatch = pytest.MonkeyPatch()
|
||||
monkeypatch.setattr("approved_release_executor.run_adapter", fake_missing)
|
||||
try:
|
||||
with pytest.raises(ReconciliationRequired, match="runtime bundle/runtime registry"):
|
||||
deploy_step(["/deploy"], binding)
|
||||
finally:
|
||||
monkeypatch.undo()
|
||||
|
||||
def fake_converged(_command, _request):
|
||||
return {
|
||||
"status": "ACTIVE_VERIFIED",
|
||||
"version": binding.version,
|
||||
"target_oid": binding.target_oid,
|
||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||
"artifact_sha256": binding.artifact_sha256,
|
||||
"approval_plan_id": binding.approval_plan_id,
|
||||
"deployment_state": "ACTIVE",
|
||||
"production_deployed": True,
|
||||
"runtime_bundle_contains_current_version": True,
|
||||
"runtime_registry_converged": True,
|
||||
}
|
||||
|
||||
monkeypatch.setattr("approved_release_executor.run_adapter", fake_converged)
|
||||
try:
|
||||
assert deploy_step(["/deploy"], binding)["runtime_registry_converged"] is True
|
||||
finally:
|
||||
monkeypatch.undo()
|
||||
|
||||
|
||||
def test_execute_rechecks_authoritative_git_after_runtime_before_registry(
|
||||
monkeypatch, tmp_path: Path
|
||||
):
|
||||
work = tmp_path / "authority-work"
|
||||
bare = tmp_path / "authority.git"
|
||||
(work / "versions").mkdir(parents=True)
|
||||
subprocess.run(["git", "init", "-q", "-b", "main", str(work)], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "config", "user.name", "test"], check=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(work), "config", "user.email", "test@example.invalid"],
|
||||
check=True,
|
||||
)
|
||||
source = {
|
||||
"schema": "aios.source-registry.v1",
|
||||
"version": "VTEST",
|
||||
"repository": "aios/aios",
|
||||
"tag_oid": "1" * 40,
|
||||
"tag_object": "1" * 40,
|
||||
"target_oid": "a" * 40,
|
||||
"peeled_target_oid": "a" * 40,
|
||||
"evidence_closure_oid": "b" * 40,
|
||||
"approval_plan_id": "sha256:" + "d" * 64,
|
||||
}
|
||||
source_path = work / "versions" / "VTEST.json"
|
||||
source_path.write_bytes((json.dumps(source, sort_keys=True, indent=2) + "\n").encode())
|
||||
subprocess.run(["git", "-C", str(work), "add", "versions"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "commit", "-qm", "source"], check=True)
|
||||
pinned_main = subprocess.run(
|
||||
["git", "-C", str(work), "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
subprocess.run(["git", "clone", "-q", "--bare", str(work), str(bare)], check=True)
|
||||
source_sha = hashlib.sha256(source_path.read_bytes()).hexdigest()
|
||||
binding = _binding(
|
||||
tmp_path,
|
||||
source_registry_main_oid=pinned_main,
|
||||
source_registry_path="versions/VTEST.json",
|
||||
source_registry_sha256=source_sha,
|
||||
)
|
||||
monkeypatch.setenv("AIOS_TEST_MODE", "1")
|
||||
monkeypatch.setenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", str(bare))
|
||||
monkeypatch.setattr("approved_release_executor.secure_json", lambda _path: _authorization(binding))
|
||||
monkeypatch.setattr("approved_release_executor.verify_git_lineage", lambda *_args: {"verified": True})
|
||||
monkeypatch.setattr("approved_release_executor.verify_artifact", lambda *_args, **_kwargs: {"verified": True})
|
||||
|
||||
deployed = False
|
||||
calls = []
|
||||
|
||||
def fake_adapter(command, request):
|
||||
nonlocal deployed
|
||||
name = command[0]
|
||||
operation = request["operation"]
|
||||
calls.append((name, operation))
|
||||
common = {
|
||||
"version": binding.version,
|
||||
"target_oid": binding.target_oid,
|
||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||
"artifact_sha256": binding.artifact_sha256,
|
||||
"approval_plan_id": binding.approval_plan_id,
|
||||
}
|
||||
if name == "/tag":
|
||||
return {
|
||||
**common,
|
||||
"status": "APPLIED_VERIFIED",
|
||||
"tag_oid": "1" * 40,
|
||||
"peeled_target_oid": binding.target_oid,
|
||||
}
|
||||
if name == "/deploy":
|
||||
if operation == "probe" and not deployed:
|
||||
changed = dict(source)
|
||||
changed["review_marker"] = "changed-after-runtime"
|
||||
source_path.write_bytes((json.dumps(changed, sort_keys=True, indent=2) + "\n").encode())
|
||||
subprocess.run(["git", "-C", str(work), "add", "versions"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "commit", "-qm", "advance-main"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "push", "-q", str(bare), "HEAD:main"], check=True)
|
||||
deployed = True
|
||||
return {
|
||||
**common,
|
||||
"status": "ACTIVE_VERIFIED",
|
||||
"deployment_state": "ACTIVE",
|
||||
"production_deployed": True,
|
||||
"runtime_bundle_contains_current_version": True,
|
||||
"runtime_registry_converged": True,
|
||||
}
|
||||
raise AssertionError(f"unexpected adapter call: {name}")
|
||||
|
||||
monkeypatch.setattr("approved_release_executor.run_adapter", fake_adapter)
|
||||
with pytest.raises(ManualOwnerReviewRequired, match="current Authority main source registry"):
|
||||
execute(
|
||||
binding=binding,
|
||||
authorization_path=tmp_path / "authorization.json",
|
||||
repository=bare,
|
||||
state_root=tmp_path / "state",
|
||||
tag_command=["/tag"],
|
||||
deploy_command=["/deploy"],
|
||||
registry_command=["/registry"],
|
||||
baseline_command=["/baseline"],
|
||||
)
|
||||
assert deployed is True
|
||||
assert not any(name in {"/registry", "/baseline"} for name, _ in calls)
|
||||
@@ -1,850 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
APP = Path(__file__).resolve().parents[1] / "active-runtime" / "app"
|
||||
sys.path.insert(0, str(APP))
|
||||
|
||||
import release_metadata_adapter as adapter # noqa: E402
|
||||
import version_registry as registry_module # noqa: E402
|
||||
from version_registry import ( # noqa: E402
|
||||
RegistryConvergenceError,
|
||||
assert_runtime_registry_convergence,
|
||||
assert_source_registry_convergence,
|
||||
build_source_registry_entry,
|
||||
configured_approval_authority_root,
|
||||
configured_authoritative_git_dir,
|
||||
list_registry_versions,
|
||||
list_registry_versions_report,
|
||||
source_registry_snapshot,
|
||||
verify_authoritative_repository,
|
||||
verify_approval_authority,
|
||||
verify_source_registry_main_convergence,
|
||||
verify_source_registry_git_identity,
|
||||
)
|
||||
|
||||
|
||||
def _write_json(path: Path, obj: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(obj, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _record(
|
||||
*,
|
||||
version: str = "V2R2C1",
|
||||
tag_oid: str = "1" * 40,
|
||||
target_oid: str = "2" * 40,
|
||||
) -> dict:
|
||||
return {
|
||||
"schema": "aios.production-registry.v2",
|
||||
"version": version,
|
||||
"repository": "aios/aios",
|
||||
"status": "PRODUCTION_DEPLOYED",
|
||||
"production_deployed": True,
|
||||
"tag_oid": tag_oid,
|
||||
"target_oid": target_oid,
|
||||
"peeled_target_oid": target_oid,
|
||||
"evidence_closure_oid": "3" * 40,
|
||||
"approval_plan_id": "sha256:" + "4" * 64,
|
||||
}
|
||||
|
||||
|
||||
def _canonical_approval(root: Path, record: dict) -> Path:
|
||||
owner = {
|
||||
"approved": True,
|
||||
"status": "APPROVED",
|
||||
"approved_by": "aiman",
|
||||
"approval_source": "explicit_owner_approval_in_chat",
|
||||
}
|
||||
binding = {
|
||||
key: record[key]
|
||||
for key in (
|
||||
"version",
|
||||
"repository",
|
||||
"tag_oid",
|
||||
"target_oid",
|
||||
"peeled_target_oid",
|
||||
"evidence_closure_oid",
|
||||
"approval_plan_id",
|
||||
)
|
||||
}
|
||||
authority = {
|
||||
"schema": "aios.v2r2c1.tag-approval-request.v1",
|
||||
"state": "APPROVED",
|
||||
"status": "APPROVED",
|
||||
"execute": False,
|
||||
"approval_plan_id": record["approval_plan_id"],
|
||||
"binding": binding,
|
||||
"owner_approval": owner,
|
||||
"owner_approval_digest_sha256": hashlib.sha256(
|
||||
(json.dumps(owner, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
||||
).hexdigest(),
|
||||
}
|
||||
path = root / f"{record['version']}.json"
|
||||
_write_json(path, authority)
|
||||
path.chmod(0o640)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _explicit_test_authority_mode(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Permit only these unit tests to inject temporary authority roots."""
|
||||
monkeypatch.setenv("AIOS_TEST_MODE", "1")
|
||||
|
||||
|
||||
def test_list_registry_versions_adds_only_baseline_attested_projection(tmp_path: Path):
|
||||
ws = tmp_path / "ws"
|
||||
(ws / "versions").mkdir(parents=True)
|
||||
(ws / "versions" / "V2R1C7.json").write_text("{}\n")
|
||||
|
||||
projection_root = tmp_path / "projection"
|
||||
projection = projection_root / "V2R2C1.json"
|
||||
_write_json(projection, _record())
|
||||
digest = hashlib.sha256(projection.read_bytes()).hexdigest()
|
||||
|
||||
baseline = tmp_path / "baseline" / "current.json"
|
||||
_write_json(
|
||||
baseline,
|
||||
{
|
||||
"version": "V2R2C1",
|
||||
"status": "PRODUCTION_DEPLOYED",
|
||||
"production_deployed": True,
|
||||
"registry_source": str(projection),
|
||||
"registry_digest": digest,
|
||||
},
|
||||
)
|
||||
|
||||
assert list_registry_versions(
|
||||
ws,
|
||||
projection_root=projection_root,
|
||||
baseline_file=baseline,
|
||||
) == ["V2R1C7.json", "V2R2C1.json"]
|
||||
|
||||
|
||||
def test_list_registry_versions_rejects_projection_digest_drift(tmp_path: Path):
|
||||
ws = tmp_path / "ws"
|
||||
(ws / "versions").mkdir(parents=True)
|
||||
projection_root = tmp_path / "projection"
|
||||
projection = projection_root / "V2R2C1.json"
|
||||
_write_json(projection, _record())
|
||||
|
||||
baseline = tmp_path / "baseline.json"
|
||||
_write_json(
|
||||
baseline,
|
||||
{
|
||||
"version": "V2R2C1",
|
||||
"status": "PRODUCTION_DEPLOYED",
|
||||
"production_deployed": True,
|
||||
"registry_source": str(projection),
|
||||
"registry_digest": "0" * 64,
|
||||
},
|
||||
)
|
||||
|
||||
assert list_registry_versions(
|
||||
ws,
|
||||
projection_root=projection_root,
|
||||
baseline_file=baseline,
|
||||
) == []
|
||||
|
||||
|
||||
def test_projection_only_version_is_not_runtime_artifact_convergence(tmp_path: Path):
|
||||
ws = tmp_path / "ws"
|
||||
(ws / "versions").mkdir(parents=True)
|
||||
projection_root = tmp_path / "projection"
|
||||
projection = projection_root / "V2R2C1.json"
|
||||
_write_json(projection, _record())
|
||||
baseline = tmp_path / "baseline.json"
|
||||
_write_json(
|
||||
baseline,
|
||||
{
|
||||
"version": "V2R2C1",
|
||||
"status": "PRODUCTION_DEPLOYED",
|
||||
"production_deployed": True,
|
||||
"registry_source": str(projection),
|
||||
"registry_digest": hashlib.sha256(projection.read_bytes()).hexdigest(),
|
||||
},
|
||||
)
|
||||
|
||||
report = list_registry_versions_report(
|
||||
ws,
|
||||
projection_root=projection_root,
|
||||
baseline_file=baseline,
|
||||
)
|
||||
assert report["registry_versions"] == ["V2R2C1.json"]
|
||||
assert report["attested_projection_version"] == "V2R2C1"
|
||||
assert report["runtime_bundle_contains_version"] is False
|
||||
assert report["source_registry_converged"] == "unknown"
|
||||
assert report["runtime_registry_converged"] is False
|
||||
assert report["runtime_bundle_contains_current_version"] is False
|
||||
assert report["registry_consistency_status"] == "RUNTIME_ARTIFACT_RECONCILIATION_REQUIRED"
|
||||
with pytest.raises(RegistryConvergenceError, match="runtime_bundle_missing"):
|
||||
assert_runtime_registry_convergence(
|
||||
ws,
|
||||
"V2R2C1",
|
||||
projection_root=projection_root,
|
||||
baseline_file=baseline,
|
||||
)
|
||||
|
||||
|
||||
def _attested_projection(tmp_path: Path, record: dict) -> tuple[Path, Path]:
|
||||
projection_root = tmp_path / "projection"
|
||||
projection = projection_root / f"{record['version']}.json"
|
||||
_write_json(projection, record)
|
||||
baseline = tmp_path / "baseline.json"
|
||||
_write_json(
|
||||
baseline,
|
||||
{
|
||||
"version": record["version"],
|
||||
"status": "PRODUCTION_DEPLOYED",
|
||||
"production_deployed": True,
|
||||
"registry_source": str(projection),
|
||||
"registry_digest": hashlib.sha256(projection.read_bytes()).hexdigest(),
|
||||
},
|
||||
)
|
||||
return projection_root, baseline
|
||||
|
||||
|
||||
def test_list_report_source_good_runtime_missing_is_not_fully_consistent(tmp_path: Path):
|
||||
record = _record()
|
||||
bare, branch = _init_bare_repo(tmp_path, record)
|
||||
projection_root, baseline = _attested_projection(tmp_path, record)
|
||||
ws = tmp_path / "runtime"
|
||||
(ws / "versions").mkdir(parents=True)
|
||||
report = list_registry_versions_report(
|
||||
ws,
|
||||
projection_root=projection_root,
|
||||
baseline_file=baseline,
|
||||
repo_git_dir=bare,
|
||||
main_ref=f"refs/heads/{branch}",
|
||||
)
|
||||
assert report["source_registry_converged"] is True
|
||||
assert report["runtime_registry_converged"] is False
|
||||
assert report["runtime_bundle_contains_current_version"] is False
|
||||
assert report["registry_consistency_status"] == "RUNTIME_ARTIFACT_RECONCILIATION_REQUIRED"
|
||||
|
||||
|
||||
def test_list_report_source_runtime_projection_good_is_fully_consistent(tmp_path: Path):
|
||||
record = _record()
|
||||
bare, branch = _init_bare_repo(tmp_path, record)
|
||||
projection_root, baseline = _attested_projection(tmp_path, record)
|
||||
ws = tmp_path / "runtime"
|
||||
versions = ws / "versions"
|
||||
versions.mkdir(parents=True)
|
||||
_write_json(versions / f"{record['version']}.json", record)
|
||||
report = list_registry_versions_report(
|
||||
ws,
|
||||
projection_root=projection_root,
|
||||
baseline_file=baseline,
|
||||
repo_git_dir=bare,
|
||||
main_ref=f"refs/heads/{branch}",
|
||||
)
|
||||
assert report["source_registry_converged"] is True
|
||||
assert report["runtime_registry_converged"] is True
|
||||
assert report["runtime_bundle_contains_current_version"] is True
|
||||
assert report["registry_consistency_status"] == "FULLY_CONSISTENT"
|
||||
|
||||
|
||||
def test_list_report_source_missing_is_not_converged(tmp_path: Path):
|
||||
record = _record()
|
||||
work = tmp_path / "no-source-work"
|
||||
bare = tmp_path / "no-source.git"
|
||||
subprocess.run(["git", "init", "-q", str(work)], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "config", "user.name", "test"], check=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(work), "config", "user.email", "test@example.invalid"],
|
||||
check=True,
|
||||
)
|
||||
(work / "README").write_text("no source\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(work), "add", "README"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "commit", "-qm", "no source"], check=True)
|
||||
branch = subprocess.run(
|
||||
["git", "-C", str(work), "symbolic-ref", "--short", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
subprocess.run(["git", "clone", "-q", "--bare", str(work), str(bare)], check=True)
|
||||
projection_root, baseline = _attested_projection(tmp_path, record)
|
||||
ws = tmp_path / "runtime"
|
||||
(ws / "versions").mkdir(parents=True)
|
||||
_write_json(ws / "versions" / f"{record['version']}.json", record)
|
||||
report = list_registry_versions_report(
|
||||
ws,
|
||||
projection_root=projection_root,
|
||||
baseline_file=baseline,
|
||||
repo_git_dir=bare,
|
||||
main_ref=f"refs/heads/{branch}",
|
||||
)
|
||||
assert report["source_registry_converged"] is False
|
||||
assert report["runtime_registry_converged"] is True
|
||||
assert report["registry_consistency_status"] == "SOURCE_REGISTRY_NOT_CONVERGED"
|
||||
|
||||
|
||||
def test_list_registry_versions_excludes_non_version_entries(tmp_path: Path):
|
||||
ws = tmp_path / "ws"
|
||||
versions = ws / "versions"
|
||||
versions.mkdir(parents=True)
|
||||
(versions / "README.txt").write_text("do not expose\n", encoding="utf-8")
|
||||
(versions / "scratch.json").write_text("{}\n", encoding="utf-8")
|
||||
(versions / "proposals").mkdir()
|
||||
(versions / "V2R1C7.json").write_text("{}\n", encoding="utf-8")
|
||||
assert list_registry_versions(
|
||||
ws,
|
||||
projection_root=tmp_path / "projection",
|
||||
baseline_file=tmp_path / "baseline.json",
|
||||
) == ["V2R1C7.json"]
|
||||
|
||||
|
||||
def _init_bare_repo(tmp_path: Path, record: dict) -> tuple[Path, str]:
|
||||
work = tmp_path / "work"
|
||||
bare = tmp_path / "repo.git"
|
||||
subprocess.run(["git", "init", "-q", str(work)], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "config", "user.name", "test"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "config", "user.email", "test@example.invalid"], check=True)
|
||||
_write_json(work / "versions" / f"{record['version']}.json", record)
|
||||
subprocess.run(["git", "-C", str(work), "add", "versions"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "commit", "-qm", "registry"], check=True)
|
||||
branch = subprocess.run(
|
||||
["git", "-C", str(work), "symbolic-ref", "--short", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
subprocess.run(["git", "clone", "-q", "--bare", str(work), str(bare)], check=True)
|
||||
return bare, branch
|
||||
|
||||
|
||||
def _init_bare_repo_with_work(tmp_path: Path, record: dict) -> tuple[Path, Path, str]:
|
||||
work = tmp_path / "history-work"
|
||||
bare = tmp_path / "history.git"
|
||||
subprocess.run(["git", "init", "-q", str(work)], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "config", "user.name", "test"], check=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(work), "config", "user.email", "test@example.invalid"],
|
||||
check=True,
|
||||
)
|
||||
_write_json(work / "versions" / f"{record['version']}.json", record)
|
||||
subprocess.run(["git", "-C", str(work), "add", "versions"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "commit", "-qm", "registry"], check=True)
|
||||
branch = subprocess.run(
|
||||
["git", "-C", str(work), "symbolic-ref", "--short", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
subprocess.run(["git", "clone", "-q", "--bare", str(work), str(bare)], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "remote", "add", "origin", str(bare)], check=True)
|
||||
return work, bare, branch
|
||||
|
||||
|
||||
def test_assert_source_registry_convergence_passes_matching_identity(tmp_path: Path):
|
||||
record = _record()
|
||||
bare, branch = _init_bare_repo(tmp_path, record)
|
||||
projection = tmp_path / "projection.json"
|
||||
_write_json(projection, record)
|
||||
|
||||
assert_source_registry_convergence(
|
||||
projection,
|
||||
repo_git_dir=bare,
|
||||
main_ref=f"refs/heads/{branch}",
|
||||
)
|
||||
|
||||
|
||||
def test_assert_source_registry_convergence_fails_missing_entry(tmp_path: Path):
|
||||
record = _record()
|
||||
work = tmp_path / "work"
|
||||
bare = tmp_path / "repo.git"
|
||||
subprocess.run(["git", "init", "-q", str(work)], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "config", "user.name", "test"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "config", "user.email", "test@example.invalid"], check=True)
|
||||
(work / "README").write_text("x\n")
|
||||
subprocess.run(["git", "-C", str(work), "add", "README"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "commit", "-qm", "init"], check=True)
|
||||
branch = subprocess.run(
|
||||
["git", "-C", str(work), "symbolic-ref", "--short", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
subprocess.run(["git", "clone", "-q", "--bare", str(work), str(bare)], check=True)
|
||||
|
||||
projection = tmp_path / "projection.json"
|
||||
_write_json(projection, record)
|
||||
|
||||
with pytest.raises(RegistryConvergenceError, match="source_registry_entry_missing"):
|
||||
assert_source_registry_convergence(
|
||||
projection,
|
||||
repo_git_dir=bare,
|
||||
main_ref=f"refs/heads/{branch}",
|
||||
)
|
||||
|
||||
|
||||
def test_assert_source_registry_convergence_fails_identity_mismatch(tmp_path: Path):
|
||||
source = _record(tag_oid="5" * 40)
|
||||
projection_record = _record(tag_oid="1" * 40)
|
||||
bare, branch = _init_bare_repo(tmp_path, source)
|
||||
projection = tmp_path / "projection.json"
|
||||
_write_json(projection, projection_record)
|
||||
|
||||
with pytest.raises(RegistryConvergenceError, match="source_registry_mismatch:tag_oid"):
|
||||
assert_source_registry_convergence(
|
||||
projection,
|
||||
repo_git_dir=bare,
|
||||
main_ref=f"refs/heads/{branch}",
|
||||
)
|
||||
|
||||
|
||||
def test_assert_source_registry_convergence_requires_all_identity_fields(tmp_path: Path):
|
||||
source = _record()
|
||||
source.pop("peeled_target_oid")
|
||||
bare, branch = _init_bare_repo(tmp_path, source)
|
||||
projection = tmp_path / "projection.json"
|
||||
_write_json(projection, _record())
|
||||
with pytest.raises(RegistryConvergenceError, match="peeled_target_oid"):
|
||||
assert_source_registry_convergence(
|
||||
projection,
|
||||
repo_git_dir=bare,
|
||||
main_ref=f"refs/heads/{branch}",
|
||||
)
|
||||
|
||||
|
||||
def test_assert_source_registry_convergence_rejects_wrong_repository(tmp_path: Path):
|
||||
source = _record()
|
||||
source["repository"] = "other/repository"
|
||||
bare, branch = _init_bare_repo(tmp_path, source)
|
||||
projection = tmp_path / "projection.json"
|
||||
_write_json(projection, _record())
|
||||
with pytest.raises(RegistryConvergenceError, match="repository"):
|
||||
assert_source_registry_convergence(
|
||||
projection,
|
||||
repo_git_dir=bare,
|
||||
main_ref=f"refs/heads/{branch}",
|
||||
)
|
||||
|
||||
|
||||
def test_source_registry_main_convergence_accepts_unrelated_later_main_commit(tmp_path: Path):
|
||||
record = _record()
|
||||
work, bare, branch = _init_bare_repo_with_work(tmp_path, record)
|
||||
source_main = subprocess.run(
|
||||
["git", "-C", str(work), "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
(work / "README").write_text("unrelated later main commit\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(work), "add", "README"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "commit", "-qm", "unrelated"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "push", "-q", "origin", branch], check=True)
|
||||
result = verify_source_registry_main_convergence(
|
||||
bare,
|
||||
record["version"],
|
||||
main_ref=f"refs/heads/{branch}",
|
||||
source_registry_main_oid=source_main,
|
||||
expected_tag_oid=record["tag_oid"],
|
||||
expected_target_oid=record["target_oid"],
|
||||
expected_evidence_closure_oid=record["evidence_closure_oid"],
|
||||
expected_approval_plan_id=record["approval_plan_id"],
|
||||
)
|
||||
assert result["source_registry_main_is_ancestor_of_current_main"] is True
|
||||
|
||||
|
||||
def test_source_registry_main_convergence_rejects_side_branch_only_commit(tmp_path: Path):
|
||||
record = _record()
|
||||
work, bare, branch = _init_bare_repo_with_work(tmp_path, record)
|
||||
base = subprocess.run(
|
||||
["git", "-C", str(work), "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
subprocess.run(["git", "-C", str(work), "checkout", "-qb", "side"], check=True)
|
||||
(work / "side.txt").write_text("side\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(work), "add", "side.txt"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "commit", "-qm", "side"], check=True)
|
||||
side = subprocess.run(
|
||||
["git", "-C", str(work), "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
subprocess.run(["git", "-C", str(work), "checkout", "-q", branch], check=True)
|
||||
(work / "main.txt").write_text("main\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(work), "add", "main.txt"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "commit", "-qm", "main"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "push", "-q", "origin", branch], check=True)
|
||||
assert base != side
|
||||
with pytest.raises(RegistryConvergenceError, match="not_ancestor"):
|
||||
verify_source_registry_main_convergence(
|
||||
bare,
|
||||
record["version"],
|
||||
main_ref=f"refs/heads/{branch}",
|
||||
source_registry_main_oid=side,
|
||||
)
|
||||
|
||||
|
||||
def test_source_registry_main_convergence_rejects_removed_entry(tmp_path: Path):
|
||||
record = _record()
|
||||
work, bare, branch = _init_bare_repo_with_work(tmp_path, record)
|
||||
source_main = subprocess.run(
|
||||
["git", "-C", str(work), "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
(work / "versions" / f"{record['version']}.json").unlink()
|
||||
subprocess.run(["git", "-C", str(work), "add", "-u", "versions"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "commit", "-qm", "remove"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "push", "-q", "origin", branch], check=True)
|
||||
with pytest.raises(RegistryConvergenceError, match="entry_missing"):
|
||||
verify_source_registry_main_convergence(
|
||||
bare,
|
||||
record["version"],
|
||||
main_ref=f"refs/heads/{branch}",
|
||||
source_registry_main_oid=source_main,
|
||||
)
|
||||
|
||||
|
||||
def test_source_registry_main_convergence_rejects_altered_entry(tmp_path: Path):
|
||||
record = _record()
|
||||
work, bare, branch = _init_bare_repo_with_work(tmp_path, record)
|
||||
source_main = subprocess.run(
|
||||
["git", "-C", str(work), "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
altered = dict(record)
|
||||
altered["approval_plan_id"] = "sha256:" + "9" * 64
|
||||
_write_json(work / "versions" / f"{record['version']}.json", altered)
|
||||
subprocess.run(["git", "-C", str(work), "add", "versions"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "commit", "-qm", "alter"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "push", "-q", "origin", branch], check=True)
|
||||
with pytest.raises(RegistryConvergenceError, match="entry_changed"):
|
||||
verify_source_registry_main_convergence(
|
||||
bare,
|
||||
record["version"],
|
||||
main_ref=f"refs/heads/{branch}",
|
||||
source_registry_main_oid=source_main,
|
||||
)
|
||||
|
||||
|
||||
def test_source_registry_generator_predeclares_release_identity():
|
||||
value = build_source_registry_entry(
|
||||
version="V2R2C1",
|
||||
tag_oid="1" * 40,
|
||||
target_oid="2" * 40,
|
||||
peeled_target_oid="2" * 40,
|
||||
evidence_closure_oid="3" * 40,
|
||||
approval_plan_id="sha256:" + "4" * 64,
|
||||
)
|
||||
assert set(
|
||||
(
|
||||
"version",
|
||||
"tag_oid",
|
||||
"target_oid",
|
||||
"peeled_target_oid",
|
||||
"evidence_closure_oid",
|
||||
"approval_plan_id",
|
||||
)
|
||||
).issubset(value)
|
||||
|
||||
|
||||
def test_authoritative_repository_rejects_arbitrary_repository(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
record = _record()
|
||||
bare, _ = _init_bare_repo(tmp_path, record)
|
||||
monkeypatch.delenv("AIOS_TEST_MODE")
|
||||
with pytest.raises(RegistryConvergenceError, match="path_mismatch"):
|
||||
verify_authoritative_repository(bare)
|
||||
|
||||
|
||||
def test_production_authority_defaults_are_fixed_and_env_overrides_fail(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.delenv("AIOS_TEST_MODE")
|
||||
monkeypatch.delenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", raising=False)
|
||||
monkeypatch.delenv("AIOS_GITOPS_APPROVAL_AUTHORITY_ROOT", raising=False)
|
||||
assert configured_authoritative_git_dir() == Path("/data/aios-gitops/repo.git")
|
||||
assert configured_approval_authority_root() == Path(
|
||||
"/var/lib/aios-gitops/approval-authority"
|
||||
)
|
||||
monkeypatch.setenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", str(tmp_path / "fake.git"))
|
||||
with pytest.raises(RegistryConvergenceError, match="override_forbidden"):
|
||||
configured_authoritative_git_dir()
|
||||
monkeypatch.setenv("AIOS_GITOPS_APPROVAL_AUTHORITY_ROOT", str(tmp_path / "fake-root"))
|
||||
with pytest.raises(RegistryConvergenceError, match="override_forbidden"):
|
||||
configured_approval_authority_root()
|
||||
|
||||
|
||||
def test_production_canonical_repository_and_test_injected_authority(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
record = _record()
|
||||
bare, _ = _init_bare_repo(tmp_path, record)
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={bare}",
|
||||
"config",
|
||||
"remote.gitea.url",
|
||||
"https://g.cnnetmkt.top/aios/aios.git",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
monkeypatch.delenv("AIOS_TEST_MODE")
|
||||
monkeypatch.setattr(registry_module, "DEFAULT_AUTHORITATIVE_GIT_DIR", bare)
|
||||
assert verify_authoritative_repository(bare)["repository_identity"] == "aios/aios"
|
||||
|
||||
monkeypatch.setenv("AIOS_TEST_MODE", "1")
|
||||
monkeypatch.setenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", str(bare))
|
||||
assert verify_authoritative_repository(bare)["configured_path"] == str(bare.resolve())
|
||||
|
||||
|
||||
def test_authoritative_git_unavailable_stays_unknown_and_closure_fails(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
record = _record()
|
||||
projection_root, baseline = _attested_projection(tmp_path, record)
|
||||
ws = tmp_path / "runtime"
|
||||
(ws / "versions").mkdir(parents=True)
|
||||
_write_json(ws / "versions" / f"{record['version']}.json", record)
|
||||
missing = tmp_path / "missing-authority.git"
|
||||
report = list_registry_versions_report(
|
||||
ws,
|
||||
projection_root=projection_root,
|
||||
baseline_file=baseline,
|
||||
repo_git_dir=missing,
|
||||
)
|
||||
assert report["source_registry_converged"] == "unknown"
|
||||
assert report["source_registry_convergence_error"] == "authoritative_repository_unavailable"
|
||||
monkeypatch.delenv("AIOS_TEST_MODE")
|
||||
with pytest.raises(RegistryConvergenceError, match="path_unavailable"):
|
||||
assert_source_registry_convergence(
|
||||
projection_root / f"{record['version']}.json",
|
||||
repo_git_dir=missing,
|
||||
)
|
||||
|
||||
|
||||
def test_source_registry_snapshot_stays_pinned_after_main_advances(tmp_path: Path):
|
||||
work = tmp_path / "work"
|
||||
bare = tmp_path / "repo.git"
|
||||
subprocess.run(["git", "init", "-q", str(work)], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "config", "user.name", "test"], check=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(work), "config", "user.email", "test@example.invalid"],
|
||||
check=True,
|
||||
)
|
||||
record = _record()
|
||||
_write_json(work / "versions" / "V2R2C1.json", record)
|
||||
subprocess.run(["git", "-C", str(work), "add", "versions"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "commit", "-qm", "registry-1"], check=True)
|
||||
branch = subprocess.run(
|
||||
["git", "-C", str(work), "branch", "--show-current"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
subprocess.run(["git", "clone", "-q", "--bare", str(work), str(bare)], check=True)
|
||||
pinned = source_registry_snapshot(
|
||||
bare,
|
||||
"V2R2C1",
|
||||
main_ref=f"refs/heads/{branch}",
|
||||
)
|
||||
|
||||
updated = dict(record)
|
||||
updated["review_marker"] = "main-advanced"
|
||||
_write_json(work / "versions" / "V2R2C1.json", updated)
|
||||
subprocess.run(["git", "-C", str(work), "add", "versions"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "commit", "-qm", "registry-2"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "remote", "add", "authority", str(bare)], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "push", "-q", "authority", branch], check=True)
|
||||
|
||||
repinned = source_registry_snapshot(
|
||||
bare,
|
||||
"V2R2C1",
|
||||
main_ref=f"refs/heads/{branch}",
|
||||
source_registry_main_oid=pinned["source_registry_main_oid"],
|
||||
)
|
||||
assert repinned["source_registry_main_oid"] == pinned["source_registry_main_oid"]
|
||||
assert repinned["bytes"] == pinned["bytes"]
|
||||
assert repinned["source_registry_sha256"] == pinned["source_registry_sha256"]
|
||||
assert repinned["record"].get("review_marker") is None
|
||||
|
||||
|
||||
def _git_commit_with_release_tag(tmp_path: Path, *, annotated: bool = True):
|
||||
work = tmp_path / "authority-work"
|
||||
bare = tmp_path / "authority.git"
|
||||
subprocess.run(["git", "init", "-q", str(work)], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "config", "user.name", "test"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "config", "user.email", "test@example.invalid"], check=True)
|
||||
(work / "README").write_text("release\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(work), "add", "README"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "commit", "-qm", "target"], check=True)
|
||||
target = subprocess.run(
|
||||
["git", "-C", str(work), "rev-parse", "HEAD"], capture_output=True, text=True, check=True
|
||||
).stdout.strip()
|
||||
tag_args = ["git", "-C", str(work), "tag"]
|
||||
if annotated:
|
||||
tag_args += ["-a", "V2R2C1", target, "-m", "release"]
|
||||
else:
|
||||
tag_args += ["V2R2C1", target]
|
||||
subprocess.run(tag_args, check=True)
|
||||
tag_oid = subprocess.run(
|
||||
["git", "-C", str(work), "rev-parse", "refs/tags/V2R2C1"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
subprocess.run(["git", "-C", str(work), "commit", "--allow-empty", "-qm", "evidence"], check=True)
|
||||
evidence = subprocess.run(
|
||||
["git", "-C", str(work), "rev-parse", "HEAD"], capture_output=True, text=True, check=True
|
||||
).stdout.strip()
|
||||
record = _record(tag_oid=tag_oid, target_oid=target)
|
||||
record["evidence_closure_oid"] = evidence
|
||||
_write_json(work / "versions" / "V2R2C1.json", record)
|
||||
subprocess.run(["git", "-C", str(work), "add", "versions"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "commit", "-qm", "source registry"], check=True)
|
||||
branch = subprocess.run(
|
||||
["git", "-C", str(work), "branch", "--show-current"], capture_output=True, text=True, check=True
|
||||
).stdout.strip()
|
||||
subprocess.run(["git", "clone", "-q", "--bare", str(work), str(bare)], check=True)
|
||||
return work, bare, branch, record, target, evidence, tag_oid
|
||||
|
||||
|
||||
def test_source_registry_git_identity_rejects_lightweight_tag(tmp_path: Path):
|
||||
_, bare, _, record, _, _, _ = _git_commit_with_release_tag(tmp_path, annotated=False)
|
||||
with pytest.raises(RegistryConvergenceError, match="tag_not_annotated"):
|
||||
verify_source_registry_git_identity(bare, record)
|
||||
|
||||
|
||||
def test_source_registry_git_identity_proves_annotated_tag_and_lineage(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
_, bare, _, record, target, evidence, tag_oid = _git_commit_with_release_tag(tmp_path)
|
||||
result = verify_source_registry_git_identity(bare, record)
|
||||
assert result["tag_oid"] == tag_oid
|
||||
assert result["target_oid"] == target
|
||||
assert result["evidence_closure_oid"] == evidence
|
||||
|
||||
authority_root = tmp_path / "authority-root"
|
||||
authority_root.mkdir()
|
||||
monkeypatch.setenv("AIOS_GITOPS_APPROVAL_AUTHORITY_ROOT", str(authority_root))
|
||||
authority = _canonical_approval(authority_root, record)
|
||||
assert verify_approval_authority(authority, record)["release_binding_verified"] is True
|
||||
|
||||
|
||||
def test_source_registry_generator_requires_authoritative_git_and_approval(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
_, bare, _, record, _, _, _ = _git_commit_with_release_tag(tmp_path)
|
||||
subprocess.run(
|
||||
["git", f"--git-dir={bare}", "config", "remote.gitea.url", "https://g.cnnetmkt.top/aios/aios.git"],
|
||||
check=True,
|
||||
)
|
||||
authority_root = tmp_path / "authority-root"
|
||||
authority_root.mkdir()
|
||||
monkeypatch.setenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", str(bare))
|
||||
monkeypatch.setenv("AIOS_GITOPS_APPROVAL_AUTHORITY_ROOT", str(authority_root))
|
||||
authority = _canonical_approval(authority_root, record)
|
||||
output = tmp_path / "generated" / "versions" / "V2R2C1.json"
|
||||
script = Path(__file__).resolve().parents[1] / "scripts" / "generate_source_registry.py"
|
||||
command = [
|
||||
sys.executable,
|
||||
str(script),
|
||||
"--output",
|
||||
str(output),
|
||||
"--version",
|
||||
record["version"],
|
||||
"--tag-oid",
|
||||
record["tag_oid"],
|
||||
"--target-oid",
|
||||
record["target_oid"],
|
||||
"--peeled-target-oid",
|
||||
record["peeled_target_oid"],
|
||||
"--evidence-closure-oid",
|
||||
record["evidence_closure_oid"],
|
||||
"--approval-plan-id",
|
||||
record["approval_plan_id"],
|
||||
"--repository",
|
||||
str(bare),
|
||||
"--approval-authority",
|
||||
str(authority),
|
||||
]
|
||||
completed = subprocess.run(command, capture_output=True, text=True, check=False)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert json.loads(output.read_text(encoding="utf-8"))["tag_oid"] == record["tag_oid"]
|
||||
|
||||
wrong = list(command)
|
||||
wrong[wrong.index("--approval-plan-id") + 1] = "sha256:" + "f" * 64
|
||||
failed = subprocess.run(wrong, capture_output=True, text=True, check=False)
|
||||
assert failed.returncode != 0
|
||||
other = tmp_path / "other.git"
|
||||
subprocess.run(["git", "clone", "-q", "--bare", str(bare), str(other)], check=True)
|
||||
arbitrary = list(command)
|
||||
arbitrary[arbitrary.index("--repository") + 1] = str(other)
|
||||
failed_authority = subprocess.run(arbitrary, capture_output=True, text=True, check=False)
|
||||
assert failed_authority.returncode != 0
|
||||
|
||||
|
||||
def test_approval_authority_rejects_unassociated_plan(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
record = _record()
|
||||
authority_root = tmp_path / "authority-root"
|
||||
authority_root.mkdir()
|
||||
monkeypatch.setenv("AIOS_GITOPS_APPROVAL_AUTHORITY_ROOT", str(authority_root))
|
||||
authority = authority_root / f"{record['version']}.json"
|
||||
_write_json(
|
||||
authority,
|
||||
{
|
||||
"schema": "aios.v2r2c1.tag-approval-request.v1",
|
||||
"state": "APPROVED",
|
||||
"approval_plan_id": record["approval_plan_id"],
|
||||
},
|
||||
)
|
||||
authority.chmod(0o640)
|
||||
with pytest.raises(RegistryConvergenceError, match="binding_missing"):
|
||||
verify_approval_authority(authority, record)
|
||||
|
||||
|
||||
def test_approval_authority_rejects_untrusted_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
authority_root = tmp_path / "authority-root"
|
||||
authority_root.mkdir()
|
||||
monkeypatch.setenv("AIOS_GITOPS_APPROVAL_AUTHORITY_ROOT", str(authority_root))
|
||||
authority = tmp_path / "approval.json"
|
||||
_write_json(authority, {"schema": "aios.v2r2c1.tag-approval-request.v1"})
|
||||
with pytest.raises(RegistryConvergenceError, match="untrusted_path"):
|
||||
verify_approval_authority(authority, _record())
|
||||
|
||||
|
||||
def test_approval_authority_rejects_revoked_receipt(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
authority_root = tmp_path / "authority-root"
|
||||
authority_root.mkdir()
|
||||
monkeypatch.setenv("AIOS_GITOPS_APPROVAL_AUTHORITY_ROOT", str(authority_root))
|
||||
record = _record()
|
||||
authority = _canonical_approval(authority_root, record)
|
||||
value = json.loads(authority.read_text(encoding="utf-8"))
|
||||
value["revoked"] = True
|
||||
authority.write_text(json.dumps(value) + "\n", encoding="utf-8")
|
||||
authority.chmod(0o640)
|
||||
with pytest.raises(RegistryConvergenceError, match="revoked"):
|
||||
verify_approval_authority(authority, record)
|
||||
|
||||
|
||||
def test_baseline_apply_wires_convergence_to_caller_repository():
|
||||
source = inspect.getsource(adapter.baseline_apply)
|
||||
assert "assert_source_registry_convergence(" in source
|
||||
assert "repo_git_dir=args.repository" in source
|
||||
# The gate must execute before the dict that writes final production status.
|
||||
assert source.index("assert_source_registry_convergence(") < source.index(
|
||||
'"status": "PRODUCTION_DEPLOYED"'
|
||||
)
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"approval_plan_id": "sha256:c0df52008ae7f64cec5ee2d28947aa7b892066c670ab8295e4549140b1a7500e",
|
||||
"artifact_sha256": "ab4ae257346c82863f05abf5e309381a1c86612957608c3a4f4ab5cc9233f052",
|
||||
"deployment_state": "ACTIVE",
|
||||
"enabled": true,
|
||||
"evidence_closure_oid": "a8b906d1bc24088c19647e98de6a679268f6d0a2",
|
||||
"peeled_target_oid": "998d30304787e31e5ae607c0bd86b5edb2b0e5ad",
|
||||
"production_deployed": true,
|
||||
"production_port": 18927,
|
||||
"production_service": "aios-v2r1c7-production.service",
|
||||
"registered": true,
|
||||
"repository": "aios/aios",
|
||||
"schema": "aios.production-registry.v2",
|
||||
"status": "PRODUCTION_DEPLOYED",
|
||||
"tag": "V2R2C1",
|
||||
"tag_object": "07fc2746b688f5f243f29b39de39f32edc3dd9d2",
|
||||
"tag_oid": "07fc2746b688f5f243f29b39de39f32edc3dd9d2",
|
||||
"target_oid": "998d30304787e31e5ae607c0bd86b5edb2b0e5ad",
|
||||
"updated_at": "2026-08-09T09:13:13Z",
|
||||
"version": "V2R2C1"
|
||||
}
|
||||
Reference in New Issue
Block a user