Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d332fd8818 |
@@ -1,315 +0,0 @@
|
||||
#!/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()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,870 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Durable online task protocol for external AIOS agents.
|
||||
|
||||
The store is deliberately independent from Git/Gitea mutation. It provides a
|
||||
small durable state machine for publishing work, atomic claim/lease ownership,
|
||||
heartbeat, idempotent result submission, explicit server-side verification,
|
||||
lease recovery, and audit events.
|
||||
|
||||
No plaintext agent credential or task lease token is persisted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Iterable, Mapping
|
||||
import uuid
|
||||
|
||||
from aios_capability_contract import has_capabilities
|
||||
from aios_online_agent_execution import (
|
||||
ExecutionProjectionError,
|
||||
validate_execution_envelope,
|
||||
)
|
||||
|
||||
|
||||
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"})
|
||||
|
||||
|
||||
GOVERNED_WRITE_CAPABILITIES = frozenset({
|
||||
"governed.write.branch",
|
||||
"governed.write.file",
|
||||
})
|
||||
EXECUTION_PROJECTION_SCHEMA = "aios.online-agent.task-projection.v1"
|
||||
|
||||
|
||||
def _validate_write_execution_binding(
|
||||
task_id: str,
|
||||
required_capabilities: list[str],
|
||||
metadata: Mapping[str, Any],
|
||||
) -> None:
|
||||
required = set(required_capabilities)
|
||||
if not required.intersection(GOVERNED_WRITE_CAPABILITIES):
|
||||
return
|
||||
execution = metadata.get("execution")
|
||||
projection = metadata.get("execution_projection")
|
||||
if not isinstance(execution, Mapping) or not isinstance(projection, Mapping):
|
||||
raise TaskProtocolError("GOVERNED_EXECUTION_BINDING_REQUIRED", 409)
|
||||
try:
|
||||
canonical = validate_execution_envelope(dict(execution))
|
||||
except (ExecutionProjectionError, TypeError, ValueError, KeyError) as exc:
|
||||
raise TaskProtocolError("EXECUTION_ENVELOPE_INVALID", 409) from exc
|
||||
if canonical.get("task_id") != task_id:
|
||||
raise TaskProtocolError("TASK_BINDING_CONFLICT", 409)
|
||||
if set(canonical.get("capability_scope") or ()) != required:
|
||||
raise TaskProtocolError("EXECUTION_CAPABILITY_SCOPE_CONFLICT", 409)
|
||||
if (
|
||||
projection.get("schema") != EXECUTION_PROJECTION_SCHEMA
|
||||
or projection.get("task_id") != task_id
|
||||
or projection.get("job_id") != canonical.get("job_id")
|
||||
or projection.get("execution_digest") != canonical.get("execution_digest")
|
||||
):
|
||||
raise TaskProtocolError("EXECUTION_PROJECTION_INVALID", 409)
|
||||
|
||||
|
||||
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")
|
||||
_validate_write_execution_binding(task_id, required, metadata_value)
|
||||
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 = json.loads(agent["capabilities_json"])
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM tasks WHERE status='QUEUED' ORDER BY priority DESC,created_at ASC"
|
||||
).fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
required = json.loads(row["required_capabilities_json"])
|
||||
if has_capabilities(capabilities, required):
|
||||
result.append(self._row_task(row, include_result=False))
|
||||
if len(result) >= int(limit):
|
||||
break
|
||||
connection.commit()
|
||||
return result
|
||||
|
||||
def _agent_capabilities(self, connection: sqlite3.Connection, agent_id: str) -> set[str]:
|
||||
row = connection.execute(
|
||||
"SELECT capabilities_json FROM agents WHERE agent_id=? AND status='ACTIVE'",
|
||||
(agent_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("AGENT_NOT_FOUND", 404)
|
||||
return set(json.loads(row["capabilities_json"]))
|
||||
|
||||
def claim_task(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_seconds: int = 300,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(lease_seconds, int) or isinstance(lease_seconds, bool) or not 30 <= lease_seconds <= 3600:
|
||||
raise TaskProtocolError("LEASE_SECONDS_INVALID", 400)
|
||||
lease_token = "lease_" + secrets.token_urlsafe(32)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._expire_locked(connection)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["status"] != "QUEUED":
|
||||
raise TaskProtocolError("TASK_NOT_CLAIMABLE", 409)
|
||||
capabilities = self._agent_capabilities(connection, agent_id)
|
||||
required = json.loads(row["required_capabilities_json"])
|
||||
if not has_capabilities(capabilities, required):
|
||||
raise TaskProtocolError("AGENT_CAPABILITY_MISMATCH", 403)
|
||||
attempt = int(row["attempt"]) + 1
|
||||
if attempt > int(row["max_attempts"]):
|
||||
raise TaskProtocolError("TASK_ATTEMPTS_EXHAUSTED", 409)
|
||||
expires = self.now() + timedelta(seconds=lease_seconds)
|
||||
now = iso(self.now())
|
||||
updated = connection.execute(
|
||||
"UPDATE tasks SET status='CLAIMED',claimed_by=?,lease_token_hash=?,"
|
||||
"lease_expires_at=?,attempt=?,revision=revision+1,updated_at=? "
|
||||
"WHERE task_id=? AND status='QUEUED'",
|
||||
(
|
||||
agent_id,
|
||||
self._token_hash(lease_token),
|
||||
iso(expires),
|
||||
attempt,
|
||||
now,
|
||||
task_id,
|
||||
),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
raise TaskProtocolError("TASK_CLAIM_CONFLICT", 409)
|
||||
self._event(
|
||||
connection,
|
||||
"TASK_CLAIMED",
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
payload={"attempt": attempt, "lease_expires_at": iso(expires)},
|
||||
)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return {
|
||||
**self._row_task(row, include_result=False),
|
||||
"lease_token": lease_token,
|
||||
"lease_token_returned_once": True,
|
||||
}
|
||||
|
||||
def _validate_lease(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_token: str,
|
||||
allowed_states: frozenset[str] = ACTIVE_LEASE_STATES,
|
||||
) -> sqlite3.Row:
|
||||
self._expire_locked(connection)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["status"] not in allowed_states:
|
||||
raise TaskProtocolError("TASK_LEASE_NOT_ACTIVE", 409)
|
||||
if row["claimed_by"] != agent_id:
|
||||
raise TaskProtocolError("TASK_LEASE_OWNER_MISMATCH", 403)
|
||||
if not row["lease_token_hash"] or not hmac.compare_digest(
|
||||
row["lease_token_hash"], self._token_hash(lease_token)
|
||||
):
|
||||
raise TaskProtocolError("TASK_LEASE_TOKEN_INVALID", 403)
|
||||
expires = parse_time(row["lease_expires_at"])
|
||||
if expires is None or expires <= self.now():
|
||||
raise TaskProtocolError("TASK_LEASE_EXPIRED", 409)
|
||||
return row
|
||||
|
||||
def heartbeat(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_token: str,
|
||||
progress: Mapping[str, Any] | None = None,
|
||||
lease_seconds: int = 300,
|
||||
) -> dict[str, Any]:
|
||||
if not 30 <= int(lease_seconds) <= 3600:
|
||||
raise TaskProtocolError("LEASE_SECONDS_INVALID", 400)
|
||||
progress_value = _json_object(progress, "progress", max_bytes=64 * 1024)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._validate_lease(
|
||||
connection,
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
lease_token=lease_token,
|
||||
)
|
||||
expires = self.now() + timedelta(seconds=int(lease_seconds))
|
||||
now = iso(self.now())
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status='RUNNING',lease_expires_at=?,revision=revision+1,"
|
||||
"updated_at=? WHERE task_id=?",
|
||||
(iso(expires), now, task_id),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"TASK_HEARTBEAT",
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
payload={"progress": progress_value, "lease_expires_at": iso(expires)},
|
||||
)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return self._row_task(row, include_result=False)
|
||||
|
||||
def submit_result(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_token: str,
|
||||
idempotency_key: str,
|
||||
result: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
idempotency_key = idempotency_key.strip()
|
||||
if not idempotency_key or len(idempotency_key) > 160:
|
||||
raise TaskProtocolError("IDEMPOTENCY_KEY_INVALID", 400)
|
||||
result_value = _json_object(result, "result", max_bytes=1024 * 1024)
|
||||
result_digest = digest_json(result_value)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["result_idempotency_key"] == idempotency_key:
|
||||
if not row["result_digest"] or not hmac.compare_digest(row["result_digest"], result_digest):
|
||||
raise TaskProtocolError("IDEMPOTENCY_CONFLICT", 409)
|
||||
connection.commit()
|
||||
return {**self._row_task(row), "idempotent": True}
|
||||
self._validate_lease(
|
||||
connection,
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
lease_token=lease_token,
|
||||
)
|
||||
now = iso(self.now())
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status='VERIFYING',result_json=?,result_digest=?,"
|
||||
"result_idempotency_key=?,lease_token_hash=NULL,lease_expires_at=NULL,"
|
||||
"revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(
|
||||
canonical_json(result_value),
|
||||
result_digest,
|
||||
idempotency_key,
|
||||
now,
|
||||
task_id,
|
||||
),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"RESULT_SUBMITTED",
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
payload={"result_digest": result_digest, "idempotency_key": idempotency_key},
|
||||
)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return self._row_task(row)
|
||||
|
||||
def verify_task(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
decision: str,
|
||||
verification: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
decision = decision.strip().upper()
|
||||
if decision not in VERIFY_DECISIONS:
|
||||
raise TaskProtocolError("VERIFICATION_DECISION_INVALID", 400)
|
||||
verification_value = _json_object(verification, "verification")
|
||||
verification_value = {"decision": decision, **verification_value}
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["status"] != "VERIFYING":
|
||||
raise TaskProtocolError("TASK_NOT_VERIFYING", 409)
|
||||
if decision == "SUCCEEDED":
|
||||
next_state = "SUCCEEDED"
|
||||
elif decision == "FAILED":
|
||||
next_state = "FAILED"
|
||||
elif int(row["attempt"]) >= int(row["max_attempts"]):
|
||||
next_state = "FAILED"
|
||||
verification_value["reason"] = verification_value.get(
|
||||
"reason", "MAX_ATTEMPTS_EXHAUSTED"
|
||||
)
|
||||
else:
|
||||
next_state = "QUEUED"
|
||||
now = iso(self.now())
|
||||
if next_state in {"SUCCEEDED", "FAILED"}:
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status=?,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)
|
||||
]
|
||||
@@ -1,655 +0,0 @@
|
||||
"""CAS-bound projection from an unprivileged planner to a host coordinator."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hmac
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Mapping, Sequence
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from aios_agent_task_initial_execution_projection import (
|
||||
InitialExecutionProjectionError,
|
||||
InitialExecutionProjectionStore,
|
||||
JOB_RE,
|
||||
KEY_RE,
|
||||
MAX_BODY,
|
||||
SHA_RE,
|
||||
TASK_RE,
|
||||
_canonical,
|
||||
_now,
|
||||
_required_authorization_id,
|
||||
_require_string,
|
||||
_sha,
|
||||
load_control_token,
|
||||
)
|
||||
from aios_online_agent_execution import (
|
||||
GovernedExecutionBinding,
|
||||
validate_execution_envelope,
|
||||
)
|
||||
|
||||
|
||||
RUNTIME_CAPABILITY = "agent.runtime.openclaw"
|
||||
COORDINATOR_CAPABILITY = "engineering.autonomous"
|
||||
COORDINATOR_CAPABILITIES = [RUNTIME_CAPABILITY, COORDINATOR_CAPABILITY]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SupervisorCoordinatorProjectionRequest:
|
||||
expected_status: str
|
||||
expected_revision: int
|
||||
expected_attempt: int
|
||||
expected_metadata_sha256: str
|
||||
expected_required_capabilities_sha256: str
|
||||
previous_verification_sha256: str
|
||||
expected_planner_result_sha256: str
|
||||
expected_old_execution_digest: str
|
||||
expected_new_execution_digest: str
|
||||
expected_job_id: str
|
||||
authorization_id: str
|
||||
idempotency_key: str
|
||||
reason: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(
|
||||
cls, value: Mapping[str, Any]
|
||||
) -> "SupervisorCoordinatorProjectionRequest":
|
||||
allowed = {
|
||||
"expected_status",
|
||||
"expected_revision",
|
||||
"expected_attempt",
|
||||
"expected_metadata_sha256",
|
||||
"expected_required_capabilities_sha256",
|
||||
"previous_verification_sha256",
|
||||
"expected_planner_result_sha256",
|
||||
"expected_old_execution_digest",
|
||||
"expected_new_execution_digest",
|
||||
"expected_job_id",
|
||||
"authorization_id",
|
||||
"idempotency_key",
|
||||
"reason",
|
||||
}
|
||||
if set(value) != allowed:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERVISOR_COORDINATOR_PROJECTION_FIELDS_INVALID", 400
|
||||
)
|
||||
status = _require_string(value["expected_status"], "EXPECTED_STATUS_INVALID")
|
||||
revision = value["expected_revision"]
|
||||
attempt = value["expected_attempt"]
|
||||
if (
|
||||
status != "QUEUED"
|
||||
or not isinstance(revision, int)
|
||||
or isinstance(revision, bool)
|
||||
or revision < 2
|
||||
or not isinstance(attempt, int)
|
||||
or isinstance(attempt, bool)
|
||||
or attempt < 1
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERVISOR_COORDINATOR_PROJECTION_STATE_INVALID", 400
|
||||
)
|
||||
names = (
|
||||
"expected_metadata_sha256",
|
||||
"expected_required_capabilities_sha256",
|
||||
"previous_verification_sha256",
|
||||
"expected_planner_result_sha256",
|
||||
"expected_old_execution_digest",
|
||||
"expected_new_execution_digest",
|
||||
)
|
||||
digests = {
|
||||
name: _require_string(value[name], name.upper() + "_INVALID", SHA_RE)
|
||||
for name in names
|
||||
}
|
||||
job_id = _require_string(value["expected_job_id"], "JOB_ID_INVALID", JOB_RE)
|
||||
authorization_id = _require_string(
|
||||
value["authorization_id"], "AUTHORIZATION_ID_INVALID", KEY_RE
|
||||
)
|
||||
idempotency_key = _require_string(
|
||||
value["idempotency_key"], "IDEMPOTENCY_KEY_INVALID", KEY_RE
|
||||
)
|
||||
reason = _require_string(value["reason"], "PROJECTION_REASON_INVALID")
|
||||
if len(reason) > 512:
|
||||
raise InitialExecutionProjectionError("PROJECTION_REASON_INVALID", 400)
|
||||
return cls(
|
||||
status,
|
||||
revision,
|
||||
attempt,
|
||||
digests["expected_metadata_sha256"],
|
||||
digests["expected_required_capabilities_sha256"],
|
||||
digests["previous_verification_sha256"],
|
||||
digests["expected_planner_result_sha256"],
|
||||
digests["expected_old_execution_digest"],
|
||||
digests["expected_new_execution_digest"],
|
||||
job_id,
|
||||
authorization_id,
|
||||
idempotency_key,
|
||||
reason,
|
||||
)
|
||||
|
||||
def public(self) -> dict[str, Any]:
|
||||
return {
|
||||
"expected_status": self.expected_status,
|
||||
"expected_revision": self.expected_revision,
|
||||
"expected_attempt": self.expected_attempt,
|
||||
"expected_metadata_sha256": self.expected_metadata_sha256,
|
||||
"expected_required_capabilities_sha256": self.expected_required_capabilities_sha256,
|
||||
"previous_verification_sha256": self.previous_verification_sha256,
|
||||
"expected_planner_result_sha256": self.expected_planner_result_sha256,
|
||||
"expected_old_execution_digest": self.expected_old_execution_digest,
|
||||
"expected_new_execution_digest": self.expected_new_execution_digest,
|
||||
"expected_job_id": self.expected_job_id,
|
||||
"authorization_id": self.authorization_id,
|
||||
"idempotency_key": self.idempotency_key,
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
|
||||
class CanonicalCoordinatorExecutionResolver:
|
||||
def __init__(self, base_url: str, control_token: str) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.control_token = control_token
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
task_id: str,
|
||||
expected_job_id: str,
|
||||
runtime_input: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
body = {
|
||||
"task_id": task_id,
|
||||
"job_id": expected_job_id,
|
||||
"required_capabilities": COORDINATOR_CAPABILITIES,
|
||||
"execution": {
|
||||
"action": "agent_runtime",
|
||||
"adapter": "openclaw",
|
||||
"capability_scope": COORDINATOR_CAPABILITIES,
|
||||
"runtime_input": dict(runtime_input),
|
||||
},
|
||||
}
|
||||
request = urllib.request.Request(
|
||||
self.base_url + "/v2/execution-envelope",
|
||||
method="POST",
|
||||
data=_canonical(body),
|
||||
headers={
|
||||
"Authorization": "Bearer " + self.control_token,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
raw, status = response.read(), response.status
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw, status = exc.read(), exc.code
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise InitialExecutionProjectionError(
|
||||
"CANONICAL_COORDINATOR_EXECUTION_RESPONSE_INVALID", 502
|
||||
) from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise InitialExecutionProjectionError(
|
||||
"CANONICAL_COORDINATOR_EXECUTION_RESPONSE_INVALID", 502
|
||||
)
|
||||
if status != 200:
|
||||
raise InitialExecutionProjectionError(
|
||||
str(value.get("error") or "CANONICAL_COORDINATOR_EXECUTION_FAILED"),
|
||||
502 if status >= 500 else 409,
|
||||
)
|
||||
return dict(value)
|
||||
|
||||
|
||||
class SupervisorCoordinatorProjectionStore(InitialExecutionProjectionStore):
|
||||
def __init__(
|
||||
self,
|
||||
db_path: Path,
|
||||
resolver: Callable[[str, str, Mapping[str, Any]], Mapping[str, Any]],
|
||||
) -> None:
|
||||
super().__init__(db_path, resolver) # type: ignore[arg-type]
|
||||
self.coordinator_resolver = resolver
|
||||
|
||||
@staticmethod
|
||||
def _ensure_table(connection: sqlite3.Connection) -> None:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_supervisor_coordinator_projections (
|
||||
task_id TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL,
|
||||
request_sha256 TEXT NOT NULL,
|
||||
authorization_id TEXT NOT NULL,
|
||||
expected_job_id TEXT NOT NULL,
|
||||
planner_result_sha256 TEXT NOT NULL,
|
||||
old_execution_digest TEXT NOT NULL,
|
||||
new_execution_digest TEXT NOT NULL,
|
||||
expected_revision INTEGER NOT NULL,
|
||||
applied_revision INTEGER NOT NULL,
|
||||
attempt INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(task_id,idempotency_key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _planner_event_chain(
|
||||
cls, connection: sqlite3.Connection, task_id: str
|
||||
) -> None:
|
||||
table, columns = cls._event_table(connection)
|
||||
ordering = "event_id" if "event_id" in columns else "rowid"
|
||||
rows = connection.execute(
|
||||
f'SELECT event_type,payload_json FROM "{table}" '
|
||||
f'WHERE task_id=? ORDER BY {ordering}',
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
events = [str(row[0]) for row in rows]
|
||||
try:
|
||||
projection = len(events) - 1 - events[::-1].index(
|
||||
"TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZED"
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERVISOR_PLANNER_PROJECTION_EVENT_MISSING", 409
|
||||
) from exc
|
||||
tail = events[projection + 1 :]
|
||||
if (
|
||||
len(tail) < 4
|
||||
or tail[0] != "TASK_CLAIMED"
|
||||
or tail.count("TASK_CLAIMED") != 1
|
||||
or tail.count("RESULT_SUBMITTED") != 1
|
||||
or tail.count("TASK_VERIFIED") != 1
|
||||
or tail[-1] != "TASK_VERIFIED"
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERVISOR_PLANNER_EVENT_CHAIN_INVALID", 409
|
||||
)
|
||||
result = tail.index("RESULT_SUBMITTED")
|
||||
if (
|
||||
not any(event == "TASK_HEARTBEAT" for event in tail[1:result])
|
||||
or any(
|
||||
event not in {
|
||||
"TASK_CLAIMED",
|
||||
"TASK_HEARTBEAT",
|
||||
"RESULT_SUBMITTED",
|
||||
"TASK_VERIFIED",
|
||||
}
|
||||
for event in tail
|
||||
)
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERVISOR_PLANNER_EVENT_CHAIN_INVALID", 409
|
||||
)
|
||||
verification_event = json.loads(rows[-1][1])
|
||||
if (
|
||||
verification_event.get("decision") != "NEEDS_REWORK"
|
||||
or verification_event.get("next_state") != "QUEUED"
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERVISOR_PLANNER_NEEDS_REWORK_EVENT_REQUIRED", 409
|
||||
)
|
||||
|
||||
def _load(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
task_id: str,
|
||||
request: SupervisorCoordinatorProjectionRequest,
|
||||
) -> tuple[sqlite3.Row, dict[str, Any], dict[str, Any]]:
|
||||
task = connection.execute(
|
||||
"SELECT * FROM tasks WHERE task_id=?", (task_id,)
|
||||
).fetchone()
|
||||
if task is None:
|
||||
raise InitialExecutionProjectionError("TASK_NOT_FOUND", 404)
|
||||
if (
|
||||
task["status"] != request.expected_status
|
||||
or int(task["revision"]) != request.expected_revision
|
||||
or int(task["attempt"]) != request.expected_attempt
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERVISOR_COORDINATOR_PROJECTION_CAS_MISMATCH", 409
|
||||
)
|
||||
if (
|
||||
task["claimed_by"] not in (None, "")
|
||||
or task["lease_token_hash"] not in (None, "")
|
||||
or task["lease_expires_at"] not in (None, "")
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERVISOR_COORDINATOR_ACTIVE_LEASE_DENY", 409
|
||||
)
|
||||
metadata = self._json_value(task["metadata_json"], "TASK_METADATA_INVALID")
|
||||
capabilities = self._json_value(
|
||||
task["required_capabilities_json"], "TASK_CAPABILITIES_INVALID"
|
||||
)
|
||||
result = self._json_value(task["result_json"], "PLANNER_RESULT_INVALID")
|
||||
verification = self._json_value(
|
||||
task["verification_json"], "PLANNER_VERIFICATION_INVALID"
|
||||
)
|
||||
if (
|
||||
not isinstance(metadata, Mapping)
|
||||
or not isinstance(capabilities, list)
|
||||
or not isinstance(result, Mapping)
|
||||
or not isinstance(verification, Mapping)
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERVISOR_COORDINATOR_STATE_INVALID", 409
|
||||
)
|
||||
metadata, result, verification = dict(metadata), dict(result), dict(verification)
|
||||
if _sha(metadata) != request.expected_metadata_sha256:
|
||||
raise InitialExecutionProjectionError("TASK_METADATA_DRIFT", 409)
|
||||
if _sha(capabilities) != request.expected_required_capabilities_sha256:
|
||||
raise InitialExecutionProjectionError("TASK_CAPABILITIES_DRIFT", 409)
|
||||
if _sha(result) != request.expected_planner_result_sha256:
|
||||
raise InitialExecutionProjectionError("PLANNER_RESULT_DRIFT", 409)
|
||||
if _sha(verification) != request.previous_verification_sha256:
|
||||
raise InitialExecutionProjectionError("PLANNER_VERIFICATION_DRIFT", 409)
|
||||
old = metadata.get("execution")
|
||||
if not isinstance(old, Mapping):
|
||||
raise InitialExecutionProjectionError("TASK_EXECUTION_METADATA_MISSING", 409)
|
||||
old_execution = validate_execution_envelope(dict(old))
|
||||
if (
|
||||
old_execution.get("action") != "agent_runtime"
|
||||
or old_execution.get("adapter") != "openclaw"
|
||||
or old_execution.get("capability_scope") != [RUNTIME_CAPABILITY]
|
||||
or old_execution.get("execution_digest")
|
||||
!= request.expected_old_execution_digest
|
||||
or capabilities != [RUNTIME_CAPABILITY]
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERVISOR_PLANNER_EXECUTION_CONTRACT_INVALID", 409
|
||||
)
|
||||
expected_result = {
|
||||
"task_id": task_id,
|
||||
"job_id": request.expected_job_id,
|
||||
"status": "SUCCEEDED",
|
||||
"execution_mode": "agent_runtime",
|
||||
"agent_runtime": "openclaw",
|
||||
"execution_digest": request.expected_old_execution_digest,
|
||||
"control_plane_access_granted_to_runtime": False,
|
||||
"credential_material_exposed": False,
|
||||
"secret_material_exposed": False,
|
||||
"changed_files": [],
|
||||
"writer_lease": "NONE",
|
||||
"direct_main_write": False,
|
||||
"production_write": False,
|
||||
"force_push": False,
|
||||
"protected_ref_bypass": False,
|
||||
}
|
||||
if any(result.get(name) != expected for name, expected in expected_result.items()):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERVISOR_PLANNER_RESULT_CONTRACT_INVALID", 409
|
||||
)
|
||||
if verification.get("decision") != "NEEDS_REWORK":
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERVISOR_PLANNER_NEEDS_REWORK_REQUIRED", 409
|
||||
)
|
||||
self._planner_event_chain(connection, task_id)
|
||||
return task, metadata, result
|
||||
|
||||
def project(
|
||||
self,
|
||||
task_id: str,
|
||||
request: SupervisorCoordinatorProjectionRequest,
|
||||
) -> dict[str, Any]:
|
||||
task_id = _require_string(task_id, "TASK_ID_INVALID", TASK_RE)
|
||||
if request.authorization_id != _required_authorization_id():
|
||||
raise InitialExecutionProjectionError("AUTHORIZATION_ID_MISMATCH", 403)
|
||||
request_sha = _sha(request.public())
|
||||
with self._connect() as connection:
|
||||
self._ensure_table(connection)
|
||||
replay = connection.execute(
|
||||
"SELECT * FROM task_supervisor_coordinator_projections "
|
||||
"WHERE task_id=? AND idempotency_key=?",
|
||||
(task_id, request.idempotency_key),
|
||||
).fetchone()
|
||||
if replay is not None:
|
||||
if replay["request_sha256"] != request_sha:
|
||||
raise InitialExecutionProjectionError("IDEMPOTENCY_CONFLICT", 409)
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "QUEUED",
|
||||
"attempt": replay["attempt"],
|
||||
"revision": replay["applied_revision"],
|
||||
"execution_digest": replay["new_execution_digest"],
|
||||
"required_capabilities": COORDINATOR_CAPABILITIES,
|
||||
"idempotent": True,
|
||||
}
|
||||
with self._connect() as connection:
|
||||
task, _, result = self._load(connection, task_id, request)
|
||||
runtime_input = self._runtime_input(task)
|
||||
resolved = dict(
|
||||
self.coordinator_resolver(task_id, request.expected_job_id, runtime_input)
|
||||
)
|
||||
binding = GovernedExecutionBinding.from_mapping(resolved)
|
||||
if binding.task_id != task_id or binding.job_id != request.expected_job_id:
|
||||
raise InitialExecutionProjectionError("GOVERNED_BINDING_CONFLICT", 409)
|
||||
result_binding = {
|
||||
name: result.get(name)
|
||||
for name in (
|
||||
"task_id",
|
||||
"job_id",
|
||||
"repository",
|
||||
"base_oid",
|
||||
"expected_branch_oid",
|
||||
"target_branch",
|
||||
"generation",
|
||||
)
|
||||
}
|
||||
expected_binding = {
|
||||
"task_id": binding.task_id,
|
||||
"job_id": binding.job_id,
|
||||
"repository": binding.repository,
|
||||
"base_oid": binding.base_oid,
|
||||
"expected_branch_oid": binding.expected_branch_oid,
|
||||
"target_branch": binding.target_branch,
|
||||
"generation": binding.generation,
|
||||
}
|
||||
if result_binding != expected_binding:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERVISOR_PLANNER_BINDING_CONFLICT", 409
|
||||
)
|
||||
execution = validate_execution_envelope(resolved, binding=binding)
|
||||
if (
|
||||
execution.get("action") != "agent_runtime"
|
||||
or execution.get("adapter") != "openclaw"
|
||||
or execution.get("capability_scope") != COORDINATOR_CAPABILITIES
|
||||
or execution.get("change_set") != []
|
||||
or execution.get("verification") != []
|
||||
or execution.get("governance", {}).get("writer_lease") is not False
|
||||
or execution.get("execution_digest") != request.expected_new_execution_digest
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERVISOR_COORDINATOR_ENVELOPE_INVALID", 409
|
||||
)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._ensure_table(connection)
|
||||
task, metadata, _ = self._load(connection, task_id, request)
|
||||
new_metadata = dict(metadata)
|
||||
new_metadata["execution"] = execution
|
||||
new_metadata["execution_projection"] = {
|
||||
"schema": "aios.online-agent.task-projection.v1",
|
||||
"task_id": task_id,
|
||||
"job_id": request.expected_job_id,
|
||||
"execution_digest": execution["execution_digest"],
|
||||
}
|
||||
applied_revision = request.expected_revision + 1
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
UPDATE tasks SET metadata_json=?,required_capabilities_json=?,
|
||||
revision=?,updated_at=?
|
||||
WHERE task_id=? AND status=? AND revision=? AND attempt=?
|
||||
AND claimed_by IS NULL AND lease_token_hash IS NULL
|
||||
AND lease_expires_at IS NULL AND metadata_json=?
|
||||
AND required_capabilities_json=? AND result_json=?
|
||||
AND verification_json=?
|
||||
""",
|
||||
(
|
||||
json.dumps(new_metadata, sort_keys=True, separators=(",", ":")),
|
||||
json.dumps(COORDINATOR_CAPABILITIES, separators=(",", ":")),
|
||||
applied_revision,
|
||||
_now(),
|
||||
task_id,
|
||||
request.expected_status,
|
||||
request.expected_revision,
|
||||
request.expected_attempt,
|
||||
task["metadata_json"],
|
||||
task["required_capabilities_json"],
|
||||
task["result_json"],
|
||||
task["verification_json"],
|
||||
),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERVISOR_COORDINATOR_PROJECTION_CAS_MISMATCH", 409
|
||||
)
|
||||
event = {
|
||||
"schema": "aios.supervisor-coordinator-projection.event.v1",
|
||||
"task_id": task_id,
|
||||
"job_id": request.expected_job_id,
|
||||
"authorization_id": request.authorization_id,
|
||||
"idempotency_key": request.idempotency_key,
|
||||
"reason": request.reason,
|
||||
"old_execution_digest": request.expected_old_execution_digest,
|
||||
"new_execution_digest": execution["execution_digest"],
|
||||
"required_capabilities": COORDINATOR_CAPABILITIES,
|
||||
"previous_revision": request.expected_revision,
|
||||
"applied_revision": applied_revision,
|
||||
"attempt": request.expected_attempt,
|
||||
"request_sha256": request_sha,
|
||||
"credential_forwarded_to_planner": False,
|
||||
"writer_lease": False,
|
||||
"main_write": False,
|
||||
"production_write": False,
|
||||
}
|
||||
table, columns = self._event_table(connection)
|
||||
values = {
|
||||
"task_id": task_id,
|
||||
"event_type": "TASK_SUPERVISOR_COORDINATOR_PROJECTION_AUTHORIZED",
|
||||
"payload_json": json.dumps(event, sort_keys=True, separators=(",", ":")),
|
||||
"created_at": _now(),
|
||||
"agent_id": "owner-control",
|
||||
}
|
||||
names = [name for name in values if name in columns]
|
||||
connection.execute(
|
||||
f'INSERT INTO "{table}" ({",".join(chr(34)+name+chr(34) for name in names)}) '
|
||||
f'VALUES ({",".join("?" for _ in names)})',
|
||||
tuple(values[name] for name in names),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO task_supervisor_coordinator_projections(
|
||||
task_id,idempotency_key,request_sha256,authorization_id,
|
||||
expected_job_id,planner_result_sha256,old_execution_digest,
|
||||
new_execution_digest,expected_revision,applied_revision,
|
||||
attempt,created_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
task_id,
|
||||
request.idempotency_key,
|
||||
request_sha,
|
||||
request.authorization_id,
|
||||
request.expected_job_id,
|
||||
request.expected_planner_result_sha256,
|
||||
request.expected_old_execution_digest,
|
||||
execution["execution_digest"],
|
||||
request.expected_revision,
|
||||
applied_revision,
|
||||
request.expected_attempt,
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "QUEUED",
|
||||
"attempt": request.expected_attempt,
|
||||
"revision": applied_revision,
|
||||
"execution_digest": execution["execution_digest"],
|
||||
"required_capabilities": COORDINATOR_CAPABILITIES,
|
||||
"credential_forwarded_to_planner": False,
|
||||
"idempotent": False,
|
||||
}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
store: SupervisorCoordinatorProjectionStore | None = None
|
||||
control_token: str = ""
|
||||
|
||||
def log_message(self, *_args: Any) -> None:
|
||||
return
|
||||
|
||||
def send_json(self, value: Mapping[str, Any], status: int) -> None:
|
||||
raw = _canonical(dict(value))
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
if self.path == "/health":
|
||||
self.send_json(
|
||||
{"status": "ok", "service": "aios-supervisor-coordinator-projection"},
|
||||
200,
|
||||
)
|
||||
else:
|
||||
self.send_json({"error": "NOT_FOUND"}, 404)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
match = re.fullmatch(
|
||||
r"/v1/tasks/([^/]+)/supervisor-coordinator-projection", self.path
|
||||
)
|
||||
header = self.headers.get("Authorization", "")
|
||||
if match is None:
|
||||
self.send_json({"error": "NOT_FOUND"}, 404)
|
||||
return
|
||||
if (
|
||||
not header.startswith("Bearer ")
|
||||
or not hmac.compare_digest(header[7:], self.control_token)
|
||||
):
|
||||
self.send_json({"error": "UNAUTHORIZED"}, 401)
|
||||
return
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if length < 1 or length > MAX_BODY:
|
||||
raise InitialExecutionProjectionError("REQUEST_SIZE_INVALID", 413)
|
||||
value = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
if not isinstance(value, Mapping) or self.store is None:
|
||||
raise InitialExecutionProjectionError("REQUEST_INVALID", 400)
|
||||
request = SupervisorCoordinatorProjectionRequest.from_mapping(value)
|
||||
result = self.store.project(match.group(1), request)
|
||||
self.send_json(result, 200 if result["idempotent"] else 201)
|
||||
except InitialExecutionProjectionError as exc:
|
||||
self.send_json({"error": exc.code}, exc.status)
|
||||
except (ValueError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
self.send_json({"error": "REQUEST_INVALID"}, 400)
|
||||
|
||||
|
||||
def serve() -> None:
|
||||
credential = Path(
|
||||
os.environ.get(
|
||||
"AIOS_AGENT_TASK_CONTROL_TOKEN_FILE",
|
||||
str(
|
||||
Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials"))
|
||||
/ "agent_task_control_token"
|
||||
),
|
||||
)
|
||||
)
|
||||
token = load_control_token(credential)
|
||||
bridge = os.environ.get("AIOS_GOVERNED_WRITE_API_URL", "http://127.0.0.1:18892")
|
||||
db = Path(os.environ.get("AIOS_AGENT_TASK_DB", "/var/lib/aios-agent-task/state.sqlite3"))
|
||||
Handler.control_token = token
|
||||
Handler.store = SupervisorCoordinatorProjectionStore(
|
||||
db, CanonicalCoordinatorExecutionResolver(bridge, token)
|
||||
)
|
||||
host = os.environ.get("AIOS_SUPERVISOR_COORDINATOR_PROJECTION_HOST", "127.0.0.1")
|
||||
port = int(os.environ.get("AIOS_SUPERVISOR_COORDINATOR_PROJECTION_PORT", "18944"))
|
||||
ThreadingHTTPServer((host, port), Handler).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve()
|
||||
@@ -1,142 +0,0 @@
|
||||
"""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"
|
||||
GOVERNED_TASK_SCOPE = "governed-task:project"
|
||||
PROJECTED_SCOPES = frozenset({GOVERNED_TASK_SCOPE, SCOPE})
|
||||
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 not in PROJECTED_SCOPES:
|
||||
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)
|
||||
effective_scopes = value.get("effective_scopes")
|
||||
if (
|
||||
not isinstance(effective_scopes, list)
|
||||
or len(effective_scopes) != len(set(effective_scopes))
|
||||
or not all(isinstance(item, str) for item in effective_scopes)
|
||||
or scope not in effective_scopes
|
||||
or not set(effective_scopes).issubset(PROJECTED_SCOPES)
|
||||
):
|
||||
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)
|
||||
@@ -39,7 +39,6 @@ _TASK_ALIASES: dict[str, frozenset[str]] = {
|
||||
"gpu.train": frozenset({"legacy.gpu.train"}),
|
||||
"agent.runtime.hermes": frozenset({"agent.runtime.hermes"}),
|
||||
"agent.runtime.openclaw": frozenset({"agent.runtime.openclaw"}),
|
||||
"engineering.autonomous": frozenset({"engineering.autonomous"}),
|
||||
"release:candidate": frozenset({"release:candidate"}),
|
||||
}
|
||||
|
||||
@@ -75,7 +74,6 @@ _WORKER_ALIASES: dict[str, frozenset[str]] = {
|
||||
"gpu.train": frozenset({"legacy.gpu.train"}),
|
||||
"agent.runtime.hermes": frozenset({"agent.runtime.hermes"}),
|
||||
"agent.runtime.openclaw": frozenset({"agent.runtime.openclaw"}),
|
||||
"engineering.autonomous": frozenset({"engineering.autonomous"}),
|
||||
"release:candidate": frozenset({"release:candidate"}),
|
||||
}
|
||||
|
||||
@@ -156,11 +154,6 @@ _CAPABILITY_CONTRACTS: dict[str, CapabilityContract] = {
|
||||
dependencies=("openclaw_runtime",),
|
||||
health_requirements=("worker_liveness",),
|
||||
),
|
||||
"engineering.autonomous": CapabilityContract(
|
||||
"engineering.autonomous",
|
||||
dependencies=("agent_task_control_plane",),
|
||||
health_requirements=("worker_liveness",),
|
||||
),
|
||||
"release:candidate": CapabilityContract(
|
||||
"release:candidate",
|
||||
min_version=1,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""Loopback-only governed Bridge v2 HTTP gateway with durable handlers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import base64
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
@@ -13,8 +12,6 @@ import re
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import Any, Mapping
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from aios_authority_git_executor import AuthorityGitExecutor
|
||||
from aios_chatgpt_bridge_v2 import BridgeV2Adapter, BridgeV2Error, OPERATION_REQUIRED_FIELDS
|
||||
@@ -22,9 +19,7 @@ from aios_continuation_executor import AuthorityGitAdapter, ContinuationCommit,
|
||||
from aios_continuation_lease import WriterLeaseRequestV2
|
||||
from aios_control_plane_snapshot import build_control_plane_snapshot
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobError, JobState, JobStore
|
||||
from aios_governed_task_bootstrap import BootstrapError, GovernedTaskBinding, GovernedTaskBootstrap, _legacy_safe, _task_key
|
||||
from aios_online_agent_execution import ExecutionProjectionError, OnlineTaskProjectionClient
|
||||
from aios_online_agent_plan_store import ExecutionPlanError, ExecutionPlanStore
|
||||
from aios_governed_task_bootstrap import BootstrapError, GovernedTaskBootstrap
|
||||
from governed_production_release import build_executor_config
|
||||
|
||||
MAX_BODY = 2 * 1024 * 1024
|
||||
@@ -32,8 +27,6 @@ ROUTES = {
|
||||
"/v2/runtime": "getRuntimeStatus",
|
||||
"/v2/capabilities": "getGovernedWriteCapabilities",
|
||||
"/v2/governed-task": "createGovernedTask",
|
||||
"/v2/execution-envelope": "projectOnlineAgentExecution",
|
||||
"/v2/execution-plan": "registerOnlineAgentExecutionPlan",
|
||||
"/v2/writer-lease": "acquireWriterLease",
|
||||
"/v2/writer-lease/bootstrap": "bootstrapWriterLease",
|
||||
"/v2/writer-lease/renew": "renewWriterLease",
|
||||
@@ -69,9 +62,6 @@ class RuntimeConfig:
|
||||
registry_root: Path = Path("/var/lib/aios-gitops/runtime/registry-projection")
|
||||
baseline_path: Path = Path("/var/lib/aios-gitops-runtime-anchor/baseline/current.json")
|
||||
health_url: str = "http://127.0.0.1:18927/health"
|
||||
agent_task_api_url: str = "http://127.0.0.1:18940"
|
||||
agent_task_control_token_path: Path = Path("/run/credentials/aios-chatgpt-bridge-v2-staging.service/agent_task_control_token")
|
||||
online_task_projection_enabled: bool = False
|
||||
|
||||
|
||||
def _safe(value: str) -> str:
|
||||
@@ -84,16 +74,6 @@ class GovernedRuntime:
|
||||
config.state_root.mkdir(parents=True, exist_ok=True)
|
||||
self.authority = AuthorityGitExecutor(config.authoritative_git_dir, config.state_root / "worktrees")
|
||||
self.bootstrap = GovernedTaskBootstrap(self.authority, config.state_root, remote_name=config.remote_name, token_path=config.token_path, ssh_key_path=config.ssh_key_path, api_base=config.api_base)
|
||||
self.online_task_projector = (
|
||||
OnlineTaskProjectionClient(config.agent_task_api_url, config.agent_task_control_token_path)
|
||||
if config.online_task_projection_enabled
|
||||
else None
|
||||
)
|
||||
self.projection_outbox_root = config.state_root / "online-task-projection-outbox"
|
||||
self.projection_outbox_root.mkdir(parents=True, exist_ok=True)
|
||||
self.execution_plan_store = ExecutionPlanStore(config.state_root / "online-agent-execution-plans")
|
||||
self._projection_locks_guard = threading.Lock()
|
||||
self._projection_locks: dict[str, threading.RLock] = {}
|
||||
self.runtimes: dict[tuple[str, int], tuple[Any, GitOpsJobController, ContinuationExecutor]] = {}
|
||||
self.controllers: dict[str, GitOpsJobController] = {}
|
||||
self._recovery_lock = threading.RLock()
|
||||
@@ -113,54 +93,6 @@ class GovernedRuntime:
|
||||
askpass.write_text('#!/bin/sh\ncase "$1" in *Username*) printf "%s\\n" "$GITEA_MACHINE_ACTOR" ;; *) cat "$CREDENTIAL_FILE" ;; esac\n', encoding="utf-8")
|
||||
askpass.chmod(0o700)
|
||||
os.environ.setdefault("GIT_ASKPASS", str(askpass)); os.environ.setdefault("GIT_ASKPASS_REQUIRE", "force"); os.environ.setdefault("GIT_TERMINAL_PROMPT", "0"); os.environ.setdefault("GITEA_MACHINE_ACTOR", "aios-autonomy-agent")
|
||||
if self.online_task_projector is not None:
|
||||
threading.Thread(target=self._projection_reconciler, name="online-task-projection-reconciler", daemon=True).start()
|
||||
|
||||
def _projection_lock(self, task_id: str) -> threading.RLock:
|
||||
guard = getattr(self, "_projection_locks_guard", None)
|
||||
if guard is None:
|
||||
guard = threading.Lock()
|
||||
self._projection_locks_guard = guard
|
||||
self._projection_locks = {}
|
||||
with guard:
|
||||
return self._projection_locks.setdefault(task_id, threading.RLock())
|
||||
|
||||
def _projection_outbox_path(self, task_id: str) -> Path:
|
||||
return self.projection_outbox_root / (_task_key(task_id) + ".json")
|
||||
|
||||
def _write_projection_outbox(self, task_id: str, projection: Mapping[str, Any], binding: Any) -> Path:
|
||||
path = self._projection_outbox_path(task_id)
|
||||
temporary = path.with_name(path.name + f".{os.getpid()}.{threading.get_ident()}.tmp")
|
||||
temporary.write_text(json.dumps({"task": dict(projection), "binding": asdict(binding)}, sort_keys=True, separators=(",", ":")), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
return path
|
||||
|
||||
def _deliver_projection_outbox(self, path: Path) -> dict[str, Any]:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, Mapping) or not isinstance(value.get("task"), Mapping) or not isinstance(value.get("binding"), Mapping):
|
||||
raise RuntimeError("ONLINE_TASK_PROJECTION_OUTBOX_INVALID")
|
||||
binding = self.bootstrap.execution_binding(GovernedTaskBinding(**dict(value["binding"])))
|
||||
if self.online_task_projector is None:
|
||||
raise RuntimeError("ONLINE_TASK_PROJECTION_CONTROL_UNAVAILABLE")
|
||||
created = self.online_task_projector.create(dict(value["task"]), binding=binding)
|
||||
path.unlink(missing_ok=True)
|
||||
return created
|
||||
|
||||
def _projection_reconciler(self) -> None:
|
||||
while True:
|
||||
for path in sorted(self.projection_outbox_root.glob("*.json")):
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
binding = value.get("binding") if isinstance(value, Mapping) else None
|
||||
task_id = str(binding.get("task_id") or "") if isinstance(binding, Mapping) else ""
|
||||
if not task_id:
|
||||
continue
|
||||
with self._projection_lock(task_id):
|
||||
if path.exists():
|
||||
self._deliver_projection_outbox(path)
|
||||
except Exception:
|
||||
continue
|
||||
threading.Event().wait(5)
|
||||
|
||||
def runtime_status(self) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -181,10 +113,7 @@ class GovernedRuntime:
|
||||
}
|
||||
|
||||
def _job_root(self, task_id: str) -> Path:
|
||||
parent = self.config.state_root / "jobs"
|
||||
keyed = parent / _task_key(task_id)
|
||||
legacy = parent / _legacy_safe(task_id)
|
||||
return keyed if keyed.exists() or not legacy.exists() else legacy
|
||||
return self.config.state_root / "jobs" / _safe(task_id)
|
||||
|
||||
def _build_runtime(
|
||||
self,
|
||||
@@ -514,85 +443,7 @@ class GovernedRuntime:
|
||||
return result
|
||||
|
||||
def create_task(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
online_request = request.get("online_task")
|
||||
if online_request is not None and not isinstance(online_request, Mapping):
|
||||
raise ValueError("ONLINE_TASK_REQUEST_INVALID")
|
||||
execution_request = request.get("execution")
|
||||
if execution_request is not None and not isinstance(execution_request, Mapping):
|
||||
raise ValueError("EXECUTION_REQUEST_INVALID")
|
||||
online_task_projector = getattr(self, "online_task_projector", None)
|
||||
projection_required = (
|
||||
online_request is not None
|
||||
or execution_request is not None
|
||||
or request.get("require_online_task_projection") is True
|
||||
)
|
||||
if online_task_projector is None:
|
||||
if projection_required:
|
||||
raise RuntimeError("ONLINE_TASK_PROJECTION_CONTROL_UNAVAILABLE")
|
||||
return self.bootstrap.create(request)
|
||||
online = dict(online_request or {})
|
||||
online.setdefault("goal", str(request.get("description") or "Governed engineering task"))
|
||||
online.setdefault("instructions", "Execute only the exact governed binding and return server-verifiable evidence.")
|
||||
online.setdefault("success_criteria", [{"id": "governed", "description": "Complete the exact governed task lifecycle."}])
|
||||
online.setdefault("required_capabilities", ["git.read", "git.test", "governed.write.branch", "governed.write.file"])
|
||||
online.setdefault("priority", int(request.get("priority") or 0))
|
||||
online.setdefault("max_attempts", int(request.get("max_attempts") or 10))
|
||||
normalized = dict(request)
|
||||
normalized["online_task"] = online
|
||||
normalized["execution"] = dict(execution_request or {})
|
||||
normalized["require_online_task_projection"] = True
|
||||
result = self.bootstrap.create(normalized)
|
||||
self.bindings[result["task_id"]] = result
|
||||
projection = self.bootstrap.online_task_projection(
|
||||
result["task_id"],
|
||||
online,
|
||||
normalized["execution"],
|
||||
expected_job_id=str(result.get("job_id") or "") or None,
|
||||
)
|
||||
durable_binding = self.bootstrap.get_binding(result["task_id"])
|
||||
with self._projection_lock(result["task_id"]):
|
||||
path = self._write_projection_outbox(result["task_id"], projection, durable_binding)
|
||||
created = self._deliver_projection_outbox(path)
|
||||
return {
|
||||
**result,
|
||||
"online_task_projection": {
|
||||
"status": "PROJECTED",
|
||||
"task_id": created["task_id"],
|
||||
"job_id": result["job_id"],
|
||||
"task_status": created["status"],
|
||||
"execution_digest": projection["metadata"]["execution"]["execution_digest"],
|
||||
},
|
||||
}
|
||||
|
||||
def project_online_agent_execution(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
capabilities = request.get("required_capabilities") or []
|
||||
if not isinstance(capabilities, list) or not all(isinstance(item, str) for item in capabilities):
|
||||
raise ValueError("CAPABILITY_SCOPE_INVALID")
|
||||
execution = request.get("execution")
|
||||
if execution is not None and not isinstance(execution, Mapping):
|
||||
raise ValueError("EXECUTION_REQUEST_INVALID")
|
||||
task_id = str(request["task_id"])
|
||||
durable = self.bootstrap.get_binding(task_id)
|
||||
binding = self.bootstrap.execution_binding(durable)
|
||||
expected_job_id = str(request.get("job_id") or "") or None
|
||||
if expected_job_id and expected_job_id != binding.job_id:
|
||||
raise BootstrapError("JOB_BINDING_CONFLICT")
|
||||
if execution is None:
|
||||
planned = self.execution_plan_store.resolve(binding)
|
||||
if planned is not None:
|
||||
execution = planned["execution"]
|
||||
return self.bootstrap.execution_envelope(
|
||||
task_id,
|
||||
execution,
|
||||
required_capabilities=tuple(capabilities),
|
||||
expected_job_id=expected_job_id,
|
||||
)
|
||||
|
||||
def register_online_agent_execution_plan(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
task_id = str(request["task_id"])
|
||||
durable = self.bootstrap.get_binding(task_id)
|
||||
binding = self.bootstrap.execution_binding(durable)
|
||||
return self.execution_plan_store.register(binding, request)
|
||||
result = self.bootstrap.create(request); self.bindings[result["task_id"]] = result; return result
|
||||
|
||||
def acquire_lease(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
binding, controller, _ = self._runtime(request)
|
||||
@@ -1489,19 +1340,7 @@ class GovernedRuntime:
|
||||
controller = self._controller_for_job(job_id)
|
||||
row = controller.store.read(job_id)
|
||||
if action == "get":
|
||||
response = controller.store._receipt(row, duplicate=True).as_dict()
|
||||
try:
|
||||
with controller.store._connect() as connection:
|
||||
connection.row_factory = sqlite3.Row
|
||||
leases = connection.execute(
|
||||
"SELECT lease_id,job_id,branch,lineage_anchor_oid,current_head_oid,generation,"
|
||||
"lease_expires_at,state,revision FROM branch_leases WHERE job_id=? ORDER BY revision DESC",
|
||||
(job_id,),
|
||||
).fetchall()
|
||||
except sqlite3.Error as exc:
|
||||
raise RuntimeError("JOB_DB_UNAVAILABLE") from exc
|
||||
response["branch_leases"] = [dict(item) for item in leases]
|
||||
return response
|
||||
return controller.store._receipt(row, duplicate=True).as_dict()
|
||||
if action == "resume":
|
||||
return controller.resume(job_id).as_dict()
|
||||
return self._executor_from_row(row, controller).reconcile(job_id)
|
||||
@@ -1566,8 +1405,6 @@ class GovernedRuntime:
|
||||
def handlers(self) -> dict[str, Any]:
|
||||
return {
|
||||
"createGovernedTask": self.create_task,
|
||||
"projectOnlineAgentExecution": self.project_online_agent_execution,
|
||||
"registerOnlineAgentExecutionPlan": self.register_online_agent_execution_plan,
|
||||
"acquireWriterLease": self.acquire_lease,
|
||||
"bootstrapWriterLease": self.bootstrap_writer_lease,
|
||||
"renewWriterLease": self.renew_writer_lease,
|
||||
@@ -1597,8 +1434,6 @@ class _HTTPError(RuntimeError): pass
|
||||
|
||||
class BridgeHandler(BaseHTTPRequestHandler):
|
||||
adapter: BridgeV2Adapter | None = None
|
||||
control_token: str | None = None
|
||||
agent_task_api_url: str = "http://127.0.0.1:18940"
|
||||
def log_message(self, *_args: Any) -> None: return
|
||||
def _json(self, value: Any, status: int = 200) -> None:
|
||||
raw = json.dumps(value, sort_keys=True, separators=(",", ":")).encode(); self.send_response(status); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(raw))); self.end_headers(); self.wfile.write(raw)
|
||||
@@ -1616,50 +1451,7 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
||||
allowed = set(OPERATION_REQUIRED_FIELDS[operation]) | {"schema", "job_id", "lease_seconds", "create_target_branch", "worktree_mode", "cas_ref_update_required", "expected_branch_oid", "generation", "branch_mode"}
|
||||
if operation == "commitFilesGoverned": allowed.add("files")
|
||||
allowed.update({"owner_approval", "execute"})
|
||||
if operation == "projectOnlineAgentExecution":
|
||||
allowed.update({"execution", "required_capabilities"})
|
||||
if operation == "registerOnlineAgentExecutionPlan":
|
||||
allowed.update({"execution", "source_head_oid", "expected_previous_plan_digest"})
|
||||
if operation == "createGovernedTask":
|
||||
allowed.update({"online_task", "execution", "require_online_task_projection", "priority", "max_attempts", "parent_task_id", "parent_job_id"})
|
||||
if set(body) - allowed: raise _HTTPError("UNKNOWN_FIELDS")
|
||||
def _authorized(self, operation: str, body: Mapping[str, Any]) -> bool:
|
||||
header = self.headers.get("Authorization", "")
|
||||
if not header.startswith("Bearer "):
|
||||
return False
|
||||
supplied = header[7:]
|
||||
if self.control_token and hmac.compare_digest(supplied, self.control_token):
|
||||
return True
|
||||
poll = urllib.request.Request(
|
||||
self.agent_task_api_url.rstrip("/") + "/v1/tasks/poll",
|
||||
method="POST",
|
||||
data=b'{"limit":1}',
|
||||
headers={"Authorization": header, "Content-Type": "application/json", "Accept": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(poll, timeout=5) as response:
|
||||
principal = json.loads(response.read().decode("utf-8"))
|
||||
except (OSError, urllib.error.URLError, urllib.error.HTTPError, ValueError):
|
||||
return False
|
||||
agent_id = str(principal.get("agent_id") or "") if isinstance(principal, Mapping) else ""
|
||||
if not agent_id:
|
||||
return False
|
||||
claim_bound = {"acquireWriterLease", "bootstrapWriterLease", "renewWriterLease", "commitFileGoverned", "commitFilesGoverned", "projectOnlineAgentExecution", "getGitOpsJob"}
|
||||
if operation not in claim_bound:
|
||||
return False
|
||||
task_id = str(body.get("task_id") or "")
|
||||
if not task_id:
|
||||
return False
|
||||
task_request = urllib.request.Request(
|
||||
self.agent_task_api_url.rstrip("/") + "/v1/tasks/" + task_id,
|
||||
headers={"Authorization": header, "Accept": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(task_request, timeout=5) as response:
|
||||
task = json.loads(response.read().decode("utf-8"))
|
||||
except (OSError, urllib.error.URLError, urllib.error.HTTPError, ValueError):
|
||||
return False
|
||||
return isinstance(task, Mapping) and task.get("task_id") == task_id and task.get("claimed_by") == agent_id and task.get("status") in {"CLAIMED", "RUNNING"}
|
||||
def do_GET(self) -> None:
|
||||
if self.adapter is None: self._json({"error": "RUNTIME_UNAVAILABLE"}, 503); return
|
||||
if self.path == "/v2/runtime": self._json(self.adapter.getRuntimeStatus()); return
|
||||
@@ -1670,23 +1462,14 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
||||
operation = ROUTES.get(self.path)
|
||||
if operation is None or operation in ("getRuntimeStatus", "getGovernedWriteCapabilities") or self.adapter is None: self._json({"error": "NOT_FOUND"}, 404); return
|
||||
try:
|
||||
body = self._body()
|
||||
except _HTTPError as exc: self._json({"error": str(exc)}, 413 if str(exc) == "REQUEST_TOO_LARGE" else 400); return
|
||||
if not self._authorized(operation, body): self._json({"error": "BRIDGE_CALLER_UNAUTHORIZED"}, 401); return
|
||||
try:
|
||||
self._allowed(operation, body); self._json(getattr(self.adapter, operation)(body))
|
||||
body = self._body(); self._allowed(operation, body); self._json(getattr(self.adapter, operation)(body))
|
||||
except _HTTPError as exc: self._json({"error": str(exc)}, 413 if str(exc) == "REQUEST_TOO_LARGE" else 400)
|
||||
except (BootstrapError, ExecutionProjectionError, ExecutionPlanError, ValueError, BridgeV2Error) as exc: self._json({"error": str(exc)}, 409 if any(token in str(exc) for token in ("CONFLICT", "DRIFT", "CAS")) else 400)
|
||||
except (BootstrapError, ValueError, BridgeV2Error) as exc: self._json({"error": str(exc)}, 409 if "CONFLICT" in str(exc) else 400)
|
||||
except Exception as exc: self._json({"error": str(exc) or type(exc).__name__}, 409 if any(token in str(exc) for token in ("CONFLICT", "EXPECTATION", "MONOTONIC")) else 503)
|
||||
|
||||
|
||||
def serve(adapter: BridgeV2Adapter, host: str = "127.0.0.1", port: int = 18892, *, control_token_path: Path, agent_task_api_url: str) -> None:
|
||||
if not control_token_path.is_file() or control_token_path.is_symlink():
|
||||
raise RuntimeError("BRIDGE_CONTROL_CREDENTIAL_UNAVAILABLE")
|
||||
control_token = control_token_path.read_text(encoding="utf-8").strip()
|
||||
if len(control_token) < 32:
|
||||
raise RuntimeError("BRIDGE_CONTROL_CREDENTIAL_INVALID")
|
||||
BridgeHandler.adapter = adapter; BridgeHandler.control_token = control_token; BridgeHandler.agent_task_api_url = agent_task_api_url; ThreadingHTTPServer((host, port), BridgeHandler).serve_forever()
|
||||
def serve(adapter: BridgeV2Adapter, host: str = "127.0.0.1", port: int = 18892) -> None:
|
||||
BridgeHandler.adapter = adapter; ThreadingHTTPServer((host, port), BridgeHandler).serve_forever()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -1705,21 +1488,11 @@ def main() -> None:
|
||||
remote_name=os.environ.get("AIOS_GITEA_REMOTE", "gitea-http"),
|
||||
api_base=os.environ.get("AIOS_GITEA_API_BASE", "https://g.cnnetmkt.top/git/api/v1"),
|
||||
target_version=os.environ.get("AIOS_CONTROL_PLANE_TARGET_VERSION", "V2R2C1"),
|
||||
agent_task_api_url=os.environ.get("AIOS_AGENT_TASK_API_URL", "http://127.0.0.1:18940"),
|
||||
agent_task_control_token_path=Path(
|
||||
os.environ.get(
|
||||
"AIOS_AGENT_TASK_CONTROL_TOKEN_FILE",
|
||||
str(Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials/aios-chatgpt-bridge-v2-staging.service")) / "agent_task_control_token"),
|
||||
)
|
||||
),
|
||||
online_task_projection_enabled=os.environ.get("AIOS_ONLINE_TASK_PROJECTION_ENABLED", "1") == "1",
|
||||
)
|
||||
runtime = GovernedRuntime(config)
|
||||
serve(
|
||||
BridgeV2Adapter(runtime_status=runtime.runtime_status(), handlers=runtime.handlers()),
|
||||
port=int(os.environ.get("AIOS_BRIDGE_V2_PORT", "18892")),
|
||||
control_token_path=config.agent_task_control_token_path,
|
||||
agent_task_api_url=config.agent_task_api_url,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ class DetachedGitWorkspace:
|
||||
def materialize_files(self, files: Mapping[str, bytes]) -> None:
|
||||
dirty = self._dirty_paths()
|
||||
expected_paths = set(files)
|
||||
if dirty and not dirty.issubset(expected_paths):
|
||||
if dirty and dirty != expected_paths:
|
||||
raise ContinuationExecutionError("WORKTREE_DIRTY_SCOPE_MISMATCH")
|
||||
for relative, content in files.items():
|
||||
target = (self.path / relative).resolve()
|
||||
|
||||
@@ -11,24 +11,19 @@ or runtime switching.
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from datetime import datetime, timezone
|
||||
import argparse
|
||||
import hashlib
|
||||
import grp
|
||||
import hmac
|
||||
from http.server import BaseHTTPRequestHandler
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import socket
|
||||
import socketserver
|
||||
import stat
|
||||
import tempfile
|
||||
import struct
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -39,14 +34,6 @@ RECEIPT_SCHEMA = "aios.owner-durable-authorization-receipt.v1"
|
||||
DECISION_SCHEMA = "aios.owner-durable-authorization-decision.v1"
|
||||
PROJECTION_SCHEMA = "aios.external-capability-projection.request.v1"
|
||||
PROJECTION_LIST_SCHEMA = "aios.external-capability-projection.list.v1"
|
||||
CALLER_PROJECTION_SCHEMA = "aios.caller-admission-projection.v1"
|
||||
CALLER_PROJECTION_TTL_DEFAULT = 300
|
||||
CALLER_PROJECTION_SCOPES = frozenset({
|
||||
"governed-task:project",
|
||||
"release:candidate",
|
||||
})
|
||||
GATE_AMENDMENT_SCHEMA = "aios.release-preparation.gate-amendment.v1"
|
||||
GATE_AMENDMENT_CLASSIFICATION = "SUPERVISOR_GLOBAL_EXTERNAL_ACK_GATE_REMEDIATED"
|
||||
|
||||
ALLOWED_PERSISTENT_CAPABILITIES = frozenset({
|
||||
"governed-task:project",
|
||||
@@ -77,80 +64,6 @@ TASK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
||||
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
||||
JOB_RE = re.compile(r"^job-[0-9a-f]{32}$")
|
||||
MAX_BODY = 512 * 1024
|
||||
SO_PEERCRED_SIZE = struct.calcsize("3i")
|
||||
|
||||
|
||||
def _peer_process_status(pid: int) -> tuple[int, int, set[int]]:
|
||||
status_path = Path(f"/proc/{pid}/status")
|
||||
try:
|
||||
lines = status_path.read_text(encoding="utf-8").splitlines()
|
||||
except (OSError, UnicodeError) as exc:
|
||||
raise AuthorizationError("SOCKPEER_CREDENTIALS_INVALID", 403) from exc
|
||||
fields: dict[str, str] = {}
|
||||
for line in lines:
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
if key in {"Uid", "Gid", "Groups"}:
|
||||
fields[key] = value.strip()
|
||||
try:
|
||||
uid = int(fields["Uid"].split()[0])
|
||||
gid = int(fields["Gid"].split()[0])
|
||||
groups = {int(item) for item in fields["Groups"].split()}
|
||||
except (KeyError, IndexError, ValueError) as exc:
|
||||
raise AuthorizationError("SOCKPEER_CREDENTIALS_INVALID", 403) from exc
|
||||
return uid, gid, groups
|
||||
|
||||
|
||||
def _peer_process_groups(pid: int) -> set[int]:
|
||||
return _peer_process_status(pid)[2]
|
||||
|
||||
|
||||
def peer_credentials_from_connection(connection: object) -> tuple[int, int, int, set[int]]:
|
||||
if not hasattr(socket, "SO_PEERCRED"):
|
||||
raise AuthorizationError("SO_PEERCRED_UNAVAILABLE", 500)
|
||||
try:
|
||||
raw = connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, SO_PEERCRED_SIZE)
|
||||
except OSError as exc:
|
||||
raise AuthorizationError("SO_PEERCRED_UNAVAILABLE", 403) from exc
|
||||
if len(raw) != SO_PEERCRED_SIZE:
|
||||
raise AuthorizationError("SOCKPEER_CREDENTIALS_INVALID", 403)
|
||||
try:
|
||||
pid, uid, gid = struct.unpack("3i", raw)
|
||||
except struct.error as exc:
|
||||
raise AuthorizationError("SOCKPEER_CREDENTIALS_INVALID", 403) from exc
|
||||
if pid <= 1 or uid < 0 or gid < 0:
|
||||
raise AuthorizationError("SOCKPEER_CREDENTIALS_INVALID", 403)
|
||||
process_uid, process_gid, groups = _peer_process_status(pid)
|
||||
if process_uid != uid or process_gid != gid:
|
||||
raise AuthorizationError("SOCKPEER_CREDENTIALS_INVALID", 403)
|
||||
return pid, uid, gid, groups | {gid}
|
||||
|
||||
|
||||
def peer_user_from_connection(connection: object) -> str:
|
||||
_pid, uid, _gid, _groups = peer_credentials_from_connection(connection)
|
||||
try:
|
||||
return pwd.getpwuid(uid).pw_name
|
||||
except (KeyError, OSError) as exc:
|
||||
raise AuthorizationError("SO_PEERCRED_UID_UNKNOWN", 403) from exc
|
||||
|
||||
|
||||
CANONICAL_BROKER_GROUP = os.environ.get(
|
||||
"AIOS_OWNER_AUTH_BROKER_ALLOWED_GROUP",
|
||||
"aios-autonomous-agent-worker",
|
||||
).strip()
|
||||
|
||||
|
||||
def authorize_broker_peer(connection: object) -> bool:
|
||||
_pid, _uid, _gid, groups = peer_credentials_from_connection(connection)
|
||||
if not CANONICAL_BROKER_GROUP:
|
||||
raise AuthorizationError("SOCKGROUP_CONFIGURATION_INVALID", 503)
|
||||
try:
|
||||
canonical_gid = grp.getgrnam(CANONICAL_BROKER_GROUP).gr_gid
|
||||
except KeyError as exc:
|
||||
raise AuthorizationError("SOCKGROUP_CONFIGURATION_INVALID", 503) from exc
|
||||
if canonical_gid not in groups:
|
||||
raise AuthorizationError("SOCKGROUP_NOT_AUTHORIZED", 403)
|
||||
return True
|
||||
|
||||
|
||||
class AuthorizationError(RuntimeError):
|
||||
@@ -221,7 +134,6 @@ class DurablePolicy:
|
||||
persistent_until_revoked: bool
|
||||
constraints: Constraints
|
||||
policy_sha256: str
|
||||
expires_at: datetime | None
|
||||
|
||||
@classmethod
|
||||
def load(
|
||||
@@ -246,14 +158,10 @@ class DurablePolicy:
|
||||
"capabilities",
|
||||
"issued_at",
|
||||
"not_before",
|
||||
"expires_at",
|
||||
"persistent_until_revoked",
|
||||
"constraints",
|
||||
}
|
||||
if (
|
||||
not set(value).issubset(allowed_fields)
|
||||
or value.get("schema") != POLICY_SCHEMA
|
||||
):
|
||||
if set(value) != allowed_fields or value.get("schema") != POLICY_SCHEMA:
|
||||
raise AuthorizationError("POLICY_FIELDS_INVALID", 503)
|
||||
|
||||
grant_id = str(value.get("grant_id") or "")
|
||||
@@ -282,13 +190,6 @@ class DurablePolicy:
|
||||
current = now or datetime.now(timezone.utc)
|
||||
if issued_at > not_before or current < not_before:
|
||||
raise AuthorizationError("POLICY_NOT_ACTIVE", 403)
|
||||
expires_at = None
|
||||
if value.get("expires_at") is not None:
|
||||
expires_at = parse_time(value.get("expires_at"), field="expires_at")
|
||||
if expires_at <= not_before:
|
||||
raise AuthorizationError("POLICY_EXPIRY_INVALID", 503)
|
||||
if current >= expires_at:
|
||||
raise AuthorizationError("POLICY_EXPIRED", 403)
|
||||
if value.get("persistent_until_revoked") is not True:
|
||||
raise AuthorizationError("POLICY_PERSISTENCE_INVALID", 503)
|
||||
|
||||
@@ -342,43 +243,8 @@ class DurablePolicy:
|
||||
max_task_risk=max_risk,
|
||||
),
|
||||
policy_sha256=sha256(policy_path.read_bytes()),
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
def validate_caller_scope(
|
||||
self,
|
||||
caller: str,
|
||||
scope: str,
|
||||
request: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if caller != self.account:
|
||||
raise AuthorizationError("CALLER_MISMATCH", 403)
|
||||
if scope not in CALLER_PROJECTION_SCOPES:
|
||||
raise AuthorizationError("SCOPE_MISMATCH", 403)
|
||||
if scope not in self.capabilities:
|
||||
raise AuthorizationError("CAPABILITY_NOT_AUTHORIZED", 403)
|
||||
if not isinstance(request, Mapping):
|
||||
raise AuthorizationError("CALLER_PROJECTION_REQUEST_INVALID")
|
||||
if scope == "release:candidate":
|
||||
if set(request) != {"source_commit", "version"}:
|
||||
raise AuthorizationError("CALLER_PROJECTION_FIELDS_INVALID")
|
||||
source_commit = str(request.get("source_commit") or "")
|
||||
version = str(request.get("version") or "")
|
||||
if OID_RE.fullmatch(source_commit) is None:
|
||||
raise AuthorizationError("RELEASE_SOURCE_COMMIT_INVALID")
|
||||
if VERSION_RE.fullmatch(version) is None or not version.startswith(
|
||||
self.constraints.release_version_prefix
|
||||
):
|
||||
raise AuthorizationError("RELEASE_VERSION_NOT_AUTHORIZED", 403)
|
||||
elif request:
|
||||
raise AuthorizationError("CALLER_PROJECTION_FIELDS_INVALID")
|
||||
return {
|
||||
"schema": CALLER_PROJECTION_SCHEMA,
|
||||
"caller": caller,
|
||||
"scope": scope,
|
||||
"request_sha256": sha256(canonical(request)),
|
||||
}
|
||||
|
||||
def authorize(self, capability: str, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if capability not in self.capabilities:
|
||||
raise AuthorizationError("CAPABILITY_NOT_AUTHORIZED", 403)
|
||||
@@ -581,160 +447,9 @@ class ReceiptStore:
|
||||
ON projection_outbox(state, capability, created_at)
|
||||
"""
|
||||
)
|
||||
con.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS caller_projections(
|
||||
projection_id TEXT PRIMARY KEY,
|
||||
caller TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
grant_id TEXT NOT NULL,
|
||||
policy_sha256 TEXT NOT NULL,
|
||||
projection_state TEXT NOT NULL,
|
||||
credential_state TEXT NOT NULL,
|
||||
rehydration_state TEXT NOT NULL,
|
||||
issued_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(caller, scope)
|
||||
)
|
||||
"""
|
||||
)
|
||||
con.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS gate_amendments(
|
||||
idempotency_key TEXT PRIMARY KEY,
|
||||
amendment_sha256 TEXT NOT NULL,
|
||||
amendment_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
con.commit()
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@staticmethod
|
||||
def caller_projection_id(caller: str, scope: str) -> str:
|
||||
digest = sha256(canonical({"caller": caller, "scope": scope}))
|
||||
return "caller-projection-" + digest[:32]
|
||||
|
||||
def upsert_caller_projection(
|
||||
self,
|
||||
*,
|
||||
caller: str,
|
||||
scope: str,
|
||||
grant_id: str,
|
||||
policy_sha256: str,
|
||||
ttl_seconds: int,
|
||||
effective_scopes: Any,
|
||||
) -> dict[str, Any]:
|
||||
if not 30 <= ttl_seconds <= 900:
|
||||
raise AuthorizationError("CALLER_PROJECTION_TTL_INVALID", 503)
|
||||
normalized_scopes = sorted({str(item) for item in effective_scopes})
|
||||
if (
|
||||
not normalized_scopes
|
||||
or scope not in normalized_scopes
|
||||
or not set(normalized_scopes).issubset(CALLER_PROJECTION_SCOPES)
|
||||
):
|
||||
raise AuthorizationError("CALLER_PROJECTION_SCOPES_INVALID", 503)
|
||||
requested_projection_id = self.caller_projection_id(caller, scope)
|
||||
issued = datetime.now(timezone.utc)
|
||||
issued_at = issued.isoformat()
|
||||
expires_at = (issued + timedelta(seconds=ttl_seconds)).isoformat()
|
||||
requested_recovery = False
|
||||
with self._lock, sqlite3.connect(self.path) as con:
|
||||
con.row_factory = sqlite3.Row
|
||||
con.execute("BEGIN IMMEDIATE")
|
||||
for projected_scope in normalized_scopes:
|
||||
projection_id = self.caller_projection_id(caller, projected_scope)
|
||||
row = con.execute(
|
||||
"SELECT * FROM caller_projections WHERE caller=? AND scope=?",
|
||||
(caller, projected_scope),
|
||||
).fetchone()
|
||||
if row is not None and row["projection_state"] not in {"ACKED", "PENDING"}:
|
||||
con.rollback()
|
||||
raise AuthorizationError("PROJECTION_STATE_INVALID", 503)
|
||||
recovery = row is not None
|
||||
if projected_scope == scope:
|
||||
requested_recovery = recovery
|
||||
if row is None:
|
||||
con.execute(
|
||||
"""
|
||||
INSERT INTO caller_projections(
|
||||
projection_id,caller,scope,grant_id,policy_sha256,
|
||||
projection_state,credential_state,rehydration_state,
|
||||
issued_at,expires_at,updated_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
projection_id,
|
||||
caller,
|
||||
projected_scope,
|
||||
grant_id,
|
||||
policy_sha256,
|
||||
"PENDING",
|
||||
"GENERATING",
|
||||
"RECOVERING" if recovery else "GENERATING",
|
||||
issued_at,
|
||||
expires_at,
|
||||
issued_at,
|
||||
),
|
||||
)
|
||||
else:
|
||||
con.execute(
|
||||
"""
|
||||
UPDATE caller_projections
|
||||
SET projection_id=?,grant_id=?,policy_sha256=?,
|
||||
projection_state='PENDING',
|
||||
credential_state='GENERATING',
|
||||
rehydration_state=?,
|
||||
issued_at=?,expires_at=?,updated_at=?
|
||||
WHERE caller=? AND scope=?
|
||||
""",
|
||||
(
|
||||
projection_id,
|
||||
grant_id,
|
||||
policy_sha256,
|
||||
"RECOVERING",
|
||||
issued_at,
|
||||
expires_at,
|
||||
issued_at,
|
||||
caller,
|
||||
projected_scope,
|
||||
),
|
||||
)
|
||||
con.execute(
|
||||
"""
|
||||
UPDATE caller_projections
|
||||
SET projection_state='ACKED',
|
||||
credential_state='REHYDRATED',
|
||||
rehydration_state=?,
|
||||
updated_at=?
|
||||
WHERE caller=? AND scope=?
|
||||
""",
|
||||
(
|
||||
"RECOVERED" if recovery else "GENERATED",
|
||||
issued_at,
|
||||
caller,
|
||||
projected_scope,
|
||||
),
|
||||
)
|
||||
con.commit()
|
||||
return {
|
||||
"schema": CALLER_PROJECTION_SCHEMA,
|
||||
"projection_id": requested_projection_id,
|
||||
"caller": caller,
|
||||
"scope": scope,
|
||||
"grant_id": grant_id,
|
||||
"policy_sha256": policy_sha256,
|
||||
"projection_state": "ACKED",
|
||||
"credential_state": "REHYDRATED",
|
||||
"rehydration_state": "RECOVERED" if requested_recovery else "GENERATED",
|
||||
"effective_scopes": normalized_scopes,
|
||||
"issued_at": issued_at,
|
||||
"expires_at": expires_at,
|
||||
"secret_exposed": False,
|
||||
}
|
||||
|
||||
def get(self, key: str) -> dict[str, Any] | None:
|
||||
with sqlite3.connect(self.path) as con:
|
||||
row = con.execute(
|
||||
@@ -804,37 +519,6 @@ class ReceiptStore:
|
||||
)
|
||||
con.commit()
|
||||
|
||||
def put_gate_amendment(
|
||||
self,
|
||||
idempotency_key: str,
|
||||
amendment: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
encoded = json.dumps(amendment, sort_keys=True, separators=(",", ":"))
|
||||
lowered = encoded.lower()
|
||||
if any(marker in lowered for marker in (
|
||||
"bearer ", "access_token", "refresh_token", "private_key", "credential_file"
|
||||
)):
|
||||
raise AuthorizationError("GATE_AMENDMENT_SECRET_MATERIAL_DENY", 403)
|
||||
digest = sha256(encoded.encode())
|
||||
with self._lock, sqlite3.connect(self.path) as con:
|
||||
con.execute("BEGIN IMMEDIATE")
|
||||
row = con.execute(
|
||||
"SELECT amendment_sha256,amendment_json FROM gate_amendments WHERE idempotency_key=?",
|
||||
(idempotency_key,),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
if row[0] != digest or row[1] != encoded:
|
||||
con.rollback()
|
||||
raise AuthorizationError("GATE_AMENDMENT_IDEMPOTENCY_CONFLICT", 409)
|
||||
con.commit()
|
||||
return {"amendment_sha256": digest, "idempotent": True}
|
||||
con.execute(
|
||||
"INSERT INTO gate_amendments(idempotency_key,amendment_sha256,amendment_json,created_at) VALUES(?,?,?,?)",
|
||||
(idempotency_key, digest, encoded, datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
con.commit()
|
||||
return {"amendment_sha256": digest, "idempotent": False}
|
||||
|
||||
@staticmethod
|
||||
def projection_id(
|
||||
grant_id: str,
|
||||
@@ -1032,83 +716,12 @@ class Broker:
|
||||
store: ReceiptStore,
|
||||
upstream: Upstream,
|
||||
expected_account: str,
|
||||
expected_peer_user: str,
|
||||
allowed_peer_users: frozenset[str] | None = None,
|
||||
) -> None:
|
||||
self.policy_path = policy_path
|
||||
self.revocation_path = revocation_path
|
||||
self.store = store
|
||||
self.upstream = upstream
|
||||
self.expected_account = expected_account
|
||||
self.expected_peer_user = expected_peer_user
|
||||
self.allowed_peer_users = allowed_peer_users or frozenset({expected_peer_user})
|
||||
|
||||
@staticmethod
|
||||
def validate_caller_projection(
|
||||
value: Mapping[str, Any],
|
||||
*,
|
||||
expected_scopes: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
expected = sorted(
|
||||
set(expected_scopes) if expected_scopes is not None else CALLER_PROJECTION_SCOPES
|
||||
)
|
||||
if value.get("caller") != "chatgpt-governed-implementer-singlehost":
|
||||
raise AuthorizationError("CALLER_MISMATCH", 503)
|
||||
if value.get("scope") not in expected:
|
||||
raise AuthorizationError("SCOPE_MISMATCH", 503)
|
||||
if value.get("projection_state") != "ACKED":
|
||||
raise AuthorizationError("PROJECTION_STATE_INVALID", 503)
|
||||
if value.get("credential_state") != "REHYDRATED":
|
||||
raise AuthorizationError("CREDENTIAL_REHYDRATION_INVALID", 503)
|
||||
if value.get("rehydration_state") not in {"GENERATED", "RECOVERED"}:
|
||||
raise AuthorizationError("REHYDRATION_STATE_INVALID", 503)
|
||||
if value.get("effective_scopes") != expected:
|
||||
raise AuthorizationError("PROJECTION_SCOPE_INVALID", 503)
|
||||
if value.get("secret_exposed") is not False:
|
||||
raise AuthorizationError("PROJECTION_SECRET_EXPOSURE", 503)
|
||||
return dict(value)
|
||||
|
||||
def project_caller(
|
||||
self,
|
||||
caller: str,
|
||||
scope: str,
|
||||
request: Mapping[str, Any],
|
||||
*,
|
||||
idempotency_key: str,
|
||||
) -> dict[str, Any]:
|
||||
if not GRANT_RE.fullmatch(idempotency_key):
|
||||
raise AuthorizationError("IDEMPOTENCY_KEY_INVALID")
|
||||
if caller != self.expected_account:
|
||||
raise AuthorizationError("CALLER_MISMATCH", 403)
|
||||
if scope not in CALLER_PROJECTION_SCOPES:
|
||||
raise AuthorizationError("SCOPE_MISMATCH", 403)
|
||||
policy = self.policy()
|
||||
policy.validate_caller_scope(caller, scope, request)
|
||||
effective_scopes = sorted(policy.capabilities & CALLER_PROJECTION_SCOPES)
|
||||
try:
|
||||
ttl_seconds = int(
|
||||
os.environ.get(
|
||||
"AIOS_OWNER_AUTH_CALLER_PROJECTION_TTL_SECONDS",
|
||||
str(CALLER_PROJECTION_TTL_DEFAULT),
|
||||
)
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise AuthorizationError("CALLER_PROJECTION_TTL_INVALID", 503) from exc
|
||||
credential = secrets.token_urlsafe(32)
|
||||
value = self.store.upsert_caller_projection(
|
||||
caller=caller,
|
||||
scope=scope,
|
||||
grant_id=policy.grant_id,
|
||||
policy_sha256=policy.policy_sha256,
|
||||
ttl_seconds=ttl_seconds,
|
||||
effective_scopes=effective_scopes,
|
||||
)
|
||||
value = self.validate_caller_projection(
|
||||
value,
|
||||
expected_scopes=effective_scopes,
|
||||
)
|
||||
value["_credential"] = credential
|
||||
return value
|
||||
|
||||
def policy(self) -> DurablePolicy:
|
||||
policy = DurablePolicy.load(self.policy_path, self.revocation_path)
|
||||
@@ -1145,90 +758,24 @@ class Broker:
|
||||
"separate_owner_release_approval_still_required": True,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def validate_gate_amendment(
|
||||
amendment: Mapping[str, Any],
|
||||
*,
|
||||
policy: DurablePolicy,
|
||||
request: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
expected = {
|
||||
"schema": GATE_AMENDMENT_SCHEMA,
|
||||
"classification": GATE_AMENDMENT_CLASSIFICATION,
|
||||
"supervisor_task_id": "r42-release-preparation-autonomous-supervisor-20260820",
|
||||
"supervisor_execution_digest": "491457595667a95809f6f3cd19e9e7c9e74181070fd8038b86f30eb41bdfc231",
|
||||
"actor_type": "credentialed_host_coordinator",
|
||||
"operation": "createGovernedTask",
|
||||
"repository": "aios/aios",
|
||||
"original_external_request_sha256": "08fc2f3d1b64a2ca11da9f22da48de834cff3442fa7fa2d3bc390f8dbf9b254a",
|
||||
"external_connector_rule": "ACK_STILL_REQUIRED",
|
||||
"host_coordinator_rule": "DURABLE_OWNER_GOVERNED_TASK_PROJECT",
|
||||
"owner_grant_id": policy.grant_id,
|
||||
"owner_policy_sha256": policy.policy_sha256,
|
||||
"builder_task_id": "r42-trusted-candidate-builder-runtime-wiring-20260820",
|
||||
"builder_base_oid": "d957a216a6e9bd4c2292ffc6e44876a1c08fa1e3",
|
||||
"builder_expected_branch_oid": "d957a216a6e9bd4c2292ffc6e44876a1c08fa1e3",
|
||||
"builder_target_branch": "agent/r42-trusted-candidate-builder-runtime-wiring-20260820",
|
||||
"builder_generation": 1,
|
||||
"builder_idempotency_key": "r42-trusted-candidate-builder-runtime-wiring-binding-v1",
|
||||
}
|
||||
value = dict(amendment)
|
||||
if value != expected:
|
||||
raise AuthorizationError("GATE_AMENDMENT_BINDING_INVALID", 403)
|
||||
request_coordinates = {
|
||||
"task_id": expected["builder_task_id"],
|
||||
"base_oid": expected["builder_base_oid"],
|
||||
"expected_branch_oid": expected["builder_expected_branch_oid"],
|
||||
"target_branch": expected["builder_target_branch"],
|
||||
"generation": expected["builder_generation"],
|
||||
"idempotency_key": expected["builder_idempotency_key"],
|
||||
"repository": expected["repository"],
|
||||
}
|
||||
if any(request.get(key) != item for key, item in request_coordinates.items()):
|
||||
raise AuthorizationError("GATE_AMENDMENT_BUILDER_BINDING_INVALID", 403)
|
||||
return value
|
||||
|
||||
def execute(
|
||||
self,
|
||||
capability: str,
|
||||
request: Mapping[str, Any],
|
||||
*,
|
||||
idempotency_key: str,
|
||||
amendment: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not GRANT_RE.fullmatch(idempotency_key):
|
||||
raise AuthorizationError("IDEMPOTENCY_KEY_INVALID")
|
||||
policy = self.policy()
|
||||
decision = policy.authorize(capability, request)
|
||||
request_sha = decision["request_sha256"]
|
||||
amendment_value: dict[str, Any] | None = None
|
||||
if amendment is not None:
|
||||
if capability != "governed-task:project":
|
||||
raise AuthorizationError("GATE_AMENDMENT_CAPABILITY_INVALID", 403)
|
||||
amendment_value = self.validate_gate_amendment(
|
||||
amendment,
|
||||
policy=policy,
|
||||
request=request,
|
||||
)
|
||||
|
||||
prior = self.store.get(idempotency_key)
|
||||
if prior is not None:
|
||||
if prior["request_sha256"] != request_sha:
|
||||
raise AuthorizationError("IDEMPOTENCY_CONFLICT", 409)
|
||||
response = prior["response"]
|
||||
if amendment_value is not None:
|
||||
amendment_result = self.store.put_gate_amendment(
|
||||
idempotency_key,
|
||||
amendment_value,
|
||||
)
|
||||
response = {
|
||||
**response,
|
||||
"gate_amendment": {
|
||||
"classification": GATE_AMENDMENT_CLASSIFICATION,
|
||||
"amendment_sha256": amendment_result["amendment_sha256"],
|
||||
},
|
||||
}
|
||||
self.store.update_response(idempotency_key, request_sha, response)
|
||||
if (
|
||||
capability == "release:candidate"
|
||||
and not response.get("projection_id")
|
||||
@@ -1315,15 +862,6 @@ class Broker:
|
||||
),
|
||||
"separate_owner_release_approval_still_required": True,
|
||||
}
|
||||
if amendment_value is not None:
|
||||
amendment_result = self.store.put_gate_amendment(
|
||||
idempotency_key,
|
||||
amendment_value,
|
||||
)
|
||||
response["gate_amendment"] = {
|
||||
"classification": GATE_AMENDMENT_CLASSIFICATION,
|
||||
"amendment_sha256": amendment_result["amendment_sha256"],
|
||||
}
|
||||
self.store.put(idempotency_key, capability, request_sha, response)
|
||||
return response
|
||||
|
||||
@@ -1343,22 +881,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_args: Any) -> None:
|
||||
return
|
||||
|
||||
def send_json(
|
||||
self,
|
||||
value: Mapping[str, Any],
|
||||
status: int,
|
||||
*,
|
||||
internal_credential: str | None = None,
|
||||
) -> None:
|
||||
def send_json(self, value: Mapping[str, Any], status: int) -> None:
|
||||
raw = canonical(value)
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
if internal_credential:
|
||||
self.send_header(
|
||||
"X-AIOS-Caller-Projection-Credential",
|
||||
internal_credential,
|
||||
)
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
@@ -1366,16 +893,6 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def caller_account(self) -> str:
|
||||
return self.headers.get("X-AIOS-Account", "").strip()
|
||||
|
||||
def peer_user(self) -> str:
|
||||
if self.broker is None:
|
||||
return ""
|
||||
return peer_user_from_connection(self.connection)
|
||||
|
||||
def peer_authorized(self) -> bool:
|
||||
if self.broker is None:
|
||||
return False
|
||||
return authorize_broker_peer(self.connection)
|
||||
|
||||
def account_authorized(self) -> bool:
|
||||
if self.broker is None:
|
||||
return False
|
||||
@@ -1407,14 +924,6 @@ class Handler(BaseHTTPRequestHandler):
|
||||
200,
|
||||
)
|
||||
return
|
||||
try:
|
||||
peer_ok = self.peer_authorized()
|
||||
except AuthorizationError as exc:
|
||||
self.send_json({"error": exc.code}, exc.status)
|
||||
return
|
||||
if not peer_ok:
|
||||
self.send_json({"error": "SOCKGROUP_NOT_AUTHORIZED"}, 403)
|
||||
return
|
||||
if not self.account_authorized():
|
||||
self.send_json({"error": "ACCOUNT_NOT_AUTHORIZED"}, 403)
|
||||
return
|
||||
@@ -1429,12 +938,6 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"grant_id": policy.grant_id,
|
||||
"account": policy.account,
|
||||
"capabilities": sorted(policy.capabilities),
|
||||
"policy_sha256": policy.policy_sha256,
|
||||
"constraints": {
|
||||
"repository": policy.constraints.repository,
|
||||
"allowed_target_prefix": policy.constraints.allowed_target_prefix,
|
||||
"max_task_risk": policy.constraints.max_task_risk,
|
||||
},
|
||||
"persistent_until_revoked": True,
|
||||
"separate_owner_release_approval_still_required": True,
|
||||
}
|
||||
@@ -1454,21 +957,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json({"error": exc.code}, exc.status)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
try:
|
||||
peer_ok = self.peer_authorized()
|
||||
except AuthorizationError as exc:
|
||||
self.send_json({"error": exc.code}, exc.status)
|
||||
return
|
||||
if not peer_ok:
|
||||
self.send_json({"error": "SOCKGROUP_NOT_AUTHORIZED"}, 403)
|
||||
return
|
||||
if not self.account_authorized():
|
||||
self.send_json({"error": "ACCOUNT_NOT_AUTHORIZED"}, 403)
|
||||
return
|
||||
routes = {
|
||||
"/v1/project-governed-task": "governed-task:project",
|
||||
"/v1/authorize-release-candidate": "release:candidate",
|
||||
"/v1/project-caller": "caller:projection",
|
||||
}
|
||||
capability = routes.get(self.path)
|
||||
if capability is None or self.broker is None:
|
||||
@@ -1476,49 +970,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
try:
|
||||
body = self.body()
|
||||
if self.path == "/v1/project-caller":
|
||||
if set(body) != {
|
||||
"caller",
|
||||
"scope",
|
||||
"idempotency_key",
|
||||
"request",
|
||||
}:
|
||||
raise AuthorizationError("CALLER_PROJECTION_FIELDS_INVALID")
|
||||
if not isinstance(body["request"], Mapping):
|
||||
raise AuthorizationError("CALLER_PROJECTION_REQUEST_INVALID")
|
||||
value = self.broker.project_caller(
|
||||
str(body["caller"]),
|
||||
str(body["scope"]),
|
||||
dict(body["request"]),
|
||||
idempotency_key=str(body["idempotency_key"]),
|
||||
)
|
||||
credential = str(value.pop("_credential", ""))
|
||||
if len(credential) < 32:
|
||||
raise AuthorizationError("CREDENTIAL_REHYDRATION_MISSING", 503)
|
||||
self.send_json(
|
||||
value,
|
||||
200,
|
||||
internal_credential=credential,
|
||||
)
|
||||
return
|
||||
if set(body) not in (
|
||||
{"idempotency_key", "request"},
|
||||
{"idempotency_key", "request", "amendment"},
|
||||
):
|
||||
if set(body) != {"idempotency_key", "request"}:
|
||||
raise AuthorizationError("BROKER_FIELDS_INVALID")
|
||||
if not isinstance(body["request"], Mapping):
|
||||
raise AuthorizationError("BROKER_REQUEST_INVALID")
|
||||
if "amendment" in body and not isinstance(body["amendment"], Mapping):
|
||||
raise AuthorizationError("GATE_AMENDMENT_INVALID")
|
||||
value = self.broker.execute(
|
||||
capability,
|
||||
dict(body["request"]),
|
||||
idempotency_key=str(body["idempotency_key"]),
|
||||
amendment=(
|
||||
dict(body["amendment"])
|
||||
if isinstance(body.get("amendment"), Mapping)
|
||||
else None
|
||||
),
|
||||
)
|
||||
self.send_json(value, 200)
|
||||
except AuthorizationError as exc:
|
||||
@@ -1530,41 +989,6 @@ class UnixHTTPServer(socketserver.ThreadingUnixStreamServer):
|
||||
|
||||
def serve() -> None:
|
||||
credentials = Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials"))
|
||||
raw_allowed_users = os.environ.get(
|
||||
"AIOS_OWNER_AUTH_BROKER_ALLOWED_USERS",
|
||||
"",
|
||||
).strip()
|
||||
if raw_allowed_users:
|
||||
allowed_peer_users = tuple(
|
||||
dict.fromkeys(
|
||||
item.strip()
|
||||
for item in raw_allowed_users.split(",")
|
||||
if item.strip()
|
||||
)
|
||||
)
|
||||
else:
|
||||
single_allowed_user = os.environ.get(
|
||||
"AIOS_OWNER_AUTH_BROKER_ALLOWED_USER",
|
||||
"aios-autonomous-agent-worker",
|
||||
).strip()
|
||||
allowed_peer_users = (single_allowed_user,) if single_allowed_user else ()
|
||||
if not allowed_peer_users:
|
||||
raise RuntimeError("BROKER_ALLOWED_USER_MISSING")
|
||||
allowed_peer_user = allowed_peer_users[0]
|
||||
for username in allowed_peer_users:
|
||||
pwd.getpwnam(username)
|
||||
allowed_peer_uid = pwd.getpwnam(allowed_peer_user).pw_uid
|
||||
global CANONICAL_BROKER_GROUP
|
||||
CANONICAL_BROKER_GROUP = os.environ.get(
|
||||
"AIOS_OWNER_AUTH_BROKER_ALLOWED_GROUP",
|
||||
"aios-autonomous-agent-worker",
|
||||
).strip()
|
||||
if not CANONICAL_BROKER_GROUP:
|
||||
raise RuntimeError("BROKER_ALLOWED_GROUP_MISSING")
|
||||
try:
|
||||
allowed_peer_gid = grp.getgrnam(CANONICAL_BROKER_GROUP).gr_gid
|
||||
except KeyError as exc:
|
||||
raise RuntimeError("BROKER_ALLOWED_GROUP_INVALID") from exc
|
||||
policy_path = Path(
|
||||
os.environ.get(
|
||||
"AIOS_OWNER_DURABLE_AUTHORIZATION_POLICY",
|
||||
@@ -1605,8 +1029,6 @@ def serve() -> None:
|
||||
bridge_token=bridge_token,
|
||||
),
|
||||
expected_account=account,
|
||||
expected_peer_user=allowed_peer_user,
|
||||
allowed_peer_users=frozenset(allowed_peer_users),
|
||||
)
|
||||
socket_path = Path(
|
||||
os.environ.get(
|
||||
@@ -1622,7 +1044,6 @@ def serve() -> None:
|
||||
raise RuntimeError("BROKER_SOCKET_CONFLICT")
|
||||
socket_path.unlink()
|
||||
server = UnixHTTPServer(str(socket_path), Handler)
|
||||
os.chown(socket_path, allowed_peer_uid, allowed_peer_gid)
|
||||
os.chmod(socket_path, 0o660)
|
||||
try:
|
||||
server.serve_forever()
|
||||
|
||||
@@ -2,20 +2,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
from contextlib import contextmanager
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobStore, sha256_json
|
||||
from aios_online_agent_execution import (
|
||||
GovernedExecutionBinding,
|
||||
project_execution_envelope,
|
||||
project_online_task,
|
||||
)
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobStore
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
||||
@@ -43,161 +36,35 @@ class GovernedTaskBinding:
|
||||
return {"task_id": self.task_id, "repository": self.repository, "base_oid": self.base_oid, "target_branch": self.target_branch, "expected_branch_oid": self.expected_branch_oid, "generation": self.generation, "branch_mode": self.branch_mode, "create_target_branch": self.branch_mode == "CREATE_NEW", "worktree_mode": "detached_at_base_oid" if self.branch_mode == "CREATE_NEW" else "detached_at_expected_head", "cas_ref_update_required": True, "idempotent": idempotent, "state": "BOUND", "job_id": self.job_id}
|
||||
|
||||
|
||||
def _legacy_safe(value: str) -> str:
|
||||
def _safe(value: str) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-")[:120] or "task"
|
||||
|
||||
|
||||
def _task_key(value: str) -> str:
|
||||
readable = _legacy_safe(value)[:72]
|
||||
return f"{readable}-{hashlib.sha256(value.encode('utf-8')).hexdigest()[:24]}"
|
||||
|
||||
|
||||
class GovernedTaskBootstrap:
|
||||
def __init__(self, authority: Any, root: Path, *, remote_name: str, token_path: Path, ssh_key_path: Path, api_base: str = "https://g.cnnetmkt.top/git/api/v1") -> None:
|
||||
self.authority = authority; self.root = Path(root); self.remote_name = remote_name; self.token_path = Path(token_path); self.ssh_key_path = Path(ssh_key_path); self.api_base = api_base; self.task_meta_root = self.root / "task-bindings"; self.job_root = self.root / "jobs"; self.lock_root = self.root / "task-locks"; self.task_meta_root.mkdir(parents=True, exist_ok=True); self.job_root.mkdir(parents=True, exist_ok=True); self.lock_root.mkdir(parents=True, exist_ok=True)
|
||||
self.authority = authority; self.root = Path(root); self.remote_name = remote_name; self.token_path = Path(token_path); self.ssh_key_path = Path(ssh_key_path); self.api_base = api_base; self.task_meta_root = self.root / "task-bindings"; self.job_root = self.root / "jobs"; self.task_meta_root.mkdir(parents=True, exist_ok=True); self.job_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _store(self, task_id: str) -> tuple[JobStore, GitOpsJobController]:
|
||||
legacy = self.job_root / _legacy_safe(task_id)
|
||||
keyed = self.job_root / _task_key(task_id)
|
||||
root = legacy if legacy.is_dir() and not keyed.exists() else keyed
|
||||
store = JobStore(root, payload_root=root / "payloads"); return store, GitOpsJobController(store)
|
||||
root = self.job_root / _safe(task_id); store = JobStore(root, payload_root=root / "payloads"); return store, GitOpsJobController(store)
|
||||
|
||||
def _metadata(self, task_id: str) -> Path:
|
||||
return self.task_meta_root / (_task_key(task_id) + ".json")
|
||||
|
||||
def _legacy_metadata(self, task_id: str) -> Path:
|
||||
return self.task_meta_root / (_legacy_safe(task_id) + ".json")
|
||||
|
||||
@contextmanager
|
||||
def _task_lock(self, task_id: str):
|
||||
path = self.lock_root / (_task_key(task_id) + ".lock")
|
||||
with path.open("a+b") as handle:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
@contextmanager
|
||||
def _branch_lock(self, repository: str, target_branch: str):
|
||||
key = hashlib.sha256((repository + "\0" + target_branch).encode("utf-8")).hexdigest()
|
||||
path = self.lock_root / ("branch-" + key + ".lock")
|
||||
with path.open("a+b") as handle:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
return self.task_meta_root / (_safe(task_id) + ".json")
|
||||
|
||||
def _read_existing(self, task_id: str) -> GovernedTaskBinding | None:
|
||||
path = self._metadata(task_id)
|
||||
if not path.exists():
|
||||
path = self._legacy_metadata(task_id)
|
||||
if not path.is_file() or path.is_symlink(): return None
|
||||
binding = GovernedTaskBinding(**json.loads(path.read_text(encoding="utf-8")))
|
||||
if binding.task_id != task_id:
|
||||
raise BootstrapError("TASK_BINDING_KEY_COLLISION")
|
||||
return binding
|
||||
|
||||
def get_binding(self, task_id: str) -> GovernedTaskBinding:
|
||||
"""Return the exact durable binding; never synthesize missing lineage."""
|
||||
existing = self._read_existing(str(task_id))
|
||||
if existing is None or not existing.job_id:
|
||||
raise BootstrapError("TASK_EXECUTION_PROJECTION_MISSING")
|
||||
return existing
|
||||
|
||||
def execution_binding(self, binding: GovernedTaskBinding) -> GovernedExecutionBinding:
|
||||
expected = binding.expected_branch_oid
|
||||
mode = binding.branch_mode
|
||||
if mode == "CREATE_NEW":
|
||||
current = self.authority.read_ref("refs/heads/" + binding.target_branch)
|
||||
if current is not None:
|
||||
store, _ = self._store(binding.task_id)
|
||||
with store._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT current_head_oid,generation,state,lineage_anchor_oid "
|
||||
"FROM branch_leases WHERE job_id=? AND branch=? AND state='ACTIVE' "
|
||||
"ORDER BY revision DESC",
|
||||
(binding.job_id, binding.target_branch),
|
||||
).fetchall()
|
||||
if len(rows) != 1:
|
||||
raise BootstrapError("SAME_JOB_CONTINUATION_LEASE_INVALID")
|
||||
row = rows[0]
|
||||
if (
|
||||
str(row["current_head_oid"] or "") != current
|
||||
or int(row["generation"] or 0) != binding.generation
|
||||
or str(row["lineage_anchor_oid"] or "") != binding.base_oid
|
||||
):
|
||||
raise BootstrapError("SAME_JOB_CONTINUATION_BINDING_INVALID")
|
||||
expected = current
|
||||
mode = "CONTINUE_EXISTING"
|
||||
return GovernedExecutionBinding.from_mapping(
|
||||
{
|
||||
"task_id": binding.task_id,
|
||||
"job_id": binding.job_id,
|
||||
"repository": binding.repository,
|
||||
"base_oid": binding.base_oid,
|
||||
"expected_branch_oid": expected,
|
||||
"target_branch": binding.target_branch,
|
||||
"branch_mode": mode,
|
||||
"generation": binding.generation,
|
||||
"idempotency_key": binding.idempotency_key,
|
||||
}
|
||||
)
|
||||
|
||||
def execution_envelope(
|
||||
self,
|
||||
task_id: str,
|
||||
execution_request: Mapping[str, Any] | None = None,
|
||||
*,
|
||||
required_capabilities: tuple[str, ...] = (),
|
||||
expected_job_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
binding = self.get_binding(task_id)
|
||||
if expected_job_id and binding.job_id != expected_job_id:
|
||||
raise BootstrapError("JOB_BINDING_CONFLICT")
|
||||
return project_execution_envelope(
|
||||
self.execution_binding(binding),
|
||||
execution_request,
|
||||
required_capabilities=required_capabilities,
|
||||
)
|
||||
|
||||
def online_task_projection(
|
||||
self,
|
||||
task_id: str,
|
||||
task_request: Mapping[str, Any],
|
||||
execution_request: Mapping[str, Any] | None = None,
|
||||
*,
|
||||
expected_job_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
binding = self.get_binding(task_id)
|
||||
if expected_job_id and binding.job_id != expected_job_id:
|
||||
raise BootstrapError("JOB_BINDING_CONFLICT")
|
||||
return project_online_task(self.execution_binding(binding), task_request, execution_request)
|
||||
return GovernedTaskBinding(**json.loads(path.read_text(encoding="utf-8")))
|
||||
|
||||
def _write(self, binding: GovernedTaskBinding) -> None:
|
||||
path = self._metadata(binding.task_id); temporary = path.with_suffix(".tmp"); temporary.write_text(json.dumps(asdict(binding), sort_keys=True), encoding="utf-8"); temporary.replace(path)
|
||||
|
||||
def create(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
task_id = str(request.get("task_id") or "")
|
||||
if not task_id:
|
||||
raise BootstrapError("TASK_BINDING_INVALID")
|
||||
repository = str(request.get("repository") or "")
|
||||
target_branch = str(request.get("target_branch") or "")
|
||||
with self._task_lock(task_id):
|
||||
with self._branch_lock(repository, target_branch):
|
||||
return self._create_locked(request)
|
||||
|
||||
def _create_locked(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
values = {key: str(request[key]) for key in ("task_id", "repository", "base_oid", "target_branch", "risk", "description", "idempotency_key")}
|
||||
if values["repository"] != "aios/aios" or not OID_RE.fullmatch(values["base_oid"]): raise BootstrapError("TASK_BINDING_INVALID")
|
||||
if not BRANCH_RE.fullmatch(values["target_branch"]): raise BootstrapError("TARGET_BRANCH_INVALID")
|
||||
existing = self._read_existing(values["task_id"])
|
||||
if existing is not None:
|
||||
if any(getattr(existing, key) != values[key] for key in ("repository", "base_oid", "target_branch", "risk", "description", "idempotency_key")): raise BootstrapError("TASK_BINDING_CONFLICT")
|
||||
optional = {"expected_branch_oid": existing.expected_branch_oid, "branch_mode": existing.branch_mode, "generation": existing.generation}
|
||||
for key, durable in optional.items():
|
||||
if key in request and str(request[key]) != str(durable): raise BootstrapError("TASK_BINDING_CONFLICT")
|
||||
if any(getattr(existing, key) != values[key] for key in ("repository", "base_oid", "target_branch", "risk", "description")): raise BootstrapError("TASK_BINDING_CONFLICT")
|
||||
return existing.response(idempotent=True)
|
||||
if not self.authority.commit_exists(values["base_oid"]): raise BootstrapError("BASE_COMMIT_NOT_FOUND")
|
||||
current = self.authority.read_ref("refs/heads/" + values["target_branch"])
|
||||
@@ -207,61 +74,8 @@ class GovernedTaskBootstrap:
|
||||
if expected != values["base_oid"]: raise BootstrapError("EXPECTED_BASE_CONFLICT")
|
||||
else:
|
||||
if expected != current: raise BootstrapError("EXISTING_BRANCH_OID_CONFLICT")
|
||||
parent_task_id = str(request.get("parent_task_id") or "")
|
||||
parent_job_id = str(request.get("parent_job_id") or "")
|
||||
if not parent_task_id or not parent_job_id:
|
||||
raise BootstrapError("EXISTING_BRANCH_OWNERSHIP_REQUIRED")
|
||||
parent = self._read_existing(parent_task_id)
|
||||
if parent is None or parent.job_id != parent_job_id or parent.repository != values["repository"] or parent.target_branch != values["target_branch"]:
|
||||
raise BootstrapError("EXISTING_BRANCH_OWNERSHIP_CONFLICT")
|
||||
store, _ = self._store(parent_task_id)
|
||||
with store._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT current_head_oid,generation,state FROM branch_leases WHERE job_id=? AND branch=? AND state='ACTIVE' ORDER BY revision DESC",
|
||||
(parent_job_id, values["target_branch"]),
|
||||
).fetchall()
|
||||
if len(rows) != 1 or str(rows[0]["current_head_oid"] or "") != current:
|
||||
raise BootstrapError("EXISTING_BRANCH_OWNERSHIP_CONFLICT")
|
||||
mode, generation = "CONTINUE_EXISTING", int(rows[0]["generation"]) + 1
|
||||
if "generation" in request and int(request["generation"]) != generation:
|
||||
raise BootstrapError("GENERATION_BINDING_CONFLICT")
|
||||
online_request = request.get("online_task")
|
||||
execution_request = request.get("execution")
|
||||
projection_required = (
|
||||
online_request is not None
|
||||
or execution_request is not None
|
||||
or request.get("require_online_task_projection") is True
|
||||
)
|
||||
if projection_required:
|
||||
if not isinstance(online_request, Mapping):
|
||||
raise BootstrapError("ONLINE_TASK_REQUEST_INVALID")
|
||||
if execution_request is not None and not isinstance(execution_request, Mapping):
|
||||
raise BootstrapError("EXECUTION_REQUEST_INVALID")
|
||||
provisional_job_id = "job-" + sha256_json(
|
||||
{"client_idempotency_key": values["idempotency_key"]}
|
||||
)[:32]
|
||||
try:
|
||||
provisional_binding = GovernedExecutionBinding.from_mapping(
|
||||
{
|
||||
"task_id": values["task_id"],
|
||||
"job_id": provisional_job_id,
|
||||
"repository": values["repository"],
|
||||
"base_oid": values["base_oid"],
|
||||
"expected_branch_oid": expected,
|
||||
"target_branch": values["target_branch"],
|
||||
"branch_mode": mode,
|
||||
"generation": generation,
|
||||
"idempotency_key": values["idempotency_key"],
|
||||
}
|
||||
)
|
||||
project_online_task(
|
||||
provisional_binding,
|
||||
dict(online_request),
|
||||
dict(execution_request or {}),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise BootstrapError("EXECUTION_BINDING_INVALID") from exc
|
||||
|
||||
mode, generation = "CONTINUE_EXISTING", int(request.get("generation", 2))
|
||||
if generation < 1: raise BootstrapError("GENERATION_INVALID")
|
||||
payload = json.dumps(values, sort_keys=True, separators=(",", ":")).encode()
|
||||
_, controller = self._store(values["task_id"])
|
||||
receipt = controller.submit({"idempotency_key": values["idempotency_key"], "campaign_id": values["task_id"], "task_id": values["task_id"], "repository": values["repository"], "operation": "GOVERNED_TASK_CREATE", "branch": values["target_branch"], "expected_branch_oid": expected, "payload_digest": hashlib.sha256(payload).hexdigest(), "lineage_anchor_oid": values["base_oid"]}, payload=payload)
|
||||
|
||||
@@ -3,16 +3,8 @@ 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 aios_caller_admission_projection import (
|
||||
CALLER,
|
||||
SCOPE,
|
||||
GOVERNED_TASK_SCOPE,
|
||||
PROJECTED_SCOPES,
|
||||
CallerProjectionClient,
|
||||
CallerProjectionError,
|
||||
)
|
||||
from baseline_authority import BaselineAuthority, BaselineAuthorityError
|
||||
from silent_release_controller import ReleaseCandidate
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
@@ -37,11 +29,18 @@ from orchestrator.action_monitor import ActionMonitor
|
||||
from silent_release_hardening import (
|
||||
ApprovalKeyRing,
|
||||
ApprovalRevocationStore,
|
||||
BuilderAttestation,
|
||||
BuilderAttestationKeyRing,
|
||||
BuilderAttestationVerifier,
|
||||
CapabilityAuthorizationError,
|
||||
ReleasePlan,
|
||||
SagaConflict,
|
||||
SignedApprovalVerifier,
|
||||
SignedReleasePlanApproval,
|
||||
SilentReleaseActionService,
|
||||
TrustedCandidateBuilderUnavailable,
|
||||
TrustedCandidateBundle,
|
||||
TrustedReleasePolicy,
|
||||
)
|
||||
try:
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobError, ServiceUnavailable, JobStore
|
||||
@@ -91,18 +90,33 @@ SILENT_RELEASE_STATE_ROOT = Path(os.getenv(
|
||||
'AIOS_SILENT_RELEASE_STATE_ROOT',
|
||||
'/var/lib/aios-gitops/silent-release',
|
||||
))
|
||||
SILENT_RELEASE_EXECUTION_ENABLED = str(os.getenv(
|
||||
'AIOS_SILENT_RELEASE_EXECUTION_ENABLED', 'false'
|
||||
)).lower() in {'1', 'true', 'yes', 'on'}
|
||||
# This immutable staging API can prepare only a shadow candidate. An
|
||||
# environment value must not turn its HTTP process into a release executor.
|
||||
SILENT_RELEASE_EXECUTION_ENABLED = False
|
||||
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',
|
||||
TRUSTED_BUILDER_ENABLED = str(os.getenv(
|
||||
'AIOS_TRUSTED_CANDIDATE_BUILDER_ENABLED', 'false'
|
||||
)).lower() in {'1', 'true', 'yes', 'on'}
|
||||
TRUSTED_BUILDER_POLICY = Path(os.getenv(
|
||||
'AIOS_TRUSTED_CANDIDATE_BUILDER_POLICY', ''
|
||||
))
|
||||
TRUSTED_BUILDER_CREDENTIALS_DIRECTORY = Path(os.getenv(
|
||||
'AIOS_TRUSTED_CANDIDATE_BUILDER_CREDENTIALS_DIRECTORY',
|
||||
os.getenv('CREDENTIALS_DIRECTORY', ''),
|
||||
))
|
||||
TRUSTED_BUILDER_SOCKET = Path(os.getenv(
|
||||
'AIOS_TRUSTED_CANDIDATE_BUILDER_SOCKET',
|
||||
'/run/aios-trusted-candidate-builder.sock',
|
||||
))
|
||||
TRUSTED_BUILDER_PUBLIC_KEYRING = Path(os.getenv(
|
||||
'AIOS_TRUSTED_CANDIDATE_BUILDER_PUBLIC_KEYRING', ''
|
||||
))
|
||||
TRUSTED_RELEASE_POLICY = Path(os.getenv(
|
||||
'AIOS_SILENT_RELEASE_TRUSTED_POLICY', ''
|
||||
))
|
||||
CALLER_PROJECTION_CLIENT = CallerProjectionClient(CALLER_PROJECTION_SOCKET)
|
||||
monitor = ActionMonitor()
|
||||
|
||||
BASE_MODELS = [
|
||||
@@ -126,9 +140,6 @@ 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',
|
||||
@@ -141,25 +152,6 @@ SILENT_RELEASE_POST_PATHS = frozenset({
|
||||
'/startDeployment',
|
||||
})
|
||||
|
||||
GOVERNED_EXECUTION_CAPABILITIES = frozenset({
|
||||
"governed.write.branch",
|
||||
"governed.write.file",
|
||||
})
|
||||
|
||||
|
||||
def requires_governed_execution(value: Mapping[str, Any]) -> bool:
|
||||
capabilities = value.get("required_capabilities")
|
||||
if capabilities is None:
|
||||
return False
|
||||
if not isinstance(capabilities, list):
|
||||
return True
|
||||
return bool(
|
||||
GOVERNED_EXECUTION_CAPABILITIES.intersection(
|
||||
{str(item) for item in capabilities}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def reject_non_read_http(handler, method: str, path: str) -> bool:
|
||||
if method == 'GET':
|
||||
return False
|
||||
@@ -202,6 +194,79 @@ def _file_sha256(path: Path) -> str:
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _trusted_release_policy_from_file(path: Path) -> TrustedReleasePolicy:
|
||||
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||
required = {
|
||||
'services', 'release_roots', 'cutover_links', 'dropin_paths',
|
||||
'authoritative_git_dirs', 'repository_identities',
|
||||
}
|
||||
if not isinstance(payload, dict) or set(payload) != required:
|
||||
raise ValueError('trusted release policy schema is invalid')
|
||||
values = {}
|
||||
for name in required:
|
||||
raw = payload[name]
|
||||
if not isinstance(raw, list) or not raw or not all(
|
||||
isinstance(item, str) and item for item in raw
|
||||
):
|
||||
raise ValueError('trusted release policy values are invalid')
|
||||
values[name] = frozenset(raw)
|
||||
return TrustedReleasePolicy(**values)
|
||||
|
||||
|
||||
class _HostTrustedCandidateBuilderAdapter:
|
||||
"""Maps only a complete host-owned silent-release bundle response.
|
||||
|
||||
The API never manufactures candidate evidence or a builder signature. A
|
||||
host builder protocol that returns a different response is therefore
|
||||
unavailable, rather than silently downgraded to caller-owned evidence.
|
||||
"""
|
||||
|
||||
def __init__(self, client):
|
||||
self._client = client
|
||||
|
||||
def build(self, intent):
|
||||
import secrets
|
||||
from aios_trusted_candidate_builder import BuilderError
|
||||
|
||||
try:
|
||||
version = str(intent['version'])
|
||||
candidate_id = str(intent['candidate_id'])
|
||||
source_commit = str(intent['source_commit'])
|
||||
except (KeyError, TypeError) as exc:
|
||||
raise CapabilityAuthorizationError(
|
||||
'trusted builder requires a release worker request'
|
||||
) from exc
|
||||
try:
|
||||
response = self._client.build(
|
||||
version,
|
||||
candidate_id,
|
||||
source_commit,
|
||||
nonce=secrets.token_hex(32),
|
||||
)
|
||||
except BuilderError as exc:
|
||||
raise TrustedCandidateBuilderUnavailable(
|
||||
'trusted builder request failed'
|
||||
) from exc
|
||||
raw_bundle = response.get('silent_release_bundle')
|
||||
if not isinstance(raw_bundle, dict):
|
||||
raise CapabilityAuthorizationError(
|
||||
'trusted builder response is not a silent release bundle'
|
||||
)
|
||||
try:
|
||||
return TrustedCandidateBundle(
|
||||
candidate=ReleaseCandidate.from_dict(raw_bundle['candidate']),
|
||||
release_plan=ReleasePlan.from_dict(raw_bundle['release_plan']),
|
||||
checks=raw_bundle['checks'],
|
||||
attestation=BuilderAttestation.from_dict(
|
||||
raw_bundle['attestation'], require_fingerprint=True
|
||||
),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise CapabilityAuthorizationError(
|
||||
'trusted builder response is invalid'
|
||||
) from exc
|
||||
|
||||
def _build_silent_release_service() -> SilentReleaseActionService:
|
||||
verifier=None
|
||||
if SILENT_RELEASE_PUBLIC_KEYS.is_file():
|
||||
@@ -213,10 +278,86 @@ def _build_silent_release_service() -> SilentReleaseActionService:
|
||||
)
|
||||
policy_path=WS / 'config' / 'gitops' / 'silent-release-policy.json'
|
||||
policy_sha=_file_sha256(policy_path) if policy_path.is_file() else ''
|
||||
candidate_builder = None
|
||||
builder_attestation_verifier = None
|
||||
trusted_policy = None
|
||||
builder_readiness_probe = None
|
||||
if TRUSTED_BUILDER_ENABLED:
|
||||
# Every path is opt-in. In particular, this API receives its own
|
||||
# client HMAC credential, never the builder's signing private key.
|
||||
if not (
|
||||
TRUSTED_BUILDER_POLICY.is_file()
|
||||
and TRUSTED_BUILDER_CREDENTIALS_DIRECTORY.is_dir()
|
||||
and TRUSTED_BUILDER_PUBLIC_KEYRING.is_file()
|
||||
and TRUSTED_RELEASE_POLICY.is_file()
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
'trusted builder configuration is incomplete'
|
||||
)
|
||||
try:
|
||||
from aios_trusted_candidate_builder import (
|
||||
BuilderKeyring,
|
||||
TrustedCandidateBuilderConfig,
|
||||
UnixTrustedCandidateBuilderClient,
|
||||
load_builder_policy,
|
||||
readiness_probe,
|
||||
)
|
||||
builder_policy = load_builder_policy(TRUSTED_BUILDER_POLICY)
|
||||
builder_config = TrustedCandidateBuilderConfig.from_policy(
|
||||
builder_policy
|
||||
)
|
||||
client_hmac = (
|
||||
TRUSTED_BUILDER_CREDENTIALS_DIRECTORY / 'builder_hmac'
|
||||
).read_bytes().strip()
|
||||
client_keyring = BuilderKeyring(builder_hmac=client_hmac)
|
||||
client = UnixTrustedCandidateBuilderClient(
|
||||
builder_config,
|
||||
client_keyring,
|
||||
socket_path=TRUSTED_BUILDER_SOCKET,
|
||||
)
|
||||
candidate_builder = _HostTrustedCandidateBuilderAdapter(client)
|
||||
builder_attestation_verifier = BuilderAttestationVerifier(
|
||||
BuilderAttestationKeyRing.from_file(
|
||||
TRUSTED_BUILDER_PUBLIC_KEYRING
|
||||
)
|
||||
)
|
||||
trusted_policy = _trusted_release_policy_from_file(
|
||||
TRUSTED_RELEASE_POLICY
|
||||
)
|
||||
|
||||
def builder_readiness_probe():
|
||||
if not TRUSTED_BUILDER_SOCKET.exists():
|
||||
return {'status': 'UNAVAILABLE'}
|
||||
remote = dict(readiness_probe(client))
|
||||
if remote.get('policy_sha256') != builder_config.policy_sha256:
|
||||
return {'status': 'UNAVAILABLE'}
|
||||
builder_attestation_verifier.keyring.require_active_identity(
|
||||
key_id=str(remote.get('builder_key_id') or ''),
|
||||
generation=remote.get('builder_key_generation'),
|
||||
algorithm=str(remote.get('signature_algorithm') or ''),
|
||||
purpose=str(remote.get('signature_purpose') or ''),
|
||||
)
|
||||
return {
|
||||
**remote,
|
||||
'hmac_credential_loaded': True,
|
||||
'peer_verification_configured': True,
|
||||
'public_key_trusted': True,
|
||||
'policy_loaded': True,
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
raise CapabilityAuthorizationError(
|
||||
'trusted builder configuration is invalid'
|
||||
) from exc
|
||||
return SilentReleaseActionService(
|
||||
SILENT_RELEASE_STATE_ROOT,
|
||||
execution_enabled=SILENT_RELEASE_EXECUTION_ENABLED,
|
||||
approval_verifier=verifier,
|
||||
candidate_builder=candidate_builder,
|
||||
builder_attestation_verifier=builder_attestation_verifier,
|
||||
trusted_policy=trusted_policy,
|
||||
builder_readiness_probe=builder_readiness_probe,
|
||||
allow_legacy_candidate_input=False,
|
||||
expected_repository=REPOSITORY_IDENTITY,
|
||||
policy_sha256=policy_sha,
|
||||
)
|
||||
@@ -443,113 +584,14 @@ 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','')
|
||||
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 in PROJECTED_SCOPES:
|
||||
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
|
||||
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
|
||||
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'],
|
||||
GOVERNED_TASK_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',
|
||||
@@ -581,187 +623,31 @@ 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 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_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 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'):
|
||||
builder_readiness = silent_release_service().get_builder_readiness()
|
||||
return self.sendj({
|
||||
'status': 'ok',
|
||||
'process_liveness': 'PASS',
|
||||
'builder_readiness': builder_readiness,
|
||||
'service': os.getenv('AIOS_GITOPS_MODEL_API_SERVICE', 'aios-gitops-model-api-v2r2c1-staging.service'),
|
||||
'workspace': str(WS),
|
||||
'version': 'V2R2C1',
|
||||
@@ -994,70 +880,21 @@ 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]
|
||||
if path == '/createGovernedTask' and self.actor()['account'] == CALLER:
|
||||
scope = GOVERNED_TASK_SCOPE
|
||||
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':
|
||||
if requires_governed_execution(data):
|
||||
return self.sendj(
|
||||
{
|
||||
'error': 'governed_execution_binding_required',
|
||||
'hint': 'use createGovernedTask',
|
||||
},
|
||||
409,
|
||||
)
|
||||
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',
|
||||
projection_request={
|
||||
'source_commit': str(data.get('source_commit') or ''),
|
||||
'version': str(data.get('version') or ''),
|
||||
},
|
||||
)
|
||||
if not a:
|
||||
return
|
||||
a=self.need('release:candidate')
|
||||
if not a: return
|
||||
service = silent_release_service()
|
||||
readiness = service.get_builder_readiness()
|
||||
if readiness.get('status') != 'READY':
|
||||
return self.sendj({
|
||||
'error': 'TRUSTED_BUILDER_UNAVAILABLE',
|
||||
'builder_readiness': readiness,
|
||||
}, 503)
|
||||
return self.sendj(
|
||||
silent_release_service().create_release_candidate(data)
|
||||
service.create_release_candidate(data)
|
||||
)
|
||||
if path == '/createReleaseIntent':
|
||||
a=self.need('release:candidate')
|
||||
@@ -1097,6 +934,13 @@ class H(BaseHTTPRequestHandler):
|
||||
error=redact(e)
|
||||
monitor.record_failure('do_POST', error, {'path':path})
|
||||
return self.sendj({'error':error},403)
|
||||
except TrustedCandidateBuilderUnavailable:
|
||||
monitor.record_failure(
|
||||
'do_POST', 'TRUSTED_BUILDER_UNAVAILABLE', {'path': path}
|
||||
)
|
||||
return self.sendj(
|
||||
{'error': 'TRUSTED_BUILDER_UNAVAILABLE'}, 503
|
||||
)
|
||||
except SagaConflict as e:
|
||||
error=redact(e)
|
||||
monitor.record_failure('do_POST', error, {'path':path})
|
||||
@@ -1346,7 +1190,7 @@ def api_spec():
|
||||
return {
|
||||
'version': 'V2R2C1-IMMUTABLE-STAGING',
|
||||
'openapi': '/api/openapi.yaml',
|
||||
'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'],
|
||||
'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'],
|
||||
'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,
|
||||
@@ -1361,9 +1205,6 @@ 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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -179,7 +179,10 @@ def _agent_runtime_input(value: object) -> dict[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
_error("AGENT_RUNTIME_INPUT_REQUIRED")
|
||||
raw = dict(value)
|
||||
allowed = {"goal", "instructions", "success_criteria", "constraints"}
|
||||
allowed = {
|
||||
"goal", "instructions", "success_criteria", "constraints",
|
||||
"scope", "trace_id",
|
||||
}
|
||||
if set(raw) - allowed:
|
||||
_error("AGENT_RUNTIME_INPUT_FIELDS_INVALID")
|
||||
goal = _string(raw.get("goal"), "AGENT_RUNTIME_GOAL_REQUIRED")
|
||||
@@ -208,11 +211,34 @@ def _agent_runtime_input(value: object) -> dict[str, Any]:
|
||||
lowered = str(key).lower()
|
||||
if any(marker in lowered for marker in sensitive_markers):
|
||||
_error("AGENT_RUNTIME_SECRET_FIELD_DENY")
|
||||
|
||||
scope = raw.get("scope", {})
|
||||
if not isinstance(scope, Mapping):
|
||||
_error("AGENT_RUNTIME_SCOPE_INVALID")
|
||||
scope = {
|
||||
"scope_type": str(scope.get("scope_type") or "legacy"),
|
||||
"tenant_id": scope.get("tenant_id"),
|
||||
"project_id": scope.get("project_id"),
|
||||
}
|
||||
if scope["scope_type"] not in {"legacy", "system", "tenant"}:
|
||||
_error("AGENT_RUNTIME_SCOPE_INVALID")
|
||||
if scope["scope_type"] == "tenant":
|
||||
if not isinstance(scope["tenant_id"], str) or not scope["tenant_id"].strip():
|
||||
_error("AGENT_RUNTIME_SCOPE_INVALID")
|
||||
elif scope["tenant_id"] not in (None, "") or scope["project_id"] not in (None, ""):
|
||||
_error("AGENT_RUNTIME_SCOPE_INVALID")
|
||||
|
||||
trace_id = str(raw.get("trace_id") or "").strip()
|
||||
if not trace_id or len(trace_id) > 255:
|
||||
_error("AGENT_RUNTIME_TRACE_ID_INVALID")
|
||||
|
||||
normalized = {
|
||||
"goal": goal[:12000],
|
||||
"instructions": instructions[:32000],
|
||||
"success_criteria": normalized_criteria,
|
||||
"constraints": dict(constraints),
|
||||
"scope": scope,
|
||||
"trace_id": trace_id,
|
||||
}
|
||||
if len(_canonical(normalized).encode("utf-8")) > MAX_AGENT_RUNTIME_INPUT_BYTES:
|
||||
_error("AGENT_RUNTIME_INPUT_TOO_LARGE")
|
||||
@@ -425,6 +451,12 @@ def project_online_task(
|
||||
if adapter not in AGENT_RUNTIME_ADAPTERS:
|
||||
_error("AGENT_RUNTIME_ADAPTER_INVALID")
|
||||
runtime_input = execution_value.get("runtime_input")
|
||||
tenant_id = request.get("tenant_id")
|
||||
project_id = request.get("project_id")
|
||||
declared_scope = str(
|
||||
request.get("scope_type")
|
||||
or ("tenant" if isinstance(tenant_id, str) and tenant_id.strip() else "legacy")
|
||||
)
|
||||
if runtime_input is None:
|
||||
runtime_input = {
|
||||
"goal": request.get("goal"),
|
||||
@@ -432,6 +464,20 @@ def project_online_task(
|
||||
"success_criteria": request.get("success_criteria") or [],
|
||||
"constraints": request.get("constraints") or {},
|
||||
}
|
||||
elif not isinstance(runtime_input, Mapping):
|
||||
_error("AGENT_RUNTIME_INPUT_REQUIRED")
|
||||
runtime_input = dict(runtime_input)
|
||||
# Scope/trace are authoritative Task identity, never caller-selected runtime hints.
|
||||
runtime_input["scope"] = {
|
||||
"scope_type": declared_scope,
|
||||
"tenant_id": tenant_id,
|
||||
"project_id": project_id,
|
||||
}
|
||||
runtime_input["trace_id"] = str(
|
||||
request.get("trace_id")
|
||||
or request.get("run_id")
|
||||
or binding.task_id
|
||||
)
|
||||
execution_value["runtime_input"] = runtime_input
|
||||
execution = project_execution_envelope(
|
||||
binding,
|
||||
|
||||
@@ -1910,8 +1910,6 @@ class WorkerSupervisor:
|
||||
# engineering placeholder: never pass that stale payload back to the
|
||||
# resolver, and never claim it until a deterministic plan exists.
|
||||
pending_plan = source_capture_requires_deterministic_plan(persisted)
|
||||
if not pending_plan:
|
||||
return persisted, "persisted_verified"
|
||||
projection_input = (
|
||||
execution
|
||||
if persisted.action in {"source_capture", "agent_runtime"} and not pending_plan
|
||||
@@ -1960,7 +1958,25 @@ class WorkerSupervisor:
|
||||
phase="projection",
|
||||
)
|
||||
return persisted, "persisted_verified"
|
||||
raise WorkerError("TASK_SKIPPED_PROJECTION_MISSING", phase="projection")
|
||||
status, projected = self.bridge.project_execution(task)
|
||||
if status == 404 or projected.get("error") == "TASK_EXECUTION_PROJECTION_MISSING":
|
||||
raise WorkerError("TASK_SKIPPED_PROJECTION_MISSING", phase="projection")
|
||||
if not 200 <= status < 300:
|
||||
raise WorkerError(
|
||||
"TASK_SKIPPED_POLICY_DENY",
|
||||
phase="projection",
|
||||
details={"api_status": status, "error": projected.get("error")},
|
||||
)
|
||||
envelope = ExecutionEnvelope.from_task(task, projected, WORKER_CAPABILITIES)
|
||||
audit(
|
||||
"TASK_EXECUTION_ENVELOPE_PROJECTED",
|
||||
task_id=envelope.task_id,
|
||||
job_id=envelope.job_id,
|
||||
generation=envelope.generation,
|
||||
action=envelope.action,
|
||||
execution_digest=envelope.digest,
|
||||
)
|
||||
return envelope, "recovered_projection"
|
||||
|
||||
def process_claim(
|
||||
self,
|
||||
@@ -2432,21 +2448,23 @@ class WorkerSupervisor:
|
||||
write_worker_liveness("POLL_OK", visible_count=len(tasks))
|
||||
audit("TASK_POLL", visible_count=len(tasks))
|
||||
except WorkerError as exc:
|
||||
write_worker_liveness("POLL_FAILED", error=exc.code)
|
||||
write_worker_liveness(
|
||||
"POLL_FAILED",
|
||||
error=exc.code,
|
||||
)
|
||||
audit("TASK_POLL_FAILED", error=exc.code, phase=exc.phase, details=exc.details)
|
||||
return 10
|
||||
dynamic_tasks: list[tuple[dict[str, Any], ExecutionEnvelope, str]] = []
|
||||
dynamic_tasks: list[tuple[dict[str, Any], ExecutionEnvelope]] = []
|
||||
for task in tasks:
|
||||
try:
|
||||
metadata = task.get("metadata")
|
||||
execution = metadata.get("execution") if isinstance(metadata, Mapping) else None
|
||||
if not isinstance(execution, Mapping):
|
||||
raise WorkerError("TASK_SKIPPED_PROJECTION_MISSING", phase="projection")
|
||||
envelope = ExecutionEnvelope.from_task(task, worker_capabilities=WORKER_CAPABILITIES)
|
||||
source = "pending_plan" if source_capture_requires_deterministic_plan(envelope) else "persisted_verified"
|
||||
envelope, source = self.resolve_envelope(task)
|
||||
except WorkerError as exc:
|
||||
event = "TASK_SKIPPED_ENVELOPE_INVALID"
|
||||
if exc.code in {"TASK_SKIPPED_PROJECTION_MISSING", "TASK_SKIPPED_POLICY_DENY", "TASK_SKIPPED_PENDING_DETERMINISTIC_PLAN"}:
|
||||
if exc.code in {
|
||||
"TASK_SKIPPED_PROJECTION_MISSING",
|
||||
"TASK_SKIPPED_POLICY_DENY",
|
||||
"TASK_SKIPPED_PENDING_DETERMINISTIC_PLAN",
|
||||
}:
|
||||
event = exc.code
|
||||
elif exc.code == "CAPABILITY_MISMATCH_DENY":
|
||||
event = "TASK_SKIPPED_CAPABILITY_MISMATCH"
|
||||
@@ -2454,11 +2472,16 @@ class WorkerSupervisor:
|
||||
continue
|
||||
audit("TASK_VISIBLE_TO_WORKER_CLAIMABLE", task_id=task.get("task_id"), envelope_source=source, action=envelope.action)
|
||||
if envelope.adapter not in WORKER_ALLOWED_ADAPTERS:
|
||||
audit("TASK_SKIPPED_ADAPTER_MISMATCH", task_id=task.get("task_id"), adapter=envelope.adapter, allowed_adapters=sorted(WORKER_ALLOWED_ADAPTERS))
|
||||
audit(
|
||||
"TASK_SKIPPED_ADAPTER_MISMATCH",
|
||||
task_id=task.get("task_id"),
|
||||
adapter=envelope.adapter,
|
||||
allowed_adapters=sorted(WORKER_ALLOWED_ADAPTERS),
|
||||
)
|
||||
continue
|
||||
dynamic_tasks.append((task, envelope, source))
|
||||
dynamic_tasks.sort(key=lambda item: _claim_order(item[0]))
|
||||
for task, envelope, source in dynamic_tasks:
|
||||
dynamic_tasks.append((task, envelope))
|
||||
dynamic_tasks.sort(key=_claim_order)
|
||||
for task, envelope in dynamic_tasks:
|
||||
task_id = str(task.get("task_id") or "")
|
||||
status, claim = self.agent.claim(task_id)
|
||||
if status == 409:
|
||||
@@ -2467,19 +2490,6 @@ class WorkerSupervisor:
|
||||
if status != 200:
|
||||
audit("TASK_CLAIM_FAILED", task_id=task_id, api_status=status, error=claim.get("error"))
|
||||
continue
|
||||
if source == "pending_plan":
|
||||
current_status, current_task = self.agent.get(task_id)
|
||||
if current_status == 200:
|
||||
try:
|
||||
envelope, _ = self.resolve_envelope(current_task)
|
||||
except WorkerError as exc:
|
||||
audit("TASK_PLAN_RESOLUTION_FAILED", task_id=task_id, reason=exc.code, phase=exc.phase, details=exc.details)
|
||||
self.process_claim(task, claim, envelope)
|
||||
return 0
|
||||
else:
|
||||
audit("TASK_READ_AFTER_CLAIM_FAILED", task_id=task_id, api_status=current_status)
|
||||
self.process_claim(task, claim, envelope)
|
||||
return 0
|
||||
self.process_claim(task, claim, envelope)
|
||||
return 0
|
||||
return 0
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
"""Credential-isolated durable coordinator for the release-preparation supervisor.
|
||||
|
||||
The coordinator owns only its Online Task agent credential. It never forwards
|
||||
that credential to Hermes/OpenClaw and never submits a Task result while the
|
||||
supervision goal is incomplete. Its job is deliberately small: claim the one
|
||||
fixed supervisor Task after an exact coordinator projection and keep a durable
|
||||
heartbeat lease while later phases observe and repair through governed APIs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Any, Mapping
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
API_BASE = os.environ.get(
|
||||
"AIOS_AGENT_TASK_API_URL", "http://127.0.0.1:18940"
|
||||
).rstrip("/")
|
||||
AGENT_ID = os.environ.get(
|
||||
"AIOS_AGENT_ID",
|
||||
"online-engineering-worker-cn-network-mkt-autonomous-20260815",
|
||||
)
|
||||
TASK_ID = os.environ.get(
|
||||
"AIOS_RELEASE_PREPARATION_SUPERVISOR_TASK_ID",
|
||||
"r42-release-preparation-autonomous-supervisor-20260820",
|
||||
)
|
||||
EXPECTED_EXECUTION_DIGEST = os.environ.get(
|
||||
"AIOS_RELEASE_PREPARATION_SUPERVISOR_EXECUTION_DIGEST", ""
|
||||
)
|
||||
RUNTIME_CAPABILITY = "agent.runtime.openclaw"
|
||||
COORDINATOR_CAPABILITY = "engineering.autonomous"
|
||||
REQUIRED_CAPABILITIES = [RUNTIME_CAPABILITY, COORDINATOR_CAPABILITY]
|
||||
POLL_SECONDS = int(os.environ.get("AIOS_SUPERVISOR_COORDINATOR_POLL_SECONDS", "5"))
|
||||
HEARTBEAT_SECONDS = int(
|
||||
os.environ.get("AIOS_SUPERVISOR_COORDINATOR_HEARTBEAT_SECONDS", "10")
|
||||
)
|
||||
LEASE_SECONDS = int(os.environ.get("AIOS_SUPERVISOR_COORDINATOR_LEASE_SECONDS", "300"))
|
||||
STATE_ROOT = Path(
|
||||
os.environ.get(
|
||||
"AIOS_SUPERVISOR_COORDINATOR_STATE_ROOT",
|
||||
"/var/lib/aios-release-preparation-supervisor-coordinator",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class CoordinatorError(RuntimeError):
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def canonical(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def atomic_json(path: Path, value: Mapping[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, mode=0o750, exist_ok=True)
|
||||
if path.parent.is_symlink() or path.is_symlink():
|
||||
raise CoordinatorError("COORDINATOR_STATE_SYMLINK_REJECTED")
|
||||
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
|
||||
try:
|
||||
os.fchmod(fd, 0o640)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(dict(value), 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)
|
||||
|
||||
|
||||
def audit(event_type: str, **fields: Any) -> None:
|
||||
record = {
|
||||
"event_type": event_type,
|
||||
"created_at": now(),
|
||||
"agent_id": AGENT_ID,
|
||||
"task_id": TASK_ID,
|
||||
"credential_material_exposed": False,
|
||||
"planner_credential_material_exposed": False,
|
||||
"lease_material_exposed": False,
|
||||
**fields,
|
||||
}
|
||||
STATE_ROOT.mkdir(parents=True, mode=0o750, exist_ok=True)
|
||||
with (STATE_ROOT / "events.jsonl").open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
atomic_json(STATE_ROOT / "liveness.json", record)
|
||||
|
||||
|
||||
def api_call(
|
||||
method: str,
|
||||
route: str,
|
||||
token: str,
|
||||
body: Mapping[str, Any] | None = None,
|
||||
) -> tuple[int, dict[str, Any]]:
|
||||
payload = None if body is None else canonical(dict(body))
|
||||
headers = {"Authorization": "Bearer " + token, "Accept": "application/json"}
|
||||
if payload is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = urllib.request.Request(
|
||||
API_BASE + route, data=payload, headers=headers, method=method
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
value = json.loads(response.read().decode("utf-8"))
|
||||
return response.status, value if isinstance(value, dict) else {}
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
value = json.loads(exc.read(4096).decode("utf-8", "replace"))
|
||||
except Exception:
|
||||
value = {"error": "HTTP_ERROR"}
|
||||
return exc.code, value if isinstance(value, dict) else {"error": "HTTP_ERROR"}
|
||||
except (urllib.error.URLError, TimeoutError):
|
||||
return 599, {"error": "AGENT_TASK_API_UNAVAILABLE"}
|
||||
|
||||
|
||||
def load_agent_token() -> str:
|
||||
path = Path(os.environ.get("CREDENTIALS_DIRECTORY", "")) / "agent_token"
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise CoordinatorError("COORDINATOR_AGENT_CREDENTIAL_UNAVAILABLE")
|
||||
token = path.read_text(encoding="utf-8").strip()
|
||||
if len(token) < 24 or any(character.isspace() for character in token):
|
||||
raise CoordinatorError("COORDINATOR_AGENT_CREDENTIAL_INVALID")
|
||||
return token
|
||||
|
||||
|
||||
def validate_task(task: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if task.get("task_id") != TASK_ID or task.get("status") != "QUEUED":
|
||||
raise CoordinatorError("SUPERVISOR_TASK_STATE_INVALID")
|
||||
if task.get("required_capabilities") != REQUIRED_CAPABILITIES:
|
||||
raise CoordinatorError("SUPERVISOR_COORDINATOR_CAPABILITIES_INVALID")
|
||||
metadata = task.get("metadata")
|
||||
if not isinstance(metadata, Mapping):
|
||||
raise CoordinatorError("SUPERVISOR_EXECUTION_METADATA_MISSING")
|
||||
execution = metadata.get("execution")
|
||||
if not isinstance(execution, Mapping):
|
||||
raise CoordinatorError("SUPERVISOR_EXECUTION_METADATA_MISSING")
|
||||
governance = execution.get("governance")
|
||||
if (
|
||||
execution.get("action") != "agent_runtime"
|
||||
or execution.get("adapter") != "openclaw"
|
||||
or execution.get("capability_scope") != REQUIRED_CAPABILITIES
|
||||
or execution.get("execution_digest") != EXPECTED_EXECUTION_DIGEST
|
||||
or execution.get("change_set") != []
|
||||
or execution.get("verification") != []
|
||||
or not isinstance(governance, Mapping)
|
||||
or governance.get("writer_lease") is not False
|
||||
or any(
|
||||
governance.get(field) is not False
|
||||
for field in (
|
||||
"main_write",
|
||||
"production_write",
|
||||
"tag_write",
|
||||
"baseline_write",
|
||||
"force_push",
|
||||
"protected_ref_bypass",
|
||||
)
|
||||
)
|
||||
):
|
||||
raise CoordinatorError("SUPERVISOR_COORDINATOR_ENVELOPE_INVALID")
|
||||
return dict(execution)
|
||||
|
||||
|
||||
def heartbeat_progress(sequence: int) -> dict[str, Any]:
|
||||
return {
|
||||
"stage": "supervisor_coordinator_active",
|
||||
"phase": "external_caller_projection",
|
||||
"state": "PENDING_EXTERNAL_CONNECTOR_ACK",
|
||||
"sequence": sequence,
|
||||
"control_plane_access_granted": True,
|
||||
"external_planner_role": "UNPRIVILEGED_PLANNER",
|
||||
"credential_forwarded_to_planner": False,
|
||||
"main_write": False,
|
||||
"production_write": False,
|
||||
"owner_approval_submitted": False,
|
||||
}
|
||||
|
||||
|
||||
def coordinate_claim(
|
||||
claim: Mapping[str, Any],
|
||||
token: str,
|
||||
*,
|
||||
max_heartbeats: int | None = None,
|
||||
) -> int:
|
||||
lease_token = claim.get("lease_token")
|
||||
if not isinstance(lease_token, str) or len(lease_token) < 24:
|
||||
raise CoordinatorError("COORDINATOR_LEASE_TOKEN_MISSING")
|
||||
sequence = 0
|
||||
while max_heartbeats is None or sequence < max_heartbeats:
|
||||
sequence += 1
|
||||
status, response = api_call(
|
||||
"POST",
|
||||
f"/v1/tasks/{TASK_ID}/heartbeat",
|
||||
token,
|
||||
{
|
||||
"lease_token": lease_token,
|
||||
"lease_seconds": LEASE_SECONDS,
|
||||
"progress": heartbeat_progress(sequence),
|
||||
},
|
||||
)
|
||||
if status != 200:
|
||||
raise CoordinatorError(
|
||||
"COORDINATOR_HEARTBEAT_FAILED_" + str(response.get("error") or status)
|
||||
)
|
||||
audit(
|
||||
"SUPERVISOR_COORDINATOR_HEARTBEAT",
|
||||
sequence=sequence,
|
||||
task_status=response.get("status"),
|
||||
lease_expires_at=response.get("lease_expires_at"),
|
||||
control_plane_access_granted=True,
|
||||
external_planner_role="UNPRIVILEGED_PLANNER",
|
||||
)
|
||||
if max_heartbeats is None or sequence < max_heartbeats:
|
||||
time.sleep(HEARTBEAT_SECONDS)
|
||||
return sequence
|
||||
|
||||
|
||||
def worker_once(token: str) -> bool:
|
||||
status, poll = api_call("POST", "/v1/tasks/poll", token, {"limit": 100})
|
||||
if status != 200:
|
||||
audit("SUPERVISOR_COORDINATOR_POLL_FAILED", error=poll.get("error") or status)
|
||||
return False
|
||||
task = next(
|
||||
(
|
||||
item
|
||||
for item in poll.get("tasks", [])
|
||||
if isinstance(item, Mapping) and item.get("task_id") == TASK_ID
|
||||
),
|
||||
None,
|
||||
)
|
||||
if task is None:
|
||||
audit("SUPERVISOR_COORDINATOR_WAITING_FOR_TASK")
|
||||
return False
|
||||
validate_task(task)
|
||||
status, claim = api_call(
|
||||
"POST", f"/v1/tasks/{TASK_ID}/claim", token, {"lease_seconds": LEASE_SECONDS}
|
||||
)
|
||||
if status != 200:
|
||||
audit("SUPERVISOR_COORDINATOR_CLAIM_FAILED", error=claim.get("error") or status)
|
||||
return False
|
||||
audit(
|
||||
"SUPERVISOR_COORDINATOR_CLAIMED",
|
||||
attempt=claim.get("attempt"),
|
||||
revision=claim.get("revision"),
|
||||
claim_mode="server_atomic",
|
||||
control_plane_access_granted=True,
|
||||
)
|
||||
coordinate_claim(claim, token)
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not EXPECTED_EXECUTION_DIGEST or len(EXPECTED_EXECUTION_DIGEST) != 64:
|
||||
raise CoordinatorError("SUPERVISOR_EXECUTION_DIGEST_CONFIG_INVALID")
|
||||
token = load_agent_token()
|
||||
audit(
|
||||
"SUPERVISOR_COORDINATOR_READY",
|
||||
required_capabilities=REQUIRED_CAPABILITIES,
|
||||
control_plane_access_granted=True,
|
||||
external_planner_role="UNPRIVILEGED_PLANNER",
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
worker_once(token)
|
||||
except Exception as exc:
|
||||
audit(
|
||||
"SUPERVISOR_COORDINATOR_LOOP_ERROR",
|
||||
error=exc.code if isinstance(exc, CoordinatorError) else type(exc).__name__,
|
||||
)
|
||||
time.sleep(POLL_SECONDS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,794 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail-closed AF_UNIX builder for server-owned trusted candidate evidence."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import stat
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
|
||||
class BuilderError(RuntimeError):
|
||||
"""A stable, non-sensitive error code returned by the builder boundary."""
|
||||
|
||||
|
||||
def _canonical_json(value: Mapping[str, Any]) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256(*parts: str) -> str:
|
||||
return hashlib.sha256("\0".join(parts).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PeerIdentity:
|
||||
pid: int
|
||||
uid: int
|
||||
gid: int
|
||||
|
||||
|
||||
class DomainPurposeSigner(Protocol):
|
||||
"""A signer deliberately limited to the builder's one domain purpose."""
|
||||
|
||||
@property
|
||||
def key_id(self) -> str:
|
||||
...
|
||||
|
||||
@property
|
||||
def key_generation(self) -> int:
|
||||
...
|
||||
|
||||
def sign_for_purpose(self, purpose: str, payload: bytes) -> bytes:
|
||||
...
|
||||
|
||||
|
||||
class CredentialPathEd25519Signer:
|
||||
"""Reads a systemd-mounted credential path; never exports or logs key bytes."""
|
||||
|
||||
def __init__(self, credential_path: Path, *, key_id: str, key_generation: int, allowed_purpose: str):
|
||||
self._credential_path = Path(credential_path)
|
||||
self._key_id = str(key_id)
|
||||
self._key_generation = key_generation
|
||||
self._allowed_purpose = str(allowed_purpose)
|
||||
if not self._key_id or not isinstance(key_generation, int) or key_generation < 1:
|
||||
raise BuilderError("SIGNING_IDENTITY_INVALID")
|
||||
if not self._credential_path.is_file():
|
||||
raise BuilderError("SIGNING_CREDENTIAL_UNAVAILABLE")
|
||||
try:
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
self._private_key = load_pem_private_key(self._credential_path.read_bytes(), password=None)
|
||||
if not isinstance(self._private_key, Ed25519PrivateKey):
|
||||
raise TypeError("signing key is not Ed25519")
|
||||
except Exception as exc: # do not expose parse/key material details
|
||||
raise BuilderError("SIGNING_CREDENTIAL_INVALID") from exc
|
||||
|
||||
@property
|
||||
def key_id(self) -> str:
|
||||
return self._key_id
|
||||
|
||||
@property
|
||||
def key_generation(self) -> int:
|
||||
return self._key_generation
|
||||
|
||||
def sign_for_purpose(self, purpose: str, payload: bytes) -> bytes:
|
||||
if purpose != self._allowed_purpose:
|
||||
raise BuilderError("SIGNING_PURPOSE_DENIED")
|
||||
return self._private_key.sign(payload)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BuilderKeyring:
|
||||
builder_hmac: bytes
|
||||
signing_private_key_path: Path | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if len(self.builder_hmac) < 32:
|
||||
raise BuilderError("BUILDER_HMAC_INVALID")
|
||||
|
||||
@classmethod
|
||||
def from_credentials_directory(cls, credentials_directory: Path) -> "BuilderKeyring":
|
||||
root = Path(credentials_directory)
|
||||
hmac_path = root / "builder_hmac"
|
||||
signing_path = root / "signing_private_key"
|
||||
try:
|
||||
secret = hmac_path.read_bytes().strip()
|
||||
except OSError as exc:
|
||||
raise BuilderError("BUILDER_HMAC_UNAVAILABLE") from exc
|
||||
if not signing_path.is_file():
|
||||
raise BuilderError("SIGNING_CREDENTIAL_UNAVAILABLE")
|
||||
return cls(builder_hmac=secret, signing_private_key_path=signing_path)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrustedCandidateBuilderConfig:
|
||||
policy_schema: str
|
||||
repository: str
|
||||
request_schema: str
|
||||
response_schema: str
|
||||
operation: str
|
||||
readiness_operation: str
|
||||
maximum_request_age_seconds: int
|
||||
request_timeout_seconds: int
|
||||
allowed_peer_uid: int
|
||||
allowed_peer_cgroups: tuple[str, ...]
|
||||
allowed_peer_executable_prefix: str
|
||||
allowed_peer_runtime_prefix: str
|
||||
allowed_peer_runtime_suffix: str
|
||||
required_checks: tuple[str, ...]
|
||||
evidence_layout: Mapping[str, str]
|
||||
recipes: Mapping[str, Mapping[str, str]]
|
||||
governed_recipes: Mapping[str, Mapping[str, str]]
|
||||
policy_sha256: str
|
||||
|
||||
@classmethod
|
||||
def from_policy(cls, policy: Mapping[str, Any]) -> "TrustedCandidateBuilderConfig":
|
||||
required = {
|
||||
"schema", "repository", "request_schema", "response_schema", "operation",
|
||||
"readiness_operation", "maximum_request_age_seconds", "request_timeout_seconds",
|
||||
"allowed_peer_uid", "allowed_peer_cgroups", "allowed_peer_executable_prefix",
|
||||
"allowed_peer_runtime_prefix", "allowed_peer_runtime_suffix",
|
||||
"required_checks", "evidence_layout", "recipes", "governed_recipes",
|
||||
}
|
||||
if set(policy) != required or policy.get("schema") != "aios.trusted-candidate-builder-policy.v1":
|
||||
raise BuilderError("POLICY_SCHEMA_INVALID")
|
||||
recipes = policy.get("recipes")
|
||||
if not isinstance(recipes, Mapping) or set(recipes) != {"V2R2C2", "V2R2C3"}:
|
||||
raise BuilderError("POLICY_RECIPES_INVALID")
|
||||
c2, c3 = recipes["V2R2C2"], recipes["V2R2C3"]
|
||||
if not isinstance(c2, Mapping) or set(c2) != {"mode", "task_id", "candidate_id", "base_oid", "source_commit", "version", "purpose"} or c2.get("mode") != "fixed":
|
||||
raise BuilderError("POLICY_V2R2C2_INVALID")
|
||||
if not isinstance(c3, Mapping) or set(c3) != {"mode", "derives_from", "version", "purpose"} or c3.get("mode") != "governed-derived-only" or c3.get("derives_from") != "V2R2C2":
|
||||
raise BuilderError("POLICY_V2R2C3_INVALID")
|
||||
if (
|
||||
policy["repository"] != "aios/aios"
|
||||
or policy["operation"] != "BuildTrustedCandidateV1"
|
||||
or policy["readiness_operation"] != "ProbeTrustedCandidateBuilderV1"
|
||||
):
|
||||
raise BuilderError("POLICY_SCOPE_INVALID")
|
||||
if not isinstance(policy["maximum_request_age_seconds"], int) or not 1 <= policy["maximum_request_age_seconds"] <= 300:
|
||||
raise BuilderError("POLICY_FRESHNESS_INVALID")
|
||||
if not isinstance(policy["request_timeout_seconds"], int) or not 1 <= policy["request_timeout_seconds"] <= 120:
|
||||
raise BuilderError("POLICY_TIMEOUT_INVALID")
|
||||
if not isinstance(policy["allowed_peer_uid"], int) or policy["allowed_peer_uid"] != 1000:
|
||||
raise BuilderError("POLICY_PEER_UID_INVALID")
|
||||
cgroups, checks = policy["allowed_peer_cgroups"], policy["required_checks"]
|
||||
if not isinstance(cgroups, list) or not cgroups or not all(isinstance(value, str) and value.startswith("/") for value in cgroups):
|
||||
raise BuilderError("POLICY_CGROUPS_INVALID")
|
||||
for field in (
|
||||
"allowed_peer_executable_prefix",
|
||||
"allowed_peer_runtime_prefix",
|
||||
"allowed_peer_runtime_suffix",
|
||||
):
|
||||
if not isinstance(policy[field], str) or not policy[field].startswith("/"):
|
||||
raise BuilderError("POLICY_PROCESS_IDENTITY_INVALID")
|
||||
if not isinstance(checks, list) or not checks or len(set(checks)) != len(checks) or not all(isinstance(value, str) for value in checks):
|
||||
raise BuilderError("POLICY_CHECKS_INVALID")
|
||||
layout = policy["evidence_layout"]
|
||||
evidence_names = {
|
||||
"artifact", "manifest", "package_closure", "tests", "canary",
|
||||
"gitea", "rollback", "policy", "secret_scan",
|
||||
}
|
||||
if (
|
||||
not isinstance(layout, Mapping)
|
||||
or set(layout) != evidence_names
|
||||
or not all(
|
||||
isinstance(value, str)
|
||||
and value.startswith("{version}/")
|
||||
and ".." not in Path(value).parts
|
||||
and not Path(value).is_absolute()
|
||||
for value in layout.values()
|
||||
)
|
||||
):
|
||||
raise BuilderError("POLICY_EVIDENCE_LAYOUT_INVALID")
|
||||
return cls(
|
||||
policy_schema=str(policy["schema"]), repository=str(policy["repository"]),
|
||||
request_schema=str(policy["request_schema"]), response_schema=str(policy["response_schema"]),
|
||||
operation=str(policy["operation"]), readiness_operation=str(policy["readiness_operation"]),
|
||||
maximum_request_age_seconds=policy["maximum_request_age_seconds"],
|
||||
request_timeout_seconds=policy["request_timeout_seconds"],
|
||||
allowed_peer_uid=policy["allowed_peer_uid"], allowed_peer_cgroups=tuple(cgroups),
|
||||
allowed_peer_executable_prefix=policy["allowed_peer_executable_prefix"],
|
||||
allowed_peer_runtime_prefix=policy["allowed_peer_runtime_prefix"],
|
||||
allowed_peer_runtime_suffix=policy["allowed_peer_runtime_suffix"],
|
||||
required_checks=tuple(checks), evidence_layout=dict(layout),
|
||||
recipes={name: dict(value) for name, value in recipes.items()},
|
||||
governed_recipes={name: dict(value) for name, value in policy["governed_recipes"].items()} if isinstance(policy["governed_recipes"], Mapping) else _invalid_governed_recipes(),
|
||||
policy_sha256=hashlib.sha256(_canonical_json(dict(policy))).hexdigest(),
|
||||
)
|
||||
|
||||
def fixed_recipe(self, name: str) -> Mapping[str, str]:
|
||||
recipe = self.recipes.get(name)
|
||||
if recipe is None:
|
||||
raise BuilderError("RECIPE_UNKNOWN")
|
||||
if recipe["mode"] == "governed-derived-only":
|
||||
derived = self.governed_recipes.get(name)
|
||||
required = {"mode", "task_id", "candidate_id", "base_oid", "source_commit", "version", "purpose", "derived_from_v2r2c2_attestation", "builder_engineering_oid"}
|
||||
if not isinstance(derived, Mapping) or set(derived) != required or derived.get("mode") != "governed-derived" or derived.get("version") != name or derived.get("purpose") != recipe.get("purpose"):
|
||||
raise BuilderError("GOVERNED_V2R2C3_DERIVATION_REQUIRED")
|
||||
for name in ("base_oid", "source_commit", "builder_engineering_oid"):
|
||||
value = derived.get(name)
|
||||
if not isinstance(value, str) or len(value) != 40 or any(char not in "0123456789abcdef" for char in value.lower()):
|
||||
raise BuilderError("GOVERNED_V2R2C3_DERIVATION_REQUIRED")
|
||||
return dict(derived)
|
||||
return recipe
|
||||
|
||||
def resolve_recipe(self, version: str, candidate_id: str, source_commit: str) -> Mapping[str, str]:
|
||||
recipe = self.fixed_recipe(version)
|
||||
if {"version": recipe.get("version"), "candidate_id": recipe.get("candidate_id"), "source_commit": recipe.get("source_commit")} != {"version": version, "candidate_id": candidate_id, "source_commit": source_commit}:
|
||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
||||
return recipe
|
||||
|
||||
|
||||
def _invalid_governed_recipes() -> Mapping[str, Mapping[str, str]]:
|
||||
raise BuilderError("POLICY_GOVERNED_RECIPES_INVALID")
|
||||
|
||||
|
||||
def load_builder_policy(path: Path) -> Mapping[str, Any]:
|
||||
try:
|
||||
decoded = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise BuilderError("POLICY_UNAVAILABLE") from exc
|
||||
if not isinstance(decoded, Mapping):
|
||||
raise BuilderError("POLICY_SCHEMA_INVALID")
|
||||
TrustedCandidateBuilderConfig.from_policy(decoded)
|
||||
return decoded
|
||||
|
||||
|
||||
class ServerOwnedEvidenceSource:
|
||||
"""Recomputes a fixed on-host evidence closure without caller-owned paths."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: TrustedCandidateBuilderConfig,
|
||||
authority_git_dir: Path,
|
||||
evidence_root: Path,
|
||||
*,
|
||||
expected_owner_uid: int | None = 0,
|
||||
git_runner: Callable[..., Any] = subprocess.run,
|
||||
):
|
||||
self._config = config
|
||||
self._authority_git_dir = Path(authority_git_dir)
|
||||
self._evidence_root = Path(evidence_root)
|
||||
self._expected_owner_uid = expected_owner_uid
|
||||
self._git_runner = git_runner
|
||||
|
||||
def _regular_path(self, relative: str) -> Path:
|
||||
root = self._evidence_root.resolve(strict=True)
|
||||
candidate = self._evidence_root / relative
|
||||
current = self._evidence_root
|
||||
for part in Path(relative).parts:
|
||||
current = current / part
|
||||
if current.is_symlink():
|
||||
raise BuilderError("COLLECTOR_SYMLINK_DENIED")
|
||||
try:
|
||||
resolved = candidate.resolve(strict=True)
|
||||
resolved.relative_to(root)
|
||||
metadata = resolved.stat(follow_symlinks=False)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_UNAVAILABLE") from exc
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise BuilderError("COLLECTOR_SPECIAL_FILE_DENIED")
|
||||
if os.name != "nt" and metadata.st_mode & 0o022:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_MODE_DENIED")
|
||||
if (
|
||||
os.name != "nt"
|
||||
and
|
||||
self._expected_owner_uid is not None
|
||||
and metadata.st_uid != self._expected_owner_uid
|
||||
):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_OWNER_DENIED")
|
||||
return resolved
|
||||
|
||||
def _digest(self, path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
def _json(self, path: Path) -> Mapping[str, Any]:
|
||||
try:
|
||||
if path.stat().st_size > 2 * 1024 * 1024:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_SIZE_INVALID")
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID") from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
||||
return value
|
||||
|
||||
def __call__(self, recipe_name: str, recipe: Mapping[str, str]) -> Mapping[str, Any]:
|
||||
try:
|
||||
tree_result = self._git_runner(
|
||||
["git", f"--git-dir={self._authority_git_dir}", "rev-parse", f"{recipe['source_commit']}^{{tree}}"],
|
||||
check=True, capture_output=True, text=True, timeout=15,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
ancestry_result = self._git_runner(
|
||||
["git", f"--git-dir={self._authority_git_dir}", "merge-base", "--is-ancestor", recipe["base_oid"], recipe["source_commit"]],
|
||||
check=False, capture_output=True, text=True, timeout=15,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
tree_oid = tree_result.stdout.strip().lower()
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_UNAVAILABLE") from exc
|
||||
if getattr(ancestry_result, "returncode", 1) != 0:
|
||||
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
||||
|
||||
receipt = self._json(self._regular_path(f"{recipe_name}/receipt.json"))
|
||||
if set(receipt) != {"schema", "version", "candidate", "release_plan"}:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
||||
if (
|
||||
receipt.get("schema") != "aios.trusted-candidate-builder.evidence.v1"
|
||||
or receipt.get("version") != recipe_name
|
||||
):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
||||
candidate = receipt.get("candidate")
|
||||
release_plan = receipt.get("release_plan")
|
||||
if not isinstance(candidate, Mapping) or not isinstance(release_plan, Mapping):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
||||
if (
|
||||
candidate.get("candidate_id") != recipe["candidate_id"]
|
||||
or candidate.get("source_commit") != recipe["source_commit"]
|
||||
or candidate.get("tree_oid") != tree_oid
|
||||
):
|
||||
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
||||
git_consistency = candidate.get("git_consistency")
|
||||
if (
|
||||
not isinstance(git_consistency, Mapping)
|
||||
or git_consistency.get("consistent") is not True
|
||||
or git_consistency.get("source_commit") != recipe["source_commit"]
|
||||
or git_consistency.get("tree_oid") != tree_oid
|
||||
or git_consistency.get("repository") != self._config.repository
|
||||
):
|
||||
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
||||
|
||||
paths = {
|
||||
name: self._regular_path(template.format(version=recipe_name))
|
||||
for name, template in self._config.evidence_layout.items()
|
||||
}
|
||||
deployment = release_plan.get("deployment")
|
||||
if not isinstance(deployment, Mapping):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
||||
digests = {name: self._digest(path) for name, path in paths.items()}
|
||||
if (
|
||||
digests["artifact"] != candidate.get("artifact_sha256")
|
||||
or digests["manifest"] != candidate.get("manifest_sha256")
|
||||
or digests["package_closure"] != deployment.get("package_closure_sha256")
|
||||
or digests["policy"] != release_plan.get("policy_sha256")
|
||||
or digests["policy"] != deployment.get("policy_sha256")
|
||||
):
|
||||
raise BuilderError("COLLECTOR_DIGEST_MISMATCH")
|
||||
|
||||
tests = self._json(paths["tests"])
|
||||
canary = self._json(paths["canary"])
|
||||
gitea = self._json(paths["gitea"])
|
||||
rollback = self._json(paths["rollback"])
|
||||
secret_scan = self._json(paths["secret_scan"])
|
||||
if tests != candidate.get("tests") or tests.get("passed") is not True:
|
||||
raise BuilderError("COLLECTOR_TESTS_MISMATCH")
|
||||
if canary != candidate.get("canary") or canary.get("passed") is not True:
|
||||
raise BuilderError("COLLECTOR_CANARY_MISMATCH")
|
||||
if (
|
||||
digests["tests"] != hashlib.sha256(_canonical_json(dict(tests))).hexdigest()
|
||||
or digests["canary"] != hashlib.sha256(_canonical_json(dict(canary))).hexdigest()
|
||||
):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_CANONICALIZATION_MISMATCH")
|
||||
if (
|
||||
gitea != candidate.get("gitea_consistency")
|
||||
or gitea.get("consistent") is not True
|
||||
or gitea.get("repository") != self._config.repository
|
||||
or gitea.get("source_commit") != recipe["source_commit"]
|
||||
or gitea.get("tree_oid") != tree_oid
|
||||
or gitea.get("remote_ref_oid") != recipe["source_commit"]
|
||||
):
|
||||
raise BuilderError("COLLECTOR_GITEA_MISMATCH")
|
||||
if (
|
||||
candidate.get("rollback_ready") is not True
|
||||
or rollback.get("ready") is not True
|
||||
or rollback.get("source_commit") != recipe["source_commit"]
|
||||
or rollback.get("rollback_sha256") != deployment.get("rollback_sha256")
|
||||
):
|
||||
raise BuilderError("COLLECTOR_ROLLBACK_MISMATCH")
|
||||
if (
|
||||
digests["gitea"] != hashlib.sha256(_canonical_json(dict(gitea))).hexdigest()
|
||||
or digests["rollback"] != hashlib.sha256(_canonical_json(dict(rollback))).hexdigest()
|
||||
):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_CANONICALIZATION_MISMATCH")
|
||||
if (
|
||||
secret_scan.get("passed") is not True
|
||||
or secret_scan.get("artifact_sha256") != candidate.get("artifact_sha256")
|
||||
):
|
||||
raise BuilderError("COLLECTOR_SECRET_SCAN_MISMATCH")
|
||||
checks = {name: True for name in self._config.required_checks}
|
||||
return {
|
||||
"candidate": dict(candidate),
|
||||
"release_plan": dict(release_plan),
|
||||
"checks": checks,
|
||||
"evidence_digests": digests,
|
||||
}
|
||||
|
||||
|
||||
class DeterministicEvidenceCollector:
|
||||
"""Validates a server-owned source; request fields never influence collection."""
|
||||
|
||||
_BUNDLE_FIELDS = {"candidate", "release_plan", "checks", "evidence_digests"}
|
||||
|
||||
def __init__(self, config: TrustedCandidateBuilderConfig, *, evidence_source: Callable[[str, Mapping[str, str]], Mapping[str, Any]] | None = None):
|
||||
self._config = config
|
||||
self._evidence_source = evidence_source
|
||||
|
||||
def collect(self, recipe_name: str, recipe: Mapping[str, str]) -> Mapping[str, Any]:
|
||||
if self._evidence_source is None:
|
||||
raise BuilderError("COLLECTOR_UNCONFIGURED")
|
||||
bundle = self._evidence_source(recipe_name, recipe)
|
||||
if not isinstance(bundle, Mapping) or set(bundle) != self._BUNDLE_FIELDS:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
||||
raw_checks = bundle.get("checks")
|
||||
if (
|
||||
not isinstance(raw_checks, Mapping)
|
||||
or set(raw_checks) != set(self._config.required_checks)
|
||||
or any(raw_checks.get(name) is not True for name in self._config.required_checks)
|
||||
):
|
||||
raise BuilderError("COLLECTOR_CHECKS_INCOMPLETE")
|
||||
evidence_digests = bundle.get("evidence_digests")
|
||||
if (
|
||||
not isinstance(evidence_digests, Mapping)
|
||||
or set(evidence_digests) != set(self._config.evidence_layout)
|
||||
or any(
|
||||
not isinstance(value, str)
|
||||
or len(value) != 64
|
||||
or any(char not in "0123456789abcdef" for char in value.lower())
|
||||
for value in evidence_digests.values()
|
||||
)
|
||||
):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_DIGESTS_INVALID")
|
||||
try:
|
||||
from silent_release_controller import ReleaseCandidate
|
||||
from silent_release_hardening import ReleasePlan, TrustedCandidateBundle
|
||||
|
||||
candidate = ReleaseCandidate.from_dict(dict(bundle["candidate"]))
|
||||
release_plan = ReleasePlan.from_dict(dict(bundle["release_plan"]))
|
||||
checks = dict(raw_checks)
|
||||
TrustedCandidateBundle(candidate, release_plan, checks).validate()
|
||||
except (ImportError, KeyError, TypeError, ValueError, RuntimeError) as exc:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID") from exc
|
||||
if candidate.source_commit != recipe["source_commit"] or candidate.candidate_id != recipe["candidate_id"]:
|
||||
raise BuilderError("COLLECTOR_SOURCE_MISMATCH")
|
||||
return {
|
||||
"candidate": candidate.to_dict(),
|
||||
"release_plan": release_plan.to_dict(),
|
||||
"checks": checks,
|
||||
"evidence_digests": dict(evidence_digests),
|
||||
}
|
||||
|
||||
|
||||
def _extract_peer_identity(connection: socket.socket) -> PeerIdentity:
|
||||
if not hasattr(socket, "SO_PEERCRED"):
|
||||
raise BuilderError("SO_PEERCRED_UNAVAILABLE")
|
||||
raw = connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i"))
|
||||
if len(raw) != struct.calcsize("3i"):
|
||||
raise BuilderError("SO_PEERCRED_INVALID")
|
||||
return PeerIdentity(*struct.unpack("3i", raw))
|
||||
|
||||
|
||||
def _resolve_cgroups(pid: int) -> tuple[str, ...]:
|
||||
try:
|
||||
return tuple(
|
||||
line.rsplit(":", 1)[1].strip()
|
||||
for line in Path(f"/proc/{pid}/cgroup").read_text(encoding="utf-8").splitlines()
|
||||
if ":" in line and line.rsplit(":", 1)[1].strip().startswith("/")
|
||||
)
|
||||
except OSError as exc:
|
||||
raise BuilderError("PEER_CGROUP_UNAVAILABLE") from exc
|
||||
|
||||
|
||||
def _resolve_process_identity(pid: int) -> tuple[str, tuple[str, ...]]:
|
||||
try:
|
||||
executable = os.path.realpath(f"/proc/{pid}/exe")
|
||||
argv = tuple(
|
||||
item.decode("utf-8", "strict")
|
||||
for item in Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0")
|
||||
if item
|
||||
)
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
raise BuilderError("PEER_PROCESS_UNAVAILABLE") from exc
|
||||
if not executable or not argv:
|
||||
raise BuilderError("PEER_PROCESS_UNAVAILABLE")
|
||||
return executable, argv
|
||||
|
||||
|
||||
class TrustedCandidateBuilder:
|
||||
def __init__(
|
||||
self, config: TrustedCandidateBuilderConfig, keyring: BuilderKeyring, *,
|
||||
collector: DeterministicEvidenceCollector, signer: DomainPurposeSigner,
|
||||
peer_identity_resolver: Callable[[socket.socket], PeerIdentity] = _extract_peer_identity,
|
||||
cgroup_resolver: Callable[[int], tuple[str, ...] | str] = _resolve_cgroups,
|
||||
process_identity_resolver: Callable[[int], tuple[str, tuple[str, ...]]] = _resolve_process_identity,
|
||||
clock: Callable[[], float] = time.time, nonce_ledger_path: Path | None = None,
|
||||
) -> None:
|
||||
self.config, self.keyring, self.collector, self.signer = config, keyring, collector, signer
|
||||
self.peer_identity_resolver, self.cgroup_resolver, self.clock = peer_identity_resolver, cgroup_resolver, clock
|
||||
self.process_identity_resolver = process_identity_resolver
|
||||
self._nonce_ledger_path = Path(nonce_ledger_path) if nonce_ledger_path else None
|
||||
self._nonce_ledger: dict[str, tuple[bytes, dict[str, Any]]] = self._load_nonce_ledger()
|
||||
|
||||
def _load_nonce_ledger(self) -> dict[str, tuple[bytes, dict[str, Any]]]:
|
||||
if self._nonce_ledger_path is None or not self._nonce_ledger_path.exists():
|
||||
return {}
|
||||
if self._nonce_ledger_path.is_symlink():
|
||||
raise BuilderError("NONCE_LEDGER_INVALID")
|
||||
try:
|
||||
raw = json.loads(self._nonce_ledger_path.read_text(encoding="utf-8"))
|
||||
return {nonce: (bytes.fromhex(value["request_sha256"]), dict(value["result"])) for nonce, value in raw.items()}
|
||||
except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError) as exc:
|
||||
raise BuilderError("NONCE_LEDGER_INVALID") from exc
|
||||
|
||||
def _persist_nonce_ledger(self) -> None:
|
||||
if self._nonce_ledger_path is None:
|
||||
return
|
||||
try:
|
||||
self._nonce_ledger_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
temporary = self._nonce_ledger_path.with_suffix(".tmp")
|
||||
payload = {nonce: {"request_sha256": digest.hex(), "result": result} for nonce, (digest, result) in self._nonce_ledger.items()}
|
||||
with temporary.open("wb") as handle:
|
||||
handle.write(_canonical_json(payload))
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, self._nonce_ledger_path)
|
||||
if os.name != "nt":
|
||||
descriptor = os.open(self._nonce_ledger_path.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
except OSError as exc:
|
||||
raise BuilderError("NONCE_LEDGER_PERSIST_FAILED") from exc
|
||||
|
||||
def _verify_peer(self, connection: socket.socket) -> None:
|
||||
peer = self.peer_identity_resolver(connection)
|
||||
if peer.uid != self.config.allowed_peer_uid or peer.pid <= 0:
|
||||
raise BuilderError("PEER_IDENTITY_DENIED")
|
||||
resolved = self.cgroup_resolver(peer.pid)
|
||||
cgroups = (resolved,) if isinstance(resolved, str) else tuple(resolved)
|
||||
if not cgroups or not any(item in self.config.allowed_peer_cgroups for item in cgroups):
|
||||
raise BuilderError("PEER_CGROUP_DENIED")
|
||||
executable, argv = self.process_identity_resolver(peer.pid)
|
||||
if not executable.startswith(self.config.allowed_peer_executable_prefix):
|
||||
raise BuilderError("PEER_PROCESS_DENIED")
|
||||
runtime_arguments = [
|
||||
value
|
||||
for value in argv
|
||||
if value.startswith(self.config.allowed_peer_runtime_prefix)
|
||||
and value.endswith(self.config.allowed_peer_runtime_suffix)
|
||||
]
|
||||
if len(runtime_arguments) != 1:
|
||||
raise BuilderError("PEER_PROCESS_DENIED")
|
||||
|
||||
def handle_request(self, request: Mapping[str, Any], *, connection: socket.socket) -> dict[str, Any]:
|
||||
if not isinstance(request, Mapping):
|
||||
raise BuilderError("REQUEST_SCHEMA_INVALID")
|
||||
expected_keys = {"schema", "operation", "recipe", "repository", "task_id", "candidate_id", "base_oid", "source_commit", "version", "purpose", "issued_at", "nonce", "hmac"}
|
||||
if (
|
||||
set(request) != expected_keys
|
||||
or request.get("schema") != self.config.request_schema
|
||||
or request.get("operation") not in {
|
||||
self.config.operation,
|
||||
self.config.readiness_operation,
|
||||
}
|
||||
):
|
||||
raise BuilderError("REQUEST_SCHEMA_INVALID")
|
||||
supplied_mac = request.get("hmac")
|
||||
unsigned = {key: value for key, value in request.items() if key != "hmac"}
|
||||
if not isinstance(supplied_mac, str) or not hmac.compare_digest(hmac.new(self.keyring.builder_hmac, _canonical_json(unsigned), hashlib.sha256).hexdigest(), supplied_mac):
|
||||
raise BuilderError("REQUEST_HMAC_INVALID")
|
||||
issued_at, nonce = request.get("issued_at"), request.get("nonce")
|
||||
if not isinstance(issued_at, int) or not isinstance(nonce, str) or len(nonce) < 16 or len(nonce) > 256:
|
||||
raise BuilderError("REQUEST_SCHEMA_INVALID")
|
||||
self._verify_peer(connection)
|
||||
request_bytes = _canonical_json(dict(request))
|
||||
replay = self._nonce_ledger.get(nonce)
|
||||
if replay is not None:
|
||||
if hmac.compare_digest(replay[0], hashlib.sha256(request_bytes).digest()):
|
||||
return dict(replay[1])
|
||||
raise BuilderError("REPLAY_CONFLICT")
|
||||
if abs(int(self.clock()) - issued_at) > self.config.maximum_request_age_seconds:
|
||||
raise BuilderError("REQUEST_STALE")
|
||||
recipe_name = request.get("recipe")
|
||||
if not isinstance(recipe_name, str):
|
||||
raise BuilderError("REQUEST_SCHEMA_INVALID")
|
||||
recipe = self.config.resolve_recipe(str(request.get("version") or ""), str(request.get("candidate_id") or ""), str(request.get("source_commit") or ""))
|
||||
if recipe_name != recipe.get("version"):
|
||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
||||
bound = {"repository": self.config.repository, "task_id": recipe["task_id"], "candidate_id": recipe["candidate_id"], "base_oid": recipe["base_oid"], "source_commit": recipe["source_commit"], "version": recipe["version"], "purpose": recipe["purpose"]}
|
||||
if {key: request.get(key) for key in bound} != bound:
|
||||
raise BuilderError("REQUEST_SCOPE_INVALID")
|
||||
bundle = dict(self.collector.collect(recipe_name, recipe))
|
||||
candidate = bundle["candidate"]
|
||||
release_plan = bundle["release_plan"]
|
||||
checks = bundle.get("checks")
|
||||
evidence_digests = bundle["evidence_digests"]
|
||||
if not isinstance(checks, Mapping) or set(checks) != set(self.config.required_checks) or not all(value is True for value in checks.values()):
|
||||
raise BuilderError("COLLECTOR_CHECKS_INCOMPLETE")
|
||||
if request["operation"] == self.config.readiness_operation:
|
||||
result = {
|
||||
"schema": self.config.response_schema,
|
||||
"status": "READY",
|
||||
"repository": self.config.repository,
|
||||
"recipe": recipe_name,
|
||||
"candidate_id": recipe["candidate_id"],
|
||||
"source_commit": recipe["source_commit"],
|
||||
"builder_key_id": self.signer.key_id,
|
||||
"builder_key_generation": self.signer.key_generation,
|
||||
"signature_algorithm": "Ed25519",
|
||||
"signature_purpose": "builder_attestation",
|
||||
"policy_sha256": self.config.policy_sha256,
|
||||
"collector_ready": True,
|
||||
"signer_ready": True,
|
||||
}
|
||||
if len(self._nonce_ledger) >= 2048:
|
||||
self._nonce_ledger.pop(next(iter(self._nonce_ledger)))
|
||||
self._nonce_ledger[nonce] = (
|
||||
hashlib.sha256(request_bytes).digest(),
|
||||
dict(result),
|
||||
)
|
||||
self._persist_nonce_ledger()
|
||||
return result
|
||||
issued = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
attestation = {
|
||||
"builder_attestation_id": "builder-attestation-" + _sha256(recipe_name, recipe["task_id"], candidate["artifact_sha256"])[:24],
|
||||
"builder_key_id": self.signer.key_id,
|
||||
"builder_key_generation": self.signer.key_generation,
|
||||
"signature_algorithm": "Ed25519",
|
||||
"signature_purpose": "builder_attestation",
|
||||
"source_commit": candidate["source_commit"], "tree_oid": candidate["tree_oid"],
|
||||
"artifact_sha256": candidate["artifact_sha256"], "manifest_sha256": candidate["manifest_sha256"],
|
||||
"package_closure_sha256": release_plan["deployment"]["package_closure_sha256"],
|
||||
"test_evidence_sha256": evidence_digests["tests"],
|
||||
"canary_evidence_sha256": evidence_digests["canary"],
|
||||
"gitea_evidence_sha256": evidence_digests["gitea"],
|
||||
"rollback_evidence_sha256": evidence_digests["rollback"],
|
||||
"policy_evidence_sha256": evidence_digests["policy"],
|
||||
"secret_scan_evidence_sha256": evidence_digests["secret_scan"],
|
||||
"issued_at": issued, "trust_domain": "aios.trusted-builder.attestation.v1",
|
||||
}
|
||||
canonical_attestation = _canonical_json(attestation)
|
||||
fingerprint = hashlib.sha256(canonical_attestation).hexdigest()
|
||||
signature = base64.b64encode(self.signer.sign_for_purpose("builder_attestation", canonical_attestation)).decode("ascii")
|
||||
result = {
|
||||
"schema": self.config.response_schema, "status": "BUILT", "recipe": recipe_name,
|
||||
"repository": self.config.repository, "task_id": recipe["task_id"], "base_oid": recipe["base_oid"],
|
||||
"purpose": recipe["purpose"], "checks": dict(checks),
|
||||
"evidence": {"candidate_id": candidate["candidate_id"], "source_commit": candidate["source_commit"], "tree_oid": candidate["tree_oid"], "artifact_sha256": candidate["artifact_sha256"], "manifest_sha256": candidate["manifest_sha256"], "package_closure_sha256": release_plan["deployment"]["package_closure_sha256"], "test_evidence_sha256": attestation["test_evidence_sha256"], "canary_evidence_sha256": attestation["canary_evidence_sha256"]},
|
||||
"attestation": {**attestation, "fingerprint": fingerprint, "signature": signature},
|
||||
}
|
||||
result["silent_release_bundle"] = {"candidate": candidate, "release_plan": release_plan, "checks": dict(checks), "attestation": result["attestation"]}
|
||||
if len(self._nonce_ledger) >= 2048:
|
||||
self._nonce_ledger.pop(next(iter(self._nonce_ledger)))
|
||||
self._nonce_ledger[nonce] = (hashlib.sha256(request_bytes).digest(), dict(result))
|
||||
self._persist_nonce_ledger()
|
||||
return result
|
||||
|
||||
def serve_forever(self, listener: socket.socket) -> None:
|
||||
while True:
|
||||
connection, _ = listener.accept()
|
||||
with connection:
|
||||
try:
|
||||
raw = connection.makefile("rb").readline(65537)
|
||||
if not raw or len(raw) > 65536:
|
||||
raise BuilderError("REQUEST_SIZE_INVALID")
|
||||
result = self.handle_request(json.loads(raw), connection=connection)
|
||||
except (BuilderError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
result = {"schema": self.config.response_schema, "status": "REJECTED", "error_code": "REQUEST_REJECTED"}
|
||||
connection.sendall(_canonical_json(result) + b"\n")
|
||||
|
||||
|
||||
class UnixTrustedCandidateBuilderClient:
|
||||
def __init__(self, config: TrustedCandidateBuilderConfig, keyring: BuilderKeyring, *, socket_path: Path | None = None, clock: Callable[[], float] = time.time) -> None:
|
||||
self.config, self.keyring, self.socket_path, self.clock = config, keyring, socket_path, clock
|
||||
|
||||
def create_request(self, version: str, candidate_id: str, source_commit: str, *, nonce: str, operation: str | None = None) -> dict[str, Any]:
|
||||
selected = self.config.resolve_recipe(version, candidate_id, source_commit)
|
||||
selected_operation = operation or self.config.operation
|
||||
if selected_operation not in {self.config.operation, self.config.readiness_operation}:
|
||||
raise BuilderError("REQUEST_OPERATION_INVALID")
|
||||
request = {"schema": self.config.request_schema, "operation": selected_operation, "recipe": version, "repository": self.config.repository, "task_id": selected["task_id"], "candidate_id": selected["candidate_id"], "base_oid": selected["base_oid"], "source_commit": selected["source_commit"], "version": selected["version"], "purpose": selected["purpose"], "issued_at": int(self.clock()), "nonce": nonce}
|
||||
request["hmac"] = hmac.new(self.keyring.builder_hmac, _canonical_json(request), hashlib.sha256).hexdigest()
|
||||
return request
|
||||
|
||||
def _exchange(self, request: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
if self.socket_path is None:
|
||||
raise BuilderError("BUILDER_SOCKET_UNCONFIGURED")
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(self.config.request_timeout_seconds)
|
||||
connection.connect(os.fspath(self.socket_path))
|
||||
connection.sendall(_canonical_json(dict(request)) + b"\n")
|
||||
raw = connection.makefile("rb").readline(65537)
|
||||
except (OSError, TimeoutError) as exc:
|
||||
raise BuilderError("BUILDER_TRANSPORT_UNAVAILABLE") from exc
|
||||
try:
|
||||
result = json.loads(raw)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise BuilderError("BUILDER_RESPONSE_INVALID") from exc
|
||||
if not isinstance(result, Mapping) or result.get("schema") != self.config.response_schema:
|
||||
raise BuilderError("BUILDER_RESPONSE_INVALID")
|
||||
return result
|
||||
|
||||
def build(self, version: str, candidate_id: str, source_commit: str, *, nonce: str) -> Mapping[str, Any]:
|
||||
result = self._exchange(
|
||||
self.create_request(version, candidate_id, source_commit, nonce=nonce)
|
||||
)
|
||||
if result.get("status") != "BUILT":
|
||||
raise BuilderError("BUILDER_REQUEST_REJECTED")
|
||||
return result
|
||||
|
||||
def probe(self, *, nonce: str | None = None) -> Mapping[str, Any]:
|
||||
recipe = self.config.fixed_recipe("V2R2C2")
|
||||
result = self._exchange(
|
||||
self.create_request(
|
||||
"V2R2C2",
|
||||
recipe["candidate_id"],
|
||||
recipe["source_commit"],
|
||||
nonce=nonce or secrets.token_hex(32),
|
||||
operation=self.config.readiness_operation,
|
||||
)
|
||||
)
|
||||
if result.get("status") != "READY":
|
||||
raise BuilderError("BUILDER_NOT_READY")
|
||||
return result
|
||||
|
||||
|
||||
def readiness_probe(client: UnixTrustedCandidateBuilderClient) -> Mapping[str, Any]:
|
||||
result = dict(client.probe())
|
||||
required_true = {"collector_ready", "signer_ready"}
|
||||
if any(result.get(name) is not True for name in required_true):
|
||||
raise BuilderError("BUILDER_NOT_READY")
|
||||
return result
|
||||
|
||||
|
||||
def _main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--socket-fd", required=True, type=int)
|
||||
parser.add_argument("--policy", required=True, type=Path)
|
||||
arguments = parser.parse_args()
|
||||
config = TrustedCandidateBuilderConfig.from_policy(load_builder_policy(arguments.policy))
|
||||
credentials = os.environ.get("CREDENTIALS_DIRECTORY")
|
||||
if not credentials:
|
||||
raise SystemExit("CREDENTIALS_DIRECTORY is required")
|
||||
keyring = BuilderKeyring.from_credentials_directory(Path(credentials))
|
||||
signer = CredentialPathEd25519Signer(
|
||||
keyring.signing_private_key_path,
|
||||
key_id=os.environ["AIOS_TRUSTED_BUILDER_KEY_ID"],
|
||||
key_generation=int(os.environ["AIOS_TRUSTED_BUILDER_KEY_GENERATION"]),
|
||||
allowed_purpose="builder_attestation",
|
||||
)
|
||||
listener = socket.fromfd(arguments.socket_fd, socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
collector = DeterministicEvidenceCollector(
|
||||
config,
|
||||
evidence_source=ServerOwnedEvidenceSource(
|
||||
config,
|
||||
Path(os.environ["AIOS_TRUSTED_BUILDER_GIT_DIR"]),
|
||||
Path(os.environ["AIOS_TRUSTED_BUILDER_EVIDENCE_ROOT"]),
|
||||
),
|
||||
)
|
||||
TrustedCandidateBuilder(config, keyring, collector=collector, signer=signer, nonce_ledger_path=Path(os.environ["AIOS_TRUSTED_BUILDER_NONCE_LEDGER"])).serve_forever(listener)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -40,6 +41,8 @@ from silent_release_controller import (
|
||||
|
||||
IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{2,255}$")
|
||||
NONCE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{15,255}$")
|
||||
BUILDER_ATTESTATION_TRUST_DOMAIN = "aios.trusted-builder.attestation.v1"
|
||||
BUILDER_ATTESTATION_PURPOSE = "builder_attestation"
|
||||
ALLOWED_CAPABILITIES = frozenset(
|
||||
{
|
||||
"runtime_switch",
|
||||
@@ -62,6 +65,10 @@ class CapabilityAuthorizationError(RuntimeError):
|
||||
"""The signed plan cannot authorize the requested operation."""
|
||||
|
||||
|
||||
class TrustedCandidateBuilderUnavailable(RuntimeError):
|
||||
"""The purpose-bound builder dependency cannot currently serve a build."""
|
||||
|
||||
|
||||
class StaleApprovalError(CapabilityAuthorizationError):
|
||||
"""Live release evidence no longer matches the signed approval."""
|
||||
|
||||
@@ -222,6 +229,10 @@ JOURNAL_GENESIS_HASH = "0" * 64
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
if os.name == "nt":
|
||||
# Windows does not permit opening a directory with os.open. Durable
|
||||
# Linux host execution retains the directory fsync below.
|
||||
return
|
||||
descriptor = os.open(Path(path), os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
@@ -715,6 +726,14 @@ class BuilderAttestation:
|
||||
canary_evidence_sha256: str
|
||||
issued_at: str
|
||||
signature: str
|
||||
trust_domain: str = BUILDER_ATTESTATION_TRUST_DOMAIN
|
||||
builder_key_generation: int | None = None
|
||||
signature_algorithm: str | None = None
|
||||
signature_purpose: str | None = None
|
||||
gitea_evidence_sha256: str = ""
|
||||
rollback_evidence_sha256: str = ""
|
||||
policy_evidence_sha256: str = ""
|
||||
secret_scan_evidence_sha256: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_identifier(
|
||||
@@ -731,7 +750,35 @@ class BuilderAttestation:
|
||||
"canary_evidence_sha256",
|
||||
):
|
||||
_validate_sha(name, getattr(self, name))
|
||||
for name in (
|
||||
"gitea_evidence_sha256",
|
||||
"rollback_evidence_sha256",
|
||||
"policy_evidence_sha256",
|
||||
"secret_scan_evidence_sha256",
|
||||
):
|
||||
if getattr(self, name):
|
||||
_validate_sha(name, getattr(self, name))
|
||||
_parse_utc(self.issued_at)
|
||||
if self.trust_domain != BUILDER_ATTESTATION_TRUST_DOMAIN:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation trust domain is invalid"
|
||||
)
|
||||
metadata = (
|
||||
self.builder_key_generation,
|
||||
self.signature_algorithm,
|
||||
self.signature_purpose,
|
||||
)
|
||||
if any(value is not None for value in metadata):
|
||||
if (
|
||||
not isinstance(self.builder_key_generation, int)
|
||||
or isinstance(self.builder_key_generation, bool)
|
||||
or self.builder_key_generation < 0
|
||||
or self.signature_algorithm != "Ed25519"
|
||||
or self.signature_purpose != BUILDER_ATTESTATION_PURPOSE
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation signing metadata is invalid"
|
||||
)
|
||||
|
||||
@property
|
||||
def canonical_payload(self) -> dict[str, Any]:
|
||||
@@ -754,20 +801,290 @@ class BuilderAttestation:
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: Mapping[str, Any]) -> "BuilderAttestation":
|
||||
def from_dict(
|
||||
cls,
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
require_fingerprint: bool = False,
|
||||
) -> "BuilderAttestation":
|
||||
values = dict(payload)
|
||||
values.pop("fingerprint", None)
|
||||
return cls(**values)
|
||||
supplied_fingerprint = values.pop("fingerprint", None)
|
||||
# Older local records did not spell out the domain. They are only
|
||||
# usable when re-signed by the current builder; their original
|
||||
# signatures cannot validate against this domain-separated payload.
|
||||
values.setdefault("trust_domain", BUILDER_ATTESTATION_TRUST_DOMAIN)
|
||||
value = cls(**values)
|
||||
if supplied_fingerprint is None:
|
||||
if require_fingerprint:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation digest is required"
|
||||
)
|
||||
return value
|
||||
expected = _validate_sha(
|
||||
"builder attestation digest", str(supplied_fingerprint)
|
||||
)
|
||||
if not hmac.compare_digest(expected, value.fingerprint):
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation digest is invalid"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BuilderAttestationKeyRecord:
|
||||
"""One purpose-bound public verification key from the host keyring."""
|
||||
|
||||
key_id: str
|
||||
public_key: bytes
|
||||
algorithm: str
|
||||
purpose: str
|
||||
generation: int
|
||||
not_before: str
|
||||
expires_at: str
|
||||
status: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_identifier("builder_key_id", self.key_id)
|
||||
if self.algorithm != "Ed25519":
|
||||
raise ValueError("unsupported builder signature algorithm")
|
||||
if self.purpose != BUILDER_ATTESTATION_PURPOSE:
|
||||
raise ValueError("builder key purpose is invalid")
|
||||
if not isinstance(self.generation, int) or isinstance(
|
||||
self.generation, bool
|
||||
) or self.generation < 0:
|
||||
raise ValueError("builder key generation is invalid")
|
||||
if self.status not in {"ACTIVE", "REVOKED"}:
|
||||
raise ValueError("builder key status is invalid")
|
||||
if len(self.public_key) != 32:
|
||||
raise ValueError("Ed25519 public key must contain 32 bytes")
|
||||
Ed25519PublicKey.from_public_bytes(self.public_key)
|
||||
if _parse_utc(self.not_before) >= _parse_utc(self.expires_at):
|
||||
raise ValueError("builder key validity window is invalid")
|
||||
|
||||
@property
|
||||
def public_key_sha256(self) -> str:
|
||||
return hashlib.sha256(self.public_key).hexdigest()
|
||||
|
||||
def validate_at(self, instant: datetime) -> None:
|
||||
if self.status != "ACTIVE":
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation key is unavailable or revoked"
|
||||
)
|
||||
if instant.tzinfo is None:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation verification time is invalid"
|
||||
)
|
||||
if not _parse_utc(self.not_before) <= instant <= _parse_utc(
|
||||
self.expires_at
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation key is outside its validity window"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls, payload: Mapping[str, Any]
|
||||
) -> "BuilderAttestationKeyRecord":
|
||||
values = dict(payload)
|
||||
encoded = values.pop("public_key", values.pop("public_key_b64", None))
|
||||
if not isinstance(encoded, str):
|
||||
raise ValueError("builder public key is missing")
|
||||
try:
|
||||
public_key = base64.b64decode(encoded, validate=True)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("builder public key encoding is invalid") from exc
|
||||
supplied_digest = values.pop(
|
||||
"public_key_sha256",
|
||||
values.pop("key_sha256", values.pop("digest", None)),
|
||||
)
|
||||
if not isinstance(supplied_digest, str):
|
||||
raise ValueError("builder public key digest is missing")
|
||||
digest = _validate_sha("builder public key digest", supplied_digest)
|
||||
if not hmac.compare_digest(
|
||||
digest, hashlib.sha256(public_key).hexdigest()
|
||||
):
|
||||
raise ValueError("builder public key digest is invalid")
|
||||
allowed = {
|
||||
"algorithm",
|
||||
"purpose",
|
||||
"key_id",
|
||||
"generation",
|
||||
"not_before",
|
||||
"expires_at",
|
||||
"status",
|
||||
}
|
||||
unknown = set(values).difference(allowed)
|
||||
if unknown:
|
||||
raise ValueError("builder key record contains unknown fields")
|
||||
return cls(public_key=public_key, **values)
|
||||
|
||||
|
||||
class BuilderAttestationKeyRing:
|
||||
"""Validated, public-only keyring for builder attestations.
|
||||
|
||||
The file format deliberately cannot carry Owner approval keys: both the
|
||||
keyring and every record must declare the builder-attestation purpose.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
records: Iterable[BuilderAttestationKeyRecord],
|
||||
*,
|
||||
require_attestation_metadata: bool = False,
|
||||
):
|
||||
self._records: dict[str, BuilderAttestationKeyRecord] = {}
|
||||
self._require_attestation_metadata = require_attestation_metadata
|
||||
for record in records:
|
||||
if record.key_id in self._records:
|
||||
raise ValueError("duplicate builder key id")
|
||||
self._records[record.key_id] = record
|
||||
|
||||
@classmethod
|
||||
def from_public_keys(
|
||||
cls, public_keys: Mapping[str, bytes]
|
||||
) -> "BuilderAttestationKeyRing":
|
||||
"""Compatibility adapter for explicit in-process builder key maps.
|
||||
|
||||
This is intentionally not used for host configuration; host keyrings
|
||||
must use :meth:`from_file` so purpose, digest, validity and status are
|
||||
checked before the API accepts traffic.
|
||||
"""
|
||||
return cls(
|
||||
BuilderAttestationKeyRecord(
|
||||
key_id=_validate_identifier("builder_key_id", key_id),
|
||||
public_key=bytes(public_key),
|
||||
algorithm="Ed25519",
|
||||
purpose=BUILDER_ATTESTATION_PURPOSE,
|
||||
generation=0,
|
||||
not_before="1970-01-01T00:00:00Z",
|
||||
expires_at="9999-12-31T23:59:59Z",
|
||||
status="ACTIVE",
|
||||
)
|
||||
for key_id, public_key in public_keys.items()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: Path) -> "BuilderAttestationKeyRing":
|
||||
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
if payload.get("algorithm") != "Ed25519":
|
||||
raise ValueError("unsupported builder keyring algorithm")
|
||||
if payload.get("purpose") != BUILDER_ATTESTATION_PURPOSE:
|
||||
raise ValueError("builder keyring purpose is invalid")
|
||||
raw_records = payload.get("keys")
|
||||
if not isinstance(raw_records, list) or not raw_records:
|
||||
raise ValueError("builder keyring keys are invalid")
|
||||
allowed = {"algorithm", "purpose", "keys", "keyring_sha256"}
|
||||
if set(payload).difference(allowed):
|
||||
raise ValueError("builder keyring contains unknown fields")
|
||||
if "keyring_sha256" not in payload:
|
||||
raise ValueError("builder keyring digest is missing")
|
||||
expected = _validate_sha(
|
||||
"builder keyring digest", str(payload["keyring_sha256"])
|
||||
)
|
||||
canonical = _canonical_json(
|
||||
{
|
||||
"algorithm": payload["algorithm"],
|
||||
"purpose": payload["purpose"],
|
||||
"keys": raw_records,
|
||||
}
|
||||
)
|
||||
if not hmac.compare_digest(
|
||||
expected, hashlib.sha256(canonical).hexdigest()
|
||||
):
|
||||
raise ValueError("builder keyring digest is invalid")
|
||||
if not all(isinstance(record, Mapping) for record in raw_records):
|
||||
raise ValueError("builder keyring record is invalid")
|
||||
return cls(
|
||||
(
|
||||
BuilderAttestationKeyRecord.from_dict(record)
|
||||
for record in raw_records
|
||||
),
|
||||
require_attestation_metadata=True,
|
||||
)
|
||||
|
||||
def verify(
|
||||
self,
|
||||
key_id: str,
|
||||
signature: bytes,
|
||||
message: bytes,
|
||||
*,
|
||||
at: datetime,
|
||||
generation: int | None,
|
||||
algorithm: str | None,
|
||||
purpose: str | None,
|
||||
) -> BuilderAttestationKeyRecord:
|
||||
record = self._records.get(key_id)
|
||||
if record is None:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation key is not trusted"
|
||||
)
|
||||
record.validate_at(at)
|
||||
metadata = (generation, algorithm, purpose)
|
||||
if self._require_attestation_metadata and any(
|
||||
value is None for value in metadata
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation signing metadata is required"
|
||||
)
|
||||
if any(value is not None for value in metadata) and metadata != (
|
||||
record.generation,
|
||||
record.algorithm,
|
||||
record.purpose,
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation signing metadata does not match key"
|
||||
)
|
||||
try:
|
||||
Ed25519PublicKey.from_public_bytes(record.public_key).verify(
|
||||
signature, message
|
||||
)
|
||||
except InvalidSignature as exc:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation signature is invalid"
|
||||
) from exc
|
||||
return record
|
||||
|
||||
def require_active_identity(
|
||||
self,
|
||||
*,
|
||||
key_id: str,
|
||||
generation: int,
|
||||
algorithm: str,
|
||||
purpose: str,
|
||||
at: datetime | None = None,
|
||||
) -> BuilderAttestationKeyRecord:
|
||||
record = self._records.get(key_id)
|
||||
if record is None:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation key is not trusted"
|
||||
)
|
||||
if (
|
||||
record.generation != generation
|
||||
or record.algorithm != algorithm
|
||||
or record.purpose != purpose
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation key metadata is invalid"
|
||||
)
|
||||
record.validate_at(at or datetime.now(timezone.utc))
|
||||
return record
|
||||
|
||||
|
||||
class BuilderAttestationVerifier:
|
||||
"""Public-key-only verifier; no builder private material is accepted."""
|
||||
|
||||
def __init__(self, public_keys: Mapping[str, bytes]):
|
||||
self.public_keys = {
|
||||
_validate_identifier("builder_key_id", key_id): bytes(value)
|
||||
for key_id, value in public_keys.items()
|
||||
}
|
||||
def __init__(
|
||||
self,
|
||||
public_keys: BuilderAttestationKeyRing | Mapping[str, bytes],
|
||||
):
|
||||
if isinstance(public_keys, BuilderAttestationKeyRing):
|
||||
self.keyring = public_keys
|
||||
elif isinstance(public_keys, Mapping):
|
||||
self.keyring = BuilderAttestationKeyRing.from_public_keys(
|
||||
public_keys
|
||||
)
|
||||
else:
|
||||
raise TypeError("builder verifier requires a builder keyring")
|
||||
|
||||
def verify(
|
||||
self,
|
||||
@@ -793,22 +1110,54 @@ class BuilderAttestationVerifier:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation does not bind candidate evidence"
|
||||
)
|
||||
key = self.public_keys.get(attestation.builder_key_id)
|
||||
if key is None:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation key is not trusted"
|
||||
)
|
||||
try:
|
||||
signature = base64.b64decode(
|
||||
attestation.signature, validate=True
|
||||
)
|
||||
Ed25519PublicKey.from_public_bytes(key).verify(
|
||||
signature, attestation.canonical_bytes()
|
||||
key_record = self.keyring.verify(
|
||||
attestation.builder_key_id,
|
||||
signature,
|
||||
attestation.canonical_bytes(),
|
||||
at=_parse_utc(attestation.issued_at),
|
||||
generation=attestation.builder_key_generation,
|
||||
algorithm=attestation.signature_algorithm,
|
||||
purpose=attestation.signature_purpose,
|
||||
)
|
||||
except (InvalidSignature, ValueError, TypeError) as exc:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation signature is invalid"
|
||||
) from exc
|
||||
if key_record.generation >= 1:
|
||||
expected_gitea = hashlib.sha256(
|
||||
_canonical_json(candidate.gitea_consistency)
|
||||
).hexdigest()
|
||||
expected_rollback = hashlib.sha256(
|
||||
_canonical_json(
|
||||
{
|
||||
"ready": candidate.rollback_ready,
|
||||
"rollback_sha256": release_plan.deployment.rollback_sha256,
|
||||
"source_commit": candidate.source_commit,
|
||||
}
|
||||
)
|
||||
).hexdigest()
|
||||
production_evidence = (
|
||||
attestation.gitea_evidence_sha256,
|
||||
attestation.rollback_evidence_sha256,
|
||||
attestation.policy_evidence_sha256,
|
||||
attestation.secret_scan_evidence_sha256,
|
||||
)
|
||||
if any(not value for value in production_evidence):
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation production evidence is incomplete"
|
||||
)
|
||||
if (
|
||||
attestation.gitea_evidence_sha256 != expected_gitea
|
||||
or attestation.rollback_evidence_sha256 != expected_rollback
|
||||
or attestation.policy_evidence_sha256 != release_plan.policy_sha256
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation production evidence is invalid"
|
||||
)
|
||||
return attestation
|
||||
|
||||
|
||||
@@ -3181,9 +3530,12 @@ class SilentReleaseActionService:
|
||||
builder_attestation_verifier: BuilderAttestationVerifier | None = None,
|
||||
trusted_policy: TrustedReleasePolicy | None = None,
|
||||
orchestrator: ReleaseOrchestrator | None = None,
|
||||
builder_readiness_probe: Callable[[], Mapping[str, Any]] | None = None,
|
||||
allow_legacy_candidate_input: bool = False,
|
||||
expected_repository: str = "",
|
||||
policy_sha256: str = "",
|
||||
max_builder_attempts: int = 3,
|
||||
builder_attempt_timeout_seconds: int = 180,
|
||||
):
|
||||
self.root = Path(root)
|
||||
self.execution_enabled = bool(execution_enabled)
|
||||
@@ -3194,11 +3546,28 @@ class SilentReleaseActionService:
|
||||
self.builder_attestation_verifier = builder_attestation_verifier
|
||||
self.trusted_policy = trusted_policy
|
||||
self.orchestrator = orchestrator
|
||||
self.builder_readiness_probe = builder_readiness_probe
|
||||
self.allow_legacy_candidate_input = bool(
|
||||
allow_legacy_candidate_input
|
||||
)
|
||||
self.expected_repository = expected_repository
|
||||
self.policy_sha256 = policy_sha256
|
||||
if (
|
||||
not isinstance(max_builder_attempts, int)
|
||||
or isinstance(max_builder_attempts, bool)
|
||||
or not 1 <= max_builder_attempts <= 10
|
||||
):
|
||||
raise ValueError("max_builder_attempts must be between 1 and 10")
|
||||
self.max_builder_attempts = max_builder_attempts
|
||||
if (
|
||||
not isinstance(builder_attempt_timeout_seconds, int)
|
||||
or isinstance(builder_attempt_timeout_seconds, bool)
|
||||
or not 1 <= builder_attempt_timeout_seconds <= 3600
|
||||
):
|
||||
raise ValueError(
|
||||
"builder_attempt_timeout_seconds must be between 1 and 3600"
|
||||
)
|
||||
self.builder_attempt_timeout_seconds = builder_attempt_timeout_seconds
|
||||
self.candidates = ReleaseCandidateStore(
|
||||
self.root / "candidates"
|
||||
)
|
||||
@@ -3235,10 +3604,26 @@ class SilentReleaseActionService:
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
|
||||
def get_builder_readiness(self) -> dict[str, Any]:
|
||||
"""Report dependency readiness without conflating it with API liveness."""
|
||||
if self.candidate_builder is None:
|
||||
return {"status": "UNAVAILABLE"}
|
||||
if self.builder_readiness_probe is None:
|
||||
# Explicit in-process test/adaptor construction remains usable;
|
||||
# production wiring provides a probe backed by its credentials.
|
||||
return {"status": "READY"}
|
||||
try:
|
||||
value = dict(self.builder_readiness_probe())
|
||||
except Exception:
|
||||
return {"status": "UNAVAILABLE"}
|
||||
if value.get("status") != "READY":
|
||||
return {"status": "UNAVAILABLE"}
|
||||
return _sanitize(value)
|
||||
|
||||
def create_release_intent(
|
||||
self, payload: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
allowed = {"event_id", "source_commit"}
|
||||
allowed = {"event_id", "source_commit", "version"}
|
||||
unknown = set(payload).difference(allowed)
|
||||
if unknown:
|
||||
raise CapabilityAuthorizationError(
|
||||
@@ -3250,12 +3635,17 @@ class SilentReleaseActionService:
|
||||
source_commit = _validate_oid(
|
||||
"source_commit", str(payload.get("source_commit") or "")
|
||||
)
|
||||
version = str(payload.get("version") or "")
|
||||
if version:
|
||||
_validate_identifier("version", version)
|
||||
intent_id = "intent-" + hashlib.sha256(
|
||||
f"{event_id}\0{source_commit}".encode("utf-8")
|
||||
).hexdigest()[:24]
|
||||
path = _safe_child(self.intents_root, intent_id)
|
||||
was_existing = path.exists()
|
||||
record = {
|
||||
"created_at": utc_now(),
|
||||
"attempt_count": 0,
|
||||
"event_id": event_id,
|
||||
"intent_id": intent_id,
|
||||
"source_commit": source_commit,
|
||||
@@ -3265,42 +3655,153 @@ class SilentReleaseActionService:
|
||||
else "BUILDER_UNAVAILABLE"
|
||||
),
|
||||
}
|
||||
if path.exists():
|
||||
if version:
|
||||
record["version"] = version
|
||||
if was_existing:
|
||||
existing = json.loads(path.read_text(encoding="utf-8"))
|
||||
if (
|
||||
existing.get("event_id") != event_id
|
||||
or existing.get("source_commit") != source_commit
|
||||
or str(existing.get("version") or "") != version
|
||||
):
|
||||
raise SagaConflict(
|
||||
"release intent id is bound to different evidence"
|
||||
)
|
||||
return {
|
||||
**_sanitize(existing),
|
||||
"idempotent": True,
|
||||
"execution_enabled": self.execution_enabled,
|
||||
"shadow_mode": not self.execution_enabled,
|
||||
"side_effects_executed": False,
|
||||
if existing.get("status") == "BUILDING":
|
||||
try:
|
||||
started_at = _parse_utc(
|
||||
str(existing.get("attempt_started_at") or "")
|
||||
)
|
||||
elapsed = (
|
||||
datetime.now(timezone.utc) - started_at
|
||||
).total_seconds()
|
||||
except (TypeError, ValueError):
|
||||
elapsed = self.builder_attempt_timeout_seconds + 1
|
||||
if elapsed <= self.builder_attempt_timeout_seconds:
|
||||
return self._intent_response(existing, idempotent=True)
|
||||
existing.update(
|
||||
{
|
||||
"failure_code": "TRUSTED_BUILDER_CRASH_RECOVERED",
|
||||
"last_recovery_code": "TRUSTED_BUILDER_CRASH_RECOVERED",
|
||||
"recovery_count": int(existing.get("recovery_count") or 0) + 1,
|
||||
"retryable": int(existing.get("attempt_count") or 0)
|
||||
< self.max_builder_attempts,
|
||||
"status": "BUILDER_RETRYABLE_FAILURE",
|
||||
"updated_at": utc_now(),
|
||||
}
|
||||
)
|
||||
_durable_json(path, existing)
|
||||
if (
|
||||
existing.get("status") != "BUILDER_RETRYABLE_FAILURE"
|
||||
or int(existing.get("attempt_count") or 0)
|
||||
>= self.max_builder_attempts
|
||||
):
|
||||
return self._intent_response(existing, idempotent=True)
|
||||
record = dict(existing)
|
||||
if self.candidate_builder is None:
|
||||
record.update(
|
||||
{
|
||||
"retryable": False,
|
||||
"status": "BUILDER_UNAVAILABLE",
|
||||
}
|
||||
)
|
||||
_durable_json(path, record)
|
||||
return self._intent_response(record, idempotent=was_existing)
|
||||
|
||||
record.update(
|
||||
{
|
||||
"attempt_count": int(record.get("attempt_count") or 0) + 1,
|
||||
"attempt_started_at": utc_now(),
|
||||
"retryable": False,
|
||||
"status": "BUILDING",
|
||||
"updated_at": utc_now(),
|
||||
}
|
||||
)
|
||||
_durable_json(path, record)
|
||||
result = dict(record)
|
||||
if self.candidate_builder is not None:
|
||||
try:
|
||||
candidate = self.create_release_candidate(
|
||||
{
|
||||
"event_id": event_id,
|
||||
"intent_id": intent_id,
|
||||
"source_commit": source_commit,
|
||||
**({"version": version} if version else {}),
|
||||
}
|
||||
)
|
||||
result.update(
|
||||
except TrustedCandidateBuilderUnavailable:
|
||||
record.update(
|
||||
{
|
||||
"candidate_id": candidate["candidate_id"],
|
||||
"status": candidate["saga_state"],
|
||||
"failure_code": "TRUSTED_BUILDER_UNAVAILABLE",
|
||||
"retryable": record["attempt_count"]
|
||||
< self.max_builder_attempts,
|
||||
"status": (
|
||||
"BUILDER_RETRYABLE_FAILURE"
|
||||
if record["attempt_count"] < self.max_builder_attempts
|
||||
else "BUILDER_TERMINAL_FAILURE"
|
||||
),
|
||||
}
|
||||
)
|
||||
_durable_json(path, result)
|
||||
_durable_json(path, record)
|
||||
return self._intent_response(record, idempotent=False)
|
||||
except CapabilityAuthorizationError:
|
||||
record.update(
|
||||
{
|
||||
"failure_code": "TRUSTED_BUILDER_REJECTED",
|
||||
"retryable": False,
|
||||
"status": "BUILDER_TERMINAL_FAILURE",
|
||||
}
|
||||
)
|
||||
_durable_json(path, record)
|
||||
return self._intent_response(record, idempotent=False)
|
||||
except (ConnectionError, OSError, TimeoutError):
|
||||
record.update(
|
||||
{
|
||||
"failure_code": "TRUSTED_BUILDER_UNAVAILABLE",
|
||||
"retryable": record["attempt_count"]
|
||||
< self.max_builder_attempts,
|
||||
"status": (
|
||||
"BUILDER_RETRYABLE_FAILURE"
|
||||
if record["attempt_count"] < self.max_builder_attempts
|
||||
else "BUILDER_TERMINAL_FAILURE"
|
||||
),
|
||||
}
|
||||
)
|
||||
_durable_json(path, record)
|
||||
return self._intent_response(record, idempotent=False)
|
||||
except Exception:
|
||||
# The durable intent is recoverable without serializing an
|
||||
# arbitrary exception (which may contain a credential or host
|
||||
# path). Repeated requests consume the small fixed retry budget.
|
||||
record.update(
|
||||
{
|
||||
"failure_code": "TRUSTED_BUILDER_FAILURE",
|
||||
"retryable": record["attempt_count"]
|
||||
< self.max_builder_attempts,
|
||||
"status": (
|
||||
"BUILDER_RETRYABLE_FAILURE"
|
||||
if record["attempt_count"] < self.max_builder_attempts
|
||||
else "BUILDER_TERMINAL_FAILURE"
|
||||
),
|
||||
}
|
||||
)
|
||||
_durable_json(path, record)
|
||||
return self._intent_response(record, idempotent=False)
|
||||
result = dict(record)
|
||||
result.update(
|
||||
{
|
||||
"candidate_id": candidate["candidate_id"],
|
||||
"retryable": False,
|
||||
"status": candidate["saga_state"],
|
||||
}
|
||||
)
|
||||
_durable_json(path, result)
|
||||
return self._intent_response(result, idempotent=False)
|
||||
|
||||
def _intent_response(
|
||||
self, record: Mapping[str, Any], *, idempotent: bool
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
**_sanitize(result),
|
||||
"idempotent": False,
|
||||
**_sanitize(dict(record)),
|
||||
"idempotent": idempotent,
|
||||
"execution_enabled": self.execution_enabled,
|
||||
"shadow_mode": not self.execution_enabled,
|
||||
"side_effects_executed": False,
|
||||
@@ -3321,6 +3822,7 @@ class SilentReleaseActionService:
|
||||
self, payload: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
if self.candidate_builder is not None:
|
||||
values = dict(payload)
|
||||
forbidden_evidence = {
|
||||
"artifact_sha256",
|
||||
"canary",
|
||||
@@ -3333,13 +3835,69 @@ class SilentReleaseActionService:
|
||||
"tests",
|
||||
"tree_oid",
|
||||
}
|
||||
if forbidden_evidence.intersection(payload):
|
||||
if forbidden_evidence.intersection(values):
|
||||
raise CapabilityAuthorizationError(
|
||||
"candidate evidence must come from the trusted builder"
|
||||
)
|
||||
worker_fields = {"candidate_id", "source_commit", "version"}
|
||||
intent_fields = {"event_id", "intent_id", "source_commit", "version"}
|
||||
supplied = set(values)
|
||||
is_worker_request = worker_fields.issubset(supplied)
|
||||
if is_worker_request:
|
||||
if supplied != worker_fields:
|
||||
raise ValueError("release worker request contains unknown fields")
|
||||
_validate_identifier(
|
||||
"candidate_id", str(values.get("candidate_id") or "")
|
||||
)
|
||||
_validate_identifier(
|
||||
"version", str(values.get("version") or "")
|
||||
)
|
||||
else:
|
||||
if not supplied.issubset(intent_fields):
|
||||
raise ValueError("trusted builder intent contains unknown fields")
|
||||
# Preserve the established internal intent contract
|
||||
# (source_commit + version) while allowing newer callers to
|
||||
# add an event/intent identifier. Neither form can supply
|
||||
# acceptance evidence; the host builder owns all collectors.
|
||||
if values.get("event_id"):
|
||||
_validate_identifier(
|
||||
"event_id", str(values["event_id"])
|
||||
)
|
||||
if values.get("intent_id"):
|
||||
_validate_identifier(
|
||||
"intent_id", str(values["intent_id"])
|
||||
)
|
||||
if values.get("version"):
|
||||
_validate_identifier(
|
||||
"version", str(values["version"])
|
||||
)
|
||||
_validate_oid(
|
||||
"source_commit", str(values.get("source_commit") or "")
|
||||
)
|
||||
readiness = self.get_builder_readiness()
|
||||
if readiness.get("status") != "READY":
|
||||
raise TrustedCandidateBuilderUnavailable(
|
||||
"trusted builder dependency is unavailable"
|
||||
)
|
||||
bundle = self.candidate_builder.build(dict(payload)).validate(
|
||||
self.builder_attestation_verifier
|
||||
)
|
||||
requested_source = str(payload.get("source_commit") or "")
|
||||
if (
|
||||
requested_source
|
||||
and bundle.candidate.source_commit != requested_source
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"trusted builder source commit does not match intent"
|
||||
)
|
||||
if (
|
||||
is_worker_request
|
||||
and bundle.candidate.candidate_id
|
||||
!= str(values["candidate_id"])
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"trusted builder candidate id does not match request"
|
||||
)
|
||||
if self.trusted_policy is None:
|
||||
raise CapabilityAuthorizationError(
|
||||
"trusted release policy is not configured"
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"schema": "aios.trusted-candidate-builder-policy.v1",
|
||||
"repository": "aios/aios",
|
||||
"request_schema": "aios.trusted-candidate-builder.request.v1",
|
||||
"response_schema": "aios.trusted-candidate-builder.response.v1",
|
||||
"operation": "BuildTrustedCandidateV1",
|
||||
"readiness_operation": "ProbeTrustedCandidateBuilderV1",
|
||||
"maximum_request_age_seconds": 60,
|
||||
"request_timeout_seconds": 30,
|
||||
"allowed_peer_uid": 1000,
|
||||
"allowed_peer_cgroups": [
|
||||
"/system.slice/aios-gitops-model-api.service"
|
||||
],
|
||||
"allowed_peer_executable_prefix": "/usr/bin/python3",
|
||||
"allowed_peer_runtime_prefix": "/opt/aios/gitops-model-api/",
|
||||
"allowed_peer_runtime_suffix": "/runtime/app/aios_model_api_staging.py",
|
||||
"required_checks": [
|
||||
"authoritative_git",
|
||||
"artifact",
|
||||
"canary",
|
||||
"gitea",
|
||||
"manifest",
|
||||
"package_closure",
|
||||
"rollback",
|
||||
"secret_scan",
|
||||
"tests"
|
||||
],
|
||||
"evidence_layout": {
|
||||
"artifact": "{version}/artifact.tar.gz",
|
||||
"manifest": "{version}/manifest.json",
|
||||
"package_closure": "{version}/checksums.sha256",
|
||||
"tests": "{version}/tests.json",
|
||||
"canary": "{version}/canary.json",
|
||||
"gitea": "{version}/gitea.json",
|
||||
"rollback": "{version}/rollback.json",
|
||||
"policy": "{version}/silent-release-policy.json",
|
||||
"secret_scan": "{version}/secret-scan.json"
|
||||
},
|
||||
"recipes": {
|
||||
"V2R2C2": {
|
||||
"mode": "fixed",
|
||||
"task_id": "r42-release-candidate-v2r2c2-live-20260819",
|
||||
"candidate_id": "r42-release-candidate-v2r2c2-20260819",
|
||||
"base_oid": "71e736b05fab13efc06af7a4fe2dc022c1cf9a85",
|
||||
"source_commit": "71e736b05fab13efc06af7a4fe2dc022c1cf9a85",
|
||||
"version": "V2R2C2",
|
||||
"purpose": "release:candidate:trusted-build"
|
||||
},
|
||||
"V2R2C3": {
|
||||
"mode": "governed-derived-only",
|
||||
"derives_from": "V2R2C2",
|
||||
"version": "V2R2C3",
|
||||
"purpose": "release:candidate:trusted-build"
|
||||
}
|
||||
},
|
||||
"governed_recipes": {}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
[Unit]
|
||||
Description=AIOS owner-controlled Task initial execution projection API
|
||||
After=aios-agent-task-api.service aios-chatgpt-bridge-v2-staging.service
|
||||
Wants=aios-agent-task-api.service aios-chatgpt-bridge-v2-staging.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios-agent-task
|
||||
Group=aios-agent-task
|
||||
LoadCredential=agent_task_control_token:/etc/aios-gitops/agent-task-control.token
|
||||
Environment=AIOS_AGENT_TASK_DB=/var/lib/aios-agent-task/state.sqlite3
|
||||
Environment=AIOS_AGENT_TASK_CONTROL_TOKEN_FILE=%d/agent_task_control_token
|
||||
Environment=AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892
|
||||
Environment=AIOS_AGENT_TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZATION_ID=owner-initial-execution-projection-recovery-20260820
|
||||
Environment=AIOS_TASK_INITIAL_EXECUTION_PROJECTION_HOST=127.0.0.1
|
||||
Environment=AIOS_TASK_INITIAL_EXECUTION_PROJECTION_PORT=18943
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_agent_task_initial_execution_projection.py
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
RestrictNamespaces=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||
ReadWritePaths=/var/lib/aios-agent-task
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,37 +0,0 @@
|
||||
[Unit]
|
||||
Description=AIOS owner-controlled supervisor coordinator projection API
|
||||
After=aios-agent-task-api.service aios-chatgpt-bridge-v2-staging.service
|
||||
Wants=aios-agent-task-api.service aios-chatgpt-bridge-v2-staging.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios-agent-task
|
||||
Group=aios-agent-task
|
||||
LoadCredential=agent_task_control_token:/etc/aios-gitops/agent-task-control.token
|
||||
Environment=AIOS_AGENT_TASK_DB=/var/lib/aios-agent-task/state.sqlite3
|
||||
Environment=AIOS_AGENT_TASK_CONTROL_TOKEN_FILE=%d/agent_task_control_token
|
||||
Environment=AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892
|
||||
Environment=AIOS_AGENT_TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZATION_ID=owner-supervisor-coordinator-projection-recovery-20260820
|
||||
Environment=AIOS_SUPERVISOR_COORDINATOR_PROJECTION_HOST=127.0.0.1
|
||||
Environment=AIOS_SUPERVISOR_COORDINATOR_PROJECTION_PORT=18944
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_agent_task_supervisor_coordinator_projection.py
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
RestrictNamespaces=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||
ReadWritePaths=/var/lib/aios-agent-task
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,45 +0,0 @@
|
||||
[Unit]
|
||||
Description=AIOS credential-isolated release-preparation supervisor coordinator
|
||||
After=network-online.target aios-agent-task-api.service aios-autonomous-agent-register.service
|
||||
Wants=network-online.target aios-agent-task-api.service
|
||||
Requires=aios-autonomous-agent-register.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios-autonomous-agent-worker
|
||||
Group=aios-autonomous-agent-worker
|
||||
SupplementaryGroups=aios-agent-runtime
|
||||
LoadCredentialEncrypted=agent_token:/etc/credstore.encrypted/aios-autonomous-agent-worker/agent_token
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940
|
||||
Environment=AIOS_AGENT_ID=online-engineering-worker-cn-network-mkt-autonomous-20260815
|
||||
Environment=AIOS_RELEASE_PREPARATION_SUPERVISOR_TASK_ID=r42-release-preparation-autonomous-supervisor-20260820
|
||||
Environment=AIOS_SUPERVISOR_COORDINATOR_STATE_ROOT=/var/lib/aios-release-preparation-supervisor-coordinator
|
||||
Environment=AIOS_SUPERVISOR_COORDINATOR_POLL_SECONDS=5
|
||||
Environment=AIOS_SUPERVISOR_COORDINATOR_HEARTBEAT_SECONDS=10
|
||||
Environment=AIOS_SUPERVISOR_COORDINATOR_LEASE_SECONDS=300
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_release_preparation_supervisor_coordinator.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=20
|
||||
UMask=0027
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
RestrictNamespaces=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictAddressFamilies=AF_INET AF_UNIX
|
||||
ReadWritePaths=/var/lib/aios-release-preparation-supervisor-coordinator
|
||||
InaccessiblePaths=-/etc/credstore.encrypted
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,44 @@
|
||||
[Unit]
|
||||
Description=AIOS trusted candidate builder
|
||||
Requires=aios-trusted-candidate-builder.socket
|
||||
After=aios-trusted-candidate-builder.socket
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
UMask=0077
|
||||
LoadCredentialEncrypted=signing_private_key:/etc/credstore.encrypted/aios-trusted-candidate-builder/signing_private_key
|
||||
LoadCredentialEncrypted=builder_hmac:/etc/credstore.encrypted/aios-trusted-candidate-builder/builder_hmac
|
||||
Environment=AIOS_TRUSTED_BUILDER_POLICY=/opt/aios/trusted-candidate-builder/current/config/gitops/trusted-candidate-builder-policy.json
|
||||
Environment=AIOS_TRUSTED_BUILDER_GIT_DIR=/data/aios-gitops/repo.git
|
||||
Environment=AIOS_TRUSTED_BUILDER_EVIDENCE_ROOT=/var/lib/aios-trusted-candidate-builder/evidence
|
||||
Environment=AIOS_TRUSTED_BUILDER_NONCE_LEDGER=/var/lib/aios-trusted-candidate-builder/nonce-ledger.json
|
||||
Environment=AIOS_TRUSTED_BUILDER_KEY_ID=builder-attestation-ed25519-g1
|
||||
Environment=AIOS_TRUSTED_BUILDER_KEY_GENERATION=1
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/trusted-candidate-builder/current/app/aios_trusted_candidate_builder.py --socket-fd 3 --policy ${AIOS_TRUSTED_BUILDER_POLICY}
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
StateDirectory=aios-trusted-candidate-builder
|
||||
StateDirectoryMode=0700
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateNetwork=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectControlGroups=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictSUIDSGID=true
|
||||
SystemCallArchitectures=native
|
||||
CapabilityBoundingSet=
|
||||
ReadOnlyPaths=/opt/aios/trusted-candidate-builder /data/aios-gitops/repo.git /var/lib/aios-trusted-candidate-builder/evidence
|
||||
ReadWritePaths=/var/lib/aios-trusted-candidate-builder
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=AIOS trusted candidate builder AF_UNIX socket
|
||||
|
||||
[Socket]
|
||||
ListenStream=/run/aios-trusted-candidate-builder.sock
|
||||
SocketUser=root
|
||||
SocketGroup=gitops
|
||||
SocketMode=0660
|
||||
RemoveOnStop=yes
|
||||
Accept=no
|
||||
|
||||
[Install]
|
||||
WantedBy=sockets.target
|
||||
@@ -1,10 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
@@ -17,8 +19,18 @@ REQUIRED_APP = (
|
||||
'gitea_tag_publication.py',
|
||||
'silent_release_controller.py',
|
||||
'silent_release_hardening.py',
|
||||
'aios_trusted_candidate_builder.py',
|
||||
'aios_gitops_job_controller.py',
|
||||
)
|
||||
REQUIRED_RUNTIME = (
|
||||
'orchestrator/__init__.py',
|
||||
'orchestrator/action_monitor.py',
|
||||
)
|
||||
REQUIRED_BUILDER_ASSETS = (
|
||||
Path('config/gitops/trusted-candidate-builder-policy.json'),
|
||||
Path('deploy/systemd/aios-trusted-candidate-builder.socket'),
|
||||
Path('deploy/systemd/aios-trusted-candidate-builder.service'),
|
||||
)
|
||||
OPTIONAL_APP = (
|
||||
'aios_chatgpt_actions_api.py',
|
||||
'aios_continuation_executor.py',
|
||||
@@ -44,6 +56,38 @@ def private_marker(path: Path) -> bool:
|
||||
)
|
||||
return any(re.search(pattern, text) for pattern in patterns)
|
||||
|
||||
def checked_regular_file(path: Path, label: str) -> Path:
|
||||
forbidden_parts = {'.git', 'credential', 'credentials', 'secret', 'secrets'}
|
||||
if forbidden_parts.intersection(part.lower() for part in Path(label).parts):
|
||||
raise SystemExit(f'credential path rejected: {label}')
|
||||
if path.is_symlink():
|
||||
raise SystemExit(f'symlink rejected: {label}')
|
||||
try:
|
||||
mode = path.stat(follow_symlinks=False).st_mode
|
||||
except FileNotFoundError:
|
||||
raise SystemExit(f'required dependency missing: {label}') from None
|
||||
if not stat.S_ISREG(mode):
|
||||
raise SystemExit(f'non-regular file rejected: {label}')
|
||||
return path
|
||||
|
||||
def normalized_tar_info(path: Path, arcname: str) -> tarfile.TarInfo:
|
||||
info = tarfile.TarInfo(arcname)
|
||||
info.mtime = 0
|
||||
info.uid = 0
|
||||
info.gid = 0
|
||||
info.uname = 'root'
|
||||
info.gname = 'root'
|
||||
if path.is_dir():
|
||||
info.type = tarfile.DIRTYPE
|
||||
info.mode = 0o755
|
||||
info.size = 0
|
||||
else:
|
||||
info.type = tarfile.REGTYPE
|
||||
source_mode = path.stat(follow_symlinks=False).st_mode
|
||||
info.mode = 0o755 if source_mode & stat.S_IXUSR else 0o644
|
||||
info.size = path.stat(follow_symlinks=False).st_size
|
||||
return info
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--source-root', required=True, type=Path)
|
||||
@@ -63,27 +107,48 @@ def main() -> int:
|
||||
paths = []
|
||||
for name in REQUIRED_APP:
|
||||
path = app / name
|
||||
if not path.is_file():
|
||||
raise SystemExit(f'required app dependency missing: {name}')
|
||||
checked_regular_file(path, f'active-runtime/app/{name}')
|
||||
paths.append((Path('app') / name, path))
|
||||
for name in OPTIONAL_APP:
|
||||
path = app / name
|
||||
if path.is_symlink():
|
||||
raise SystemExit(f'symlink rejected: active-runtime/app/{name}')
|
||||
if path.is_file():
|
||||
checked_regular_file(path, f'active-runtime/app/{name}')
|
||||
paths.append((Path('app') / name, path))
|
||||
for rel in ('orchestrator/__init__.py', 'orchestrator/action_monitor.py'):
|
||||
for rel in REQUIRED_RUNTIME:
|
||||
path = runtime / rel
|
||||
if not path.is_file():
|
||||
raise SystemExit(f'required runtime dependency missing: {rel}')
|
||||
checked_regular_file(path, f'runtime/agent-os/{rel}')
|
||||
paths.append((Path('runtime-agent-os') / rel, path))
|
||||
for rel in REQUIRED_BUILDER_ASSETS:
|
||||
path = source / rel
|
||||
checked_regular_file(path, str(rel).replace('\\', '/'))
|
||||
paths.append((rel, path))
|
||||
openapi = docs / 'openapi-chatgpt-actions.yaml'
|
||||
if openapi.is_symlink():
|
||||
raise SystemExit('symlink rejected: docs/gitops/openapi-chatgpt-actions.yaml')
|
||||
if openapi.is_file():
|
||||
checked_regular_file(openapi, 'docs/gitops/openapi-chatgpt-actions.yaml')
|
||||
paths.append((Path('docs') / 'gitops' / 'openapi-chatgpt-actions.yaml', openapi))
|
||||
for folder in ('config/model_registry', 'config/model_priority', 'patches', 'versions'):
|
||||
root = source / folder
|
||||
if root.is_dir():
|
||||
for path in sorted(root.rglob('*')):
|
||||
if path.is_symlink():
|
||||
raise SystemExit(f'symlink rejected: {path.relative_to(source)}')
|
||||
if not path.is_dir() and not path.is_file():
|
||||
raise SystemExit(f'special file rejected: {path.relative_to(source)}')
|
||||
if path.is_file() and path.suffix.lower() in {'.json', '.yaml', '.yml', '.md'}:
|
||||
checked_regular_file(path, str(path.relative_to(source)).replace('\\', '/'))
|
||||
paths.append((Path(folder) / path.relative_to(root), path))
|
||||
seen: set[str] = set()
|
||||
for rel, _ in paths:
|
||||
normalized = str(rel).replace('\\', '/')
|
||||
if normalized.startswith('/') or '..' in Path(normalized).parts:
|
||||
raise SystemExit(f'path traversal rejected: {normalized}')
|
||||
if normalized in seen:
|
||||
raise SystemExit(f'duplicate archive member rejected: {normalized}')
|
||||
seen.add(normalized)
|
||||
for rel, path in paths:
|
||||
if private_marker(path):
|
||||
raise SystemExit(f'private marker rejected: {rel}')
|
||||
@@ -110,27 +175,25 @@ def main() -> int:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(path.read_bytes())
|
||||
(root / 'manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + '\n', encoding='utf-8')
|
||||
with tarfile.open(archive, 'w:gz', format=tarfile.PAX_FORMAT) as tf:
|
||||
for path in sorted(root.rglob('*')):
|
||||
rel = path.relative_to(root.parent)
|
||||
info = tf.gettarinfo(str(path), arcname=str(rel).replace('\\', '/'))
|
||||
info.mtime = 0
|
||||
info.uid = 0
|
||||
info.gid = 0
|
||||
info.uname = 'root'
|
||||
info.gname = 'root'
|
||||
if path.is_file():
|
||||
with path.open('rb') as fh:
|
||||
tf.addfile(info, fh)
|
||||
else:
|
||||
tf.addfile(info)
|
||||
with archive.open('wb') as raw:
|
||||
with gzip.GzipFile(filename='', mode='wb', fileobj=raw, mtime=0) as compressed:
|
||||
with tarfile.open(fileobj=compressed, mode='w', format=tarfile.PAX_FORMAT) as tf:
|
||||
for path in sorted(root.rglob('*')):
|
||||
rel = path.relative_to(root.parent)
|
||||
arcname = str(rel).replace('\\', '/')
|
||||
info = normalized_tar_info(path, arcname)
|
||||
if path.is_file():
|
||||
with path.open('rb') as fh:
|
||||
tf.addfile(info, fh)
|
||||
else:
|
||||
tf.addfile(info)
|
||||
digest = sha256(archive)
|
||||
receipt = {
|
||||
'schema': 'aios.v2r2c1.model-api-staging-build-receipt.v1',
|
||||
'target_version': 'V2R2C1',
|
||||
'source_oid': oid,
|
||||
'repository_identity': 'aios/aios',
|
||||
'artifact_path': str(archive),
|
||||
'artifact_path': archive.name,
|
||||
'artifact_sha256': digest,
|
||||
'deployment_state': 'STAGING',
|
||||
'manifest_schema': SCHEMA,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
placeholder
|
||||
@@ -10,13 +10,9 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime/app"))
|
||||
|
||||
from aios_capability_contract import ( # noqa: E402
|
||||
capability_contract,
|
||||
has_capabilities,
|
||||
normalize_required_capabilities,
|
||||
normalize_worker_capabilities,
|
||||
normalized_capability_matrix,
|
||||
registered_capability,
|
||||
validate_capability_contract_registry,
|
||||
)
|
||||
|
||||
|
||||
@@ -35,32 +31,3 @@ def test_runtime_capabilities_are_isolated_between_workers() -> None:
|
||||
|
||||
def test_unknown_runtime_capability_remains_fail_closed() -> None:
|
||||
assert has_capabilities(["agent.runtime.hermes"], ["agent.runtime.unknown"]) is False
|
||||
|
||||
|
||||
def test_autonomous_coordinator_capability_is_registered_on_both_sides() -> None:
|
||||
capability = "engineering.autonomous"
|
||||
assert registered_capability(capability) is True
|
||||
assert registered_capability(capability, worker=True) is True
|
||||
assert normalize_required_capabilities([capability]) == frozenset({capability})
|
||||
assert normalize_worker_capabilities([capability]) == frozenset({capability})
|
||||
contract = capability_contract(capability)
|
||||
assert contract is not None
|
||||
assert contract.dependencies == ("agent_task_control_plane",)
|
||||
assert validate_capability_contract_registry()["valid"] is True
|
||||
|
||||
|
||||
def test_supervisor_coordinator_requires_runtime_and_autonomous_capabilities() -> None:
|
||||
required = ["agent.runtime.openclaw", "engineering.autonomous"]
|
||||
assert normalized_capability_matrix(required, required) == {
|
||||
"worker_effective": required,
|
||||
"task_required": required,
|
||||
"eligible": True,
|
||||
}
|
||||
assert has_capabilities(["agent.runtime.openclaw"], required) is False
|
||||
assert has_capabilities(["engineering.autonomous"], required) is False
|
||||
|
||||
|
||||
def test_autonomous_capability_near_matches_remain_fail_closed() -> None:
|
||||
worker = ["agent.runtime.openclaw", "engineering.autonomous"]
|
||||
assert has_capabilities(worker, ["engineering.autonomous.extra"]) is False
|
||||
assert has_capabilities(worker, ["engineering.*"]) is False
|
||||
|
||||
@@ -1,452 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0,str(ROOT/"active-runtime/app"))
|
||||
|
||||
from aios_agent_task_initial_execution_projection import (
|
||||
InitialExecutionProjectionError,
|
||||
InitialExecutionProjectionRequest,
|
||||
InitialExecutionProjectionStore,
|
||||
SourceCaptureProjectionRecoveryRequest,
|
||||
)
|
||||
from aios_online_agent_execution import (
|
||||
GovernedExecutionBinding,
|
||||
project_execution_envelope,
|
||||
)
|
||||
|
||||
AUTHORIZATION="owner-initial-execution-projection-recovery-20260820"
|
||||
TASK_ID="task-initial-projection-a"
|
||||
JOB_ID="job-initial-projection-a"
|
||||
BASE="a"*40
|
||||
BRANCH="agent/task-initial-projection-a"
|
||||
IDEMPOTENCY="task-initial-projection-binding-v1"
|
||||
CAPABILITY="agent.runtime.openclaw"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _auth(monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"AIOS_AGENT_TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZATION_ID",
|
||||
AUTHORIZATION,
|
||||
)
|
||||
|
||||
|
||||
def canon(value):
|
||||
return hashlib.sha256(
|
||||
json.dumps(value,sort_keys=True,separators=(",",":")).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def binding():
|
||||
return GovernedExecutionBinding.from_mapping({
|
||||
"task_id":TASK_ID,
|
||||
"job_id":JOB_ID,
|
||||
"repository":"aios/aios",
|
||||
"base_oid":BASE,
|
||||
"expected_branch_oid":BASE,
|
||||
"target_branch":BRANCH,
|
||||
"branch_mode":"CREATE_NEW",
|
||||
"generation":1,
|
||||
"idempotency_key":IDEMPOTENCY,
|
||||
})
|
||||
|
||||
|
||||
def runtime_input():
|
||||
return {
|
||||
"goal":"Supervise release preparation",
|
||||
"instructions":"Observe and resume only the same lineage.",
|
||||
"success_criteria":[{"id":"heartbeat","description":"Heartbeat observed"}],
|
||||
"constraints":{"production_write":False},
|
||||
"scope":{"scope_type":"legacy","tenant_id":None,"project_id":None},
|
||||
"trace_id":TASK_ID,
|
||||
}
|
||||
|
||||
|
||||
def envelope(*, job_id=JOB_ID, action="agent_runtime", capability=CAPABILITY):
|
||||
bound=binding()
|
||||
if job_id != JOB_ID:
|
||||
bound=GovernedExecutionBinding.from_mapping({**bound.public(),"job_id":job_id})
|
||||
return project_execution_envelope(
|
||||
bound,
|
||||
{
|
||||
"action":action,
|
||||
"adapter":"openclaw" if action=="agent_runtime" else "source_capture",
|
||||
"capability_scope":[capability],
|
||||
"runtime_input":runtime_input() if action=="agent_runtime" else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def make_db(tmp_path, *, claimed_by=None, lease=False, metadata=None, caps=None, event="TASK_CREATED"):
|
||||
path=tmp_path/"state.sqlite3"
|
||||
con=sqlite3.connect(path)
|
||||
con.executescript("""
|
||||
CREATE TABLE 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
|
||||
);
|
||||
CREATE TABLE 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
|
||||
);
|
||||
""")
|
||||
metadata={} if metadata is None else metadata
|
||||
caps=[] if caps is None else caps
|
||||
con.execute(
|
||||
"INSERT INTO tasks VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
TASK_ID,"Supervise release preparation",
|
||||
"Observe and resume only the same lineage.",
|
||||
json.dumps([{"id":"heartbeat","description":"Heartbeat observed"}]),
|
||||
json.dumps(caps),json.dumps({"production_write":False}),json.dumps(metadata),
|
||||
None,100,20,0,"QUEUED",claimed_by,
|
||||
"lease-hash" if lease else None,
|
||||
"2099-01-01T00:00:00Z" if lease else None,
|
||||
None,None,None,None,1,"2026-08-20T00:00:00Z","2026-08-20T00:00:00Z",
|
||||
),
|
||||
)
|
||||
con.execute(
|
||||
"INSERT INTO events(task_id,agent_id,event_type,payload_json,created_at) VALUES (?,?,?,?,?)",
|
||||
(TASK_ID,None,event,"{}","2026-08-20T00:00:00Z"),
|
||||
)
|
||||
con.commit();con.close()
|
||||
return path
|
||||
|
||||
|
||||
def request(*, digest=None, key="initial-projection-a-v1"):
|
||||
return InitialExecutionProjectionRequest.from_mapping({
|
||||
"expected_status":"QUEUED",
|
||||
"expected_revision":1,
|
||||
"expected_attempt":0,
|
||||
"expected_metadata_sha256":canon({}),
|
||||
"expected_required_capabilities_sha256":canon([]),
|
||||
"expected_job_id":JOB_ID,
|
||||
"expected_execution_digest":digest or envelope()["execution_digest"],
|
||||
"required_capability":CAPABILITY,
|
||||
"adapter":"openclaw",
|
||||
"authorization_id":AUTHORIZATION,
|
||||
"idempotency_key":key,
|
||||
"reason":"initial governed supervisor runtime projection",
|
||||
})
|
||||
|
||||
|
||||
def resolver(_task_id,_job_id,_capability,_adapter,received_runtime_input):
|
||||
assert received_runtime_input==runtime_input()
|
||||
return envelope()
|
||||
|
||||
|
||||
def source_capture_result():
|
||||
old=envelope(action="source_capture",capability="git.read")
|
||||
return {
|
||||
"task_id":TASK_ID,
|
||||
"job_id":JOB_ID,
|
||||
"repository":"aios/aios",
|
||||
"base_oid":BASE,
|
||||
"expected_branch_oid":BASE,
|
||||
"target_branch":BRANCH,
|
||||
"generation":1,
|
||||
"status":"SUCCEEDED",
|
||||
"classification":"NEEDS_REWORK_SOURCE_CAPTURE",
|
||||
"execution_mode":"source_capture",
|
||||
"execution_digest":old["execution_digest"],
|
||||
"changed_files":[],
|
||||
"writer_lease":"NONE",
|
||||
"verification_state":"NEEDS_REWORK",
|
||||
"direct_main_write":False,
|
||||
"production_write":False,
|
||||
"force_push":False,
|
||||
"protected_ref_bypass":False,
|
||||
"secret_material_exposed":False,
|
||||
"source_snapshot":{"schema":"test.snapshot.v1","payload_sha256":"d"*64},
|
||||
}
|
||||
|
||||
|
||||
def make_source_capture_rework_db(tmp_path, *, mutate=None, events=None):
|
||||
path=make_db(tmp_path)
|
||||
result=source_capture_result()
|
||||
verification={
|
||||
"decision":"NEEDS_REWORK",
|
||||
"reason":"SOURCE_CAPTURE_COMPLETE_RUNTIME_PROJECTION_REQUIRED",
|
||||
}
|
||||
if mutate:
|
||||
mutate(result,verification)
|
||||
event_types=events or [
|
||||
"TASK_CLAIMED","TASK_HEARTBEAT","RESULT_SUBMITTED","TASK_VERIFIED"
|
||||
]
|
||||
con=sqlite3.connect(path)
|
||||
con.execute(
|
||||
"UPDATE tasks SET attempt=1,status='QUEUED',claimed_by=NULL,revision=5,"
|
||||
"result_json=?,result_digest=?,result_idempotency_key=?,verification_json=?",
|
||||
(
|
||||
json.dumps(result,sort_keys=True,separators=(",",":")),
|
||||
canon(result),"source-capture-result-v1",
|
||||
json.dumps(verification,sort_keys=True,separators=(",",":")),
|
||||
),
|
||||
)
|
||||
for event_type in event_types:
|
||||
payload={}
|
||||
if event_type=="TASK_VERIFIED":
|
||||
payload={"decision":"NEEDS_REWORK","next_state":"QUEUED"}
|
||||
con.execute(
|
||||
"INSERT INTO events(task_id,agent_id,event_type,payload_json,created_at) "
|
||||
"VALUES (?,?,?,?,?)",
|
||||
(TASK_ID,"generic-worker",event_type,json.dumps(payload),"2026-08-20T00:01:00Z"),
|
||||
)
|
||||
con.commit();con.close()
|
||||
return path,result,verification
|
||||
|
||||
|
||||
def recovery_request(result,verification,*,digest=None,key="source-capture-recovery-a-v1"):
|
||||
return SourceCaptureProjectionRecoveryRequest.from_mapping({
|
||||
"expected_status":"QUEUED",
|
||||
"expected_revision":5,
|
||||
"expected_attempt":1,
|
||||
"expected_metadata_sha256":canon({}),
|
||||
"expected_required_capabilities_sha256":canon([]),
|
||||
"previous_verification_sha256":canon(verification),
|
||||
"expected_source_capture_result_sha256":canon(result),
|
||||
"expected_source_capture_execution_digest":result["execution_digest"],
|
||||
"expected_job_id":JOB_ID,
|
||||
"expected_execution_digest":digest or envelope()["execution_digest"],
|
||||
"required_capability":CAPABILITY,
|
||||
"adapter":"openclaw",
|
||||
"authorization_id":AUTHORIZATION,
|
||||
"idempotency_key":key,
|
||||
"reason":"first durable runtime projection after governed source capture",
|
||||
})
|
||||
|
||||
|
||||
def test_initial_projection_cas_persists_envelope_capability_and_event(tmp_path):
|
||||
path=make_db(tmp_path)
|
||||
result=InitialExecutionProjectionStore(path,resolver).project(TASK_ID,request())
|
||||
assert result["revision"]==2
|
||||
assert result["attempt"]==0
|
||||
assert result["idempotent"] is False
|
||||
con=sqlite3.connect(path)
|
||||
row=con.execute(
|
||||
"SELECT status,attempt,revision,claimed_by,metadata_json,required_capabilities_json FROM tasks"
|
||||
).fetchone()
|
||||
assert row[:4]==("QUEUED",0,2,None)
|
||||
metadata=json.loads(row[4])
|
||||
assert metadata["execution"]["action"]=="agent_runtime"
|
||||
assert metadata["execution"]["adapter"]=="openclaw"
|
||||
assert metadata["execution"]["governance"]["writer_lease"] is False
|
||||
assert json.loads(row[5])==[CAPABILITY]
|
||||
events=con.execute("SELECT event_type,payload_json FROM events ORDER BY event_id").fetchall()
|
||||
assert [event[0] for event in events]==[
|
||||
"TASK_CREATED","TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZED"
|
||||
]
|
||||
payload=json.loads(events[-1][1])
|
||||
assert payload["job_id"]==JOB_ID
|
||||
assert payload["main_write"] is False
|
||||
assert payload["production_write"] is False
|
||||
|
||||
|
||||
def test_initial_projection_idempotent_exact_replay(tmp_path):
|
||||
path=make_db(tmp_path)
|
||||
store=InitialExecutionProjectionStore(path,resolver)
|
||||
first=store.project(TASK_ID,request())
|
||||
second=store.project(TASK_ID,request())
|
||||
assert first["idempotent"] is False
|
||||
assert second["idempotent"] is True
|
||||
assert second["execution_digest"]==first["execution_digest"]
|
||||
con=sqlite3.connect(path)
|
||||
assert con.execute("SELECT COUNT(*) FROM task_initial_execution_projections").fetchone()[0]==1
|
||||
assert con.execute("SELECT COUNT(*) FROM events").fetchone()[0]==2
|
||||
|
||||
|
||||
def test_source_capture_recovery_projects_first_runtime_envelope(tmp_path):
|
||||
path,result,verification=make_source_capture_rework_db(tmp_path)
|
||||
response=InitialExecutionProjectionStore(path,resolver).project_from_source_capture(
|
||||
TASK_ID,recovery_request(result,verification)
|
||||
)
|
||||
assert response["revision"]==6
|
||||
assert response["attempt"]==1
|
||||
assert response["recovery_from_source_capture"] is True
|
||||
assert response["idempotent"] is False
|
||||
con=sqlite3.connect(path)
|
||||
row=con.execute(
|
||||
"SELECT status,attempt,revision,claimed_by,metadata_json,"
|
||||
"required_capabilities_json,result_json,verification_json FROM tasks"
|
||||
).fetchone()
|
||||
assert row[:4]==("QUEUED",1,6,None)
|
||||
metadata=json.loads(row[4])
|
||||
assert metadata["execution"]["action"]=="agent_runtime"
|
||||
assert metadata["execution"]["adapter"]=="openclaw"
|
||||
assert metadata["execution"]["governance"]["writer_lease"] is False
|
||||
assert json.loads(row[5])==[CAPABILITY]
|
||||
assert json.loads(row[6])==result
|
||||
assert json.loads(row[7])==verification
|
||||
events=con.execute("SELECT event_type,payload_json FROM events ORDER BY event_id").fetchall()
|
||||
assert [event[0] for event in events]==[
|
||||
"TASK_CREATED","TASK_CLAIMED","TASK_HEARTBEAT","RESULT_SUBMITTED",
|
||||
"TASK_VERIFIED","TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZED",
|
||||
]
|
||||
payload=json.loads(events[-1][1])
|
||||
assert payload["recovery_from_source_capture"] is True
|
||||
assert payload["source_capture_execution_digest"]==result["execution_digest"]
|
||||
assert payload["main_write"] is False
|
||||
assert payload["production_write"] is False
|
||||
|
||||
|
||||
def test_source_capture_recovery_idempotent_exact_replay(tmp_path):
|
||||
path,result,verification=make_source_capture_rework_db(tmp_path)
|
||||
store=InitialExecutionProjectionStore(path,resolver)
|
||||
first=store.project_from_source_capture(TASK_ID,recovery_request(result,verification))
|
||||
second=store.project_from_source_capture(TASK_ID,recovery_request(result,verification))
|
||||
assert first["idempotent"] is False
|
||||
assert second["idempotent"] is True
|
||||
assert second["execution_digest"]==first["execution_digest"]
|
||||
con=sqlite3.connect(path)
|
||||
assert con.execute(
|
||||
"SELECT COUNT(*) FROM task_source_capture_projection_recoveries"
|
||||
).fetchone()[0]==1
|
||||
assert con.execute("SELECT COUNT(*) FROM events").fetchone()[0]==6
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutate,error",
|
||||
[
|
||||
(lambda result,verification: result.__setitem__("changed_files",["x"]),
|
||||
"SOURCE_CAPTURE_RESULT_CONTRACT_INVALID"),
|
||||
(lambda result,verification: result.__setitem__("job_id","job-other"),
|
||||
"SOURCE_CAPTURE_RESULT_CONTRACT_INVALID"),
|
||||
(lambda result,verification: verification.__setitem__("decision","SUCCEEDED"),
|
||||
"SOURCE_CAPTURE_NEEDS_REWORK_VERIFICATION_REQUIRED"),
|
||||
],
|
||||
)
|
||||
def test_source_capture_recovery_rejects_unsafe_result_or_verification(
|
||||
tmp_path,mutate,error
|
||||
):
|
||||
path,result,verification=make_source_capture_rework_db(tmp_path,mutate=mutate)
|
||||
with pytest.raises(InitialExecutionProjectionError,match=error):
|
||||
InitialExecutionProjectionStore(path,resolver).project_from_source_capture(
|
||||
TASK_ID,recovery_request(result,verification)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"events",
|
||||
[
|
||||
["TASK_CLAIMED","RESULT_SUBMITTED","TASK_VERIFIED"],
|
||||
["TASK_CLAIMED","TASK_HEARTBEAT","RESULT_SUBMITTED","TASK_VERIFIED","TASK_CLAIMED"],
|
||||
["TASK_CLAIMED","TASK_HEARTBEAT","TASK_VERIFIED","RESULT_SUBMITTED"],
|
||||
],
|
||||
)
|
||||
def test_source_capture_recovery_rejects_invalid_event_chain(tmp_path,events):
|
||||
path,result,verification=make_source_capture_rework_db(tmp_path,events=events)
|
||||
with pytest.raises(
|
||||
InitialExecutionProjectionError,match="SOURCE_CAPTURE_RECOVERY_EVENT_CHAIN_INVALID"
|
||||
):
|
||||
InitialExecutionProjectionStore(path,resolver).project_from_source_capture(
|
||||
TASK_ID,recovery_request(result,verification)
|
||||
)
|
||||
|
||||
|
||||
def test_source_capture_recovery_rejects_execution_digest_drift(tmp_path):
|
||||
path,result,verification=make_source_capture_rework_db(tmp_path)
|
||||
with pytest.raises(InitialExecutionProjectionError,match="EXECUTION_DIGEST_DRIFT"):
|
||||
InitialExecutionProjectionStore(path,resolver).project_from_source_capture(
|
||||
TASK_ID,recovery_request(result,verification,digest="f"*64)
|
||||
)
|
||||
|
||||
|
||||
def test_source_capture_recovery_request_is_strict(tmp_path):
|
||||
_path,result,verification=make_source_capture_rework_db(tmp_path)
|
||||
data=recovery_request(result,verification).public()
|
||||
data["metadata_patch"]={"execution":"forbidden"}
|
||||
with pytest.raises(InitialExecutionProjectionError,match="FIELDS_INVALID"):
|
||||
SourceCaptureProjectionRecoveryRequest.from_mapping(data)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs,error",
|
||||
[
|
||||
({"claimed_by":"worker-a"},"TASK_ALREADY_CLAIMED"),
|
||||
({"lease":True},"ACTIVE_LEASE_DENY"),
|
||||
({"metadata":{"execution":{}}},"TASK_EXECUTION_ALREADY_PROJECTED"),
|
||||
({"caps":["git.read"]},"TASK_INITIAL_CAPABILITIES_MUST_BE_EMPTY"),
|
||||
({"event":"TASK_CLAIMED"},"TASK_INITIAL_EVENT_CHAIN_REQUIRED"),
|
||||
],
|
||||
)
|
||||
def test_initial_projection_rejects_noninitial_state(tmp_path,kwargs,error):
|
||||
path=make_db(tmp_path,**kwargs)
|
||||
with pytest.raises(InitialExecutionProjectionError,match=error):
|
||||
InitialExecutionProjectionStore(path,resolver).project(TASK_ID,request())
|
||||
|
||||
|
||||
def test_initial_projection_rejects_binding_drift(tmp_path):
|
||||
path=make_db(tmp_path)
|
||||
def wrong(*_args):
|
||||
return envelope(job_id="job-other")
|
||||
with pytest.raises(InitialExecutionProjectionError,match="GOVERNED_BINDING_CONFLICT"):
|
||||
InitialExecutionProjectionStore(path,wrong).project(TASK_ID,request())
|
||||
|
||||
|
||||
def test_initial_projection_rejects_nonruntime_envelope(tmp_path):
|
||||
path=make_db(tmp_path)
|
||||
def wrong(*_args):
|
||||
return envelope(action="source_capture",capability="git.read")
|
||||
with pytest.raises(InitialExecutionProjectionError):
|
||||
InitialExecutionProjectionStore(path,wrong).project(TASK_ID,request())
|
||||
|
||||
|
||||
def test_initial_projection_rejects_execution_digest_drift(tmp_path):
|
||||
path=make_db(tmp_path)
|
||||
with pytest.raises(InitialExecutionProjectionError,match="EXECUTION_DIGEST_DRIFT"):
|
||||
InitialExecutionProjectionStore(path,resolver).project(
|
||||
TASK_ID,request(digest="f"*64)
|
||||
)
|
||||
|
||||
|
||||
def test_request_is_narrow_and_initial_state_bound():
|
||||
data=request().public()
|
||||
data["arbitrary_metadata"]={"x":1}
|
||||
with pytest.raises(InitialExecutionProjectionError,match="FIELDS_INVALID"):
|
||||
InitialExecutionProjectionRequest.from_mapping(data)
|
||||
data=request().public();data["expected_attempt"]=1
|
||||
with pytest.raises(InitialExecutionProjectionError,match="INITIAL_STATE_REQUIRED"):
|
||||
InitialExecutionProjectionRequest.from_mapping(data)
|
||||
|
||||
|
||||
def test_initial_projection_unit_is_unprivileged_and_owner_credential_bound():
|
||||
unit=(ROOT/"deploy/systemd/aios-agent-task-initial-execution-projection.service").read_text()
|
||||
assert "User=aios-agent-task" in unit
|
||||
assert "Group=aios-agent-task" in unit
|
||||
assert "LoadCredential=agent_task_control_token:" in unit
|
||||
assert "Environment=AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892" in unit
|
||||
assert "RestrictAddressFamilies=AF_UNIX AF_INET" in unit
|
||||
assert "ReadWritePaths=/var/lib/aios-agent-task" in unit
|
||||
assert "User=root" not in unit
|
||||
@@ -1,188 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0,str(ROOT/"active-runtime/app"))
|
||||
|
||||
from aios_agent_task_initial_execution_projection import InitialExecutionProjectionError
|
||||
from aios_agent_task_supervisor_coordinator_projection import (
|
||||
COORDINATOR_CAPABILITIES,
|
||||
RUNTIME_CAPABILITY,
|
||||
SupervisorCoordinatorProjectionRequest,
|
||||
SupervisorCoordinatorProjectionStore,
|
||||
)
|
||||
from aios_online_agent_execution import (
|
||||
GovernedExecutionBinding,
|
||||
project_execution_envelope,
|
||||
)
|
||||
|
||||
|
||||
TASK="task-supervisor-coordinator-a"
|
||||
JOB="job-supervisor-coordinator-a"
|
||||
BASE="a"*40
|
||||
BRANCH="agent/task-supervisor-coordinator-a"
|
||||
BINDING_KEY="task-supervisor-coordinator-binding-v1"
|
||||
AUTH="owner-supervisor-coordinator-projection-recovery-20260820"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def authorization(monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"AIOS_AGENT_TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZATION_ID",AUTH
|
||||
)
|
||||
|
||||
|
||||
def sha(value):
|
||||
return hashlib.sha256(
|
||||
json.dumps(value,sort_keys=True,separators=(",",":")).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def binding():
|
||||
return GovernedExecutionBinding.from_mapping({
|
||||
"task_id":TASK,"job_id":JOB,"repository":"aios/aios",
|
||||
"base_oid":BASE,"expected_branch_oid":BASE,"target_branch":BRANCH,
|
||||
"branch_mode":"CREATE_NEW","generation":1,"idempotency_key":BINDING_KEY,
|
||||
})
|
||||
|
||||
|
||||
def runtime_input():
|
||||
return {
|
||||
"goal":"Durably supervise release preparation",
|
||||
"instructions":"Wait for exact connector ACK and preserve the same lineage.",
|
||||
"success_criteria":[{"id":"heartbeat","description":"Coordinator heartbeat"}],
|
||||
"constraints":{},
|
||||
"scope":{"scope_type":"legacy","tenant_id":None,"project_id":None},
|
||||
"trace_id":TASK,
|
||||
}
|
||||
|
||||
|
||||
def envelope(capabilities):
|
||||
return project_execution_envelope(binding(),{
|
||||
"action":"agent_runtime","adapter":"openclaw",
|
||||
"capability_scope":capabilities,"runtime_input":runtime_input(),
|
||||
})
|
||||
|
||||
|
||||
def planner_result(old_digest,*,access=False):
|
||||
return {
|
||||
"task_id":TASK,"job_id":JOB,"repository":"aios/aios",
|
||||
"base_oid":BASE,"expected_branch_oid":BASE,"target_branch":BRANCH,
|
||||
"generation":1,"status":"SUCCEEDED","execution_mode":"agent_runtime",
|
||||
"agent_runtime":"openclaw","execution_digest":old_digest,
|
||||
"control_plane_access_granted_to_runtime":access,
|
||||
"credential_material_exposed":False,"secret_material_exposed":False,
|
||||
"changed_files":[],"writer_lease":"NONE","direct_main_write":False,
|
||||
"production_write":False,"force_push":False,"protected_ref_bypass":False,
|
||||
"classification":"EXTERNAL_AGENT_RUNTIME_SUCCEEDED",
|
||||
}
|
||||
|
||||
|
||||
def make_db(tmp_path,*,access=False,heartbeat=True):
|
||||
tmp_path.mkdir(parents=True,exist_ok=True)
|
||||
path=tmp_path/"state.sqlite3";con=sqlite3.connect(path)
|
||||
con.executescript("""
|
||||
CREATE TABLE 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,
|
||||
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,created_at TEXT,updated_at TEXT
|
||||
);
|
||||
CREATE TABLE 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
|
||||
);
|
||||
""")
|
||||
old=envelope([RUNTIME_CAPABILITY]);result=planner_result(old["execution_digest"],access=access)
|
||||
verification={"decision":"NEEDS_REWORK","reason":"HOST_COORDINATOR_REQUIRED"}
|
||||
metadata={"execution":old,"execution_projection":{
|
||||
"schema":"aios.online-agent.task-projection.v1","task_id":TASK,
|
||||
"job_id":JOB,"execution_digest":old["execution_digest"],
|
||||
}}
|
||||
con.execute("INSERT INTO tasks VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",(
|
||||
TASK,runtime_input()["goal"],runtime_input()["instructions"],
|
||||
json.dumps(runtime_input()["success_criteria"]),json.dumps([RUNTIME_CAPABILITY]),
|
||||
"{}",json.dumps(metadata,sort_keys=True,separators=(",",":")),None,100,20,2,
|
||||
"QUEUED",None,None,None,json.dumps(result,sort_keys=True,separators=(",",":")),
|
||||
sha(result),"planner-result-v1",json.dumps(verification,sort_keys=True,separators=(",",":")),
|
||||
11,"2026-08-20T00:00:00Z","2026-08-20T00:00:00Z",
|
||||
))
|
||||
events=["TASK_CREATED","TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZED","TASK_CLAIMED"]
|
||||
if heartbeat: events.append("TASK_HEARTBEAT")
|
||||
events += ["RESULT_SUBMITTED","TASK_VERIFIED"]
|
||||
for event in events:
|
||||
payload={"decision":"NEEDS_REWORK","next_state":"QUEUED"} if event=="TASK_VERIFIED" else {}
|
||||
con.execute("INSERT INTO events(task_id,agent_id,event_type,payload_json,created_at) VALUES (?,?,?,?,?)",(TASK,"worker",event,json.dumps(payload),"2026-08-20T00:00:00Z"))
|
||||
con.commit();con.close()
|
||||
return path,metadata,result,verification,old
|
||||
|
||||
|
||||
def request(metadata,result,verification,old,new):
|
||||
return SupervisorCoordinatorProjectionRequest.from_mapping({
|
||||
"expected_status":"QUEUED","expected_revision":11,"expected_attempt":2,
|
||||
"expected_metadata_sha256":sha(metadata),
|
||||
"expected_required_capabilities_sha256":sha([RUNTIME_CAPABILITY]),
|
||||
"previous_verification_sha256":sha(verification),
|
||||
"expected_planner_result_sha256":sha(result),
|
||||
"expected_old_execution_digest":old["execution_digest"],
|
||||
"expected_new_execution_digest":new["execution_digest"],
|
||||
"expected_job_id":JOB,"authorization_id":AUTH,
|
||||
"idempotency_key":"task-supervisor-coordinator-projection-v1",
|
||||
"reason":"credential-isolated host coordinator required",
|
||||
})
|
||||
|
||||
|
||||
def test_projects_exact_coordinator_capabilities_and_preserves_planner_evidence(tmp_path):
|
||||
path,metadata,result,verification,old=make_db(tmp_path)
|
||||
new=envelope(COORDINATOR_CAPABILITIES)
|
||||
store=SupervisorCoordinatorProjectionStore(path,lambda task,job,runtime:new)
|
||||
response=store.project(TASK,request(metadata,result,verification,old,new))
|
||||
assert response["revision"]==12 and response["attempt"]==2
|
||||
assert response["required_capabilities"]==COORDINATOR_CAPABILITIES
|
||||
assert response["credential_forwarded_to_planner"] is False
|
||||
con=sqlite3.connect(path)
|
||||
row=con.execute("select status,revision,metadata_json,required_capabilities_json,result_json from tasks").fetchone()
|
||||
assert row[:2]==("QUEUED",12)
|
||||
assert json.loads(row[2])["execution"]["execution_digest"]==new["execution_digest"]
|
||||
assert json.loads(row[3])==COORDINATOR_CAPABILITIES
|
||||
assert json.loads(row[4])==result
|
||||
assert con.execute("select event_type from events order by event_id desc limit 1").fetchone()[0]=="TASK_SUPERVISOR_COORDINATOR_PROJECTION_AUTHORIZED"
|
||||
|
||||
|
||||
def test_exact_replay_is_idempotent(tmp_path):
|
||||
path,metadata,result,verification,old=make_db(tmp_path);new=envelope(COORDINATOR_CAPABILITIES)
|
||||
store=SupervisorCoordinatorProjectionStore(path,lambda task,job,runtime:new)
|
||||
req=request(metadata,result,verification,old,new)
|
||||
assert store.project(TASK,req)["idempotent"] is False
|
||||
assert store.project(TASK,req)["idempotent"] is True
|
||||
|
||||
|
||||
def test_rejects_planner_with_control_access_or_missing_heartbeat(tmp_path):
|
||||
path,metadata,result,verification,old=make_db(tmp_path,access=True);new=envelope(COORDINATOR_CAPABILITIES)
|
||||
with pytest.raises(InitialExecutionProjectionError,match="RESULT_CONTRACT_INVALID"):
|
||||
SupervisorCoordinatorProjectionStore(path,lambda *args:new).project(
|
||||
TASK,request(metadata,result,verification,old,new)
|
||||
)
|
||||
path,metadata,result,verification,old=make_db(tmp_path/"second",heartbeat=False)
|
||||
with pytest.raises(InitialExecutionProjectionError,match="EVENT_CHAIN_INVALID"):
|
||||
SupervisorCoordinatorProjectionStore(path,lambda *args:new).project(
|
||||
TASK,request(metadata,result,verification,old,new)
|
||||
)
|
||||
|
||||
|
||||
def test_request_rejects_arbitrary_metadata_patch(tmp_path):
|
||||
path,metadata,result,verification,old=make_db(tmp_path);new=envelope(COORDINATOR_CAPABILITIES)
|
||||
value=request(metadata,result,verification,old,new).public();value["metadata_patch"]={}
|
||||
with pytest.raises(InitialExecutionProjectionError,match="FIELDS_INVALID"):
|
||||
SupervisorCoordinatorProjectionRequest.from_mapping(value)
|
||||
@@ -1,4 +1,13 @@
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -33,3 +42,111 @@ def test_model_api_builder_manifest_contract():
|
||||
assert "'deployment_state': 'STAGING'" in builder
|
||||
assert "'legacy_global_writes_enabled': False" in builder
|
||||
assert 'private marker rejected' in builder
|
||||
|
||||
|
||||
def _load_builder_module():
|
||||
path = ROOT / 'scripts' / 'build_v2r2c1_model_api_staging.py'
|
||||
spec = importlib.util.spec_from_file_location('model_api_packager', path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _run_builder(source: Path, output: Path, oid: str = 'a' * 40):
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(ROOT / 'scripts' / 'build_v2r2c1_model_api_staging.py'),
|
||||
'--source-root',
|
||||
str(source),
|
||||
'--output-root',
|
||||
str(output),
|
||||
'--source-oid',
|
||||
oid,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _minimal_source(tmp_path: Path) -> Path:
|
||||
module = _load_builder_module()
|
||||
source = tmp_path / 'source'
|
||||
for name in module.REQUIRED_APP:
|
||||
target = source / 'active-runtime' / 'app' / name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(ROOT / 'active-runtime' / 'app' / name, target)
|
||||
for rel in module.REQUIRED_RUNTIME:
|
||||
target = source / 'runtime' / 'agent-os' / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(ROOT / 'runtime' / 'agent-os' / rel, target)
|
||||
for rel in module.REQUIRED_BUILDER_ASSETS:
|
||||
target = source / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(ROOT / rel, target)
|
||||
return source
|
||||
|
||||
|
||||
def test_model_api_package_contains_builder_runtime_and_is_deterministic(tmp_path):
|
||||
first = tmp_path / 'first'
|
||||
second = tmp_path / 'second'
|
||||
one = _run_builder(ROOT, first)
|
||||
two = _run_builder(ROOT, second)
|
||||
assert one.returncode == 0, one.stderr
|
||||
assert two.returncode == 0, two.stderr
|
||||
|
||||
receipt = json.loads(one.stdout)
|
||||
peer_receipt = json.loads(two.stdout)
|
||||
assert one.stdout == two.stdout
|
||||
archive = first / receipt['artifact_path']
|
||||
peer_archive = second / peer_receipt['artifact_path']
|
||||
assert hashlib.sha256(archive.read_bytes()).hexdigest() == hashlib.sha256(
|
||||
peer_archive.read_bytes()
|
||||
).hexdigest()
|
||||
|
||||
with tarfile.open(archive, 'r:gz') as bundle:
|
||||
members = bundle.getmembers()
|
||||
names = [member.name for member in members]
|
||||
assert len(names) == len(set(names))
|
||||
assert 'runtime/app/aios_trusted_candidate_builder.py' in names
|
||||
assert 'runtime/config/gitops/trusted-candidate-builder-policy.json' in names
|
||||
assert 'runtime/deploy/systemd/aios-trusted-candidate-builder.socket' in names
|
||||
assert 'runtime/deploy/systemd/aios-trusted-candidate-builder.service' in names
|
||||
assert all(member.isfile() or member.isdir() for member in members)
|
||||
assert all('.git' not in Path(name).parts for name in names)
|
||||
assert all(
|
||||
member.mode == (0o755 if member.isdir() else 0o644)
|
||||
for member in members
|
||||
)
|
||||
|
||||
|
||||
def test_model_api_packager_rejects_symlinked_required_asset(tmp_path):
|
||||
source = _minimal_source(tmp_path)
|
||||
policy = source / 'config' / 'gitops' / 'trusted-candidate-builder-policy.json'
|
||||
policy.unlink()
|
||||
try:
|
||||
policy.symlink_to(
|
||||
source / 'active-runtime' / 'app' / 'silent_release_hardening.py'
|
||||
)
|
||||
except OSError:
|
||||
pytest.skip('creating symlinks is not permitted by this host')
|
||||
result = _run_builder(source, tmp_path / 'output')
|
||||
assert result.returncode != 0
|
||||
assert 'symlink' in (result.stderr + result.stdout).lower()
|
||||
|
||||
|
||||
def test_private_marker_rejects_real_pem_but_allows_prose(tmp_path):
|
||||
module = _load_builder_module()
|
||||
prose = tmp_path / 'prose.txt'
|
||||
prose.write_text('Documentation says BEGIN PRIVATE KEY without a PEM fence.\n')
|
||||
assert module.private_marker(prose) is False
|
||||
|
||||
private = tmp_path / 'private.pem'
|
||||
pem_begin = '-----' + 'BEGIN PRIVATE KEY' + '-----\n'
|
||||
pem_end = '-----' + 'END PRIVATE KEY' + '-----\n'
|
||||
private.write_text(
|
||||
pem_begin + 'c2Vuc2l0aXZlLWJ1aWxkZXIta2V5LW1hdGVyaWFs\n'
|
||||
+ pem_end
|
||||
)
|
||||
assert module.private_marker(private) is True
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import importlib
|
||||
import importlib.util
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
|
||||
APP = Path(__file__).resolve().parents[1] / "active-runtime" / "app"
|
||||
AGENT_OS = Path(__file__).resolve().parents[1] / "runtime" / "agent-os"
|
||||
for import_root in (APP, AGENT_OS):
|
||||
if str(import_root) not in sys.path:
|
||||
sys.path.insert(0, str(import_root))
|
||||
|
||||
from silent_release_hardening import ( # noqa: E402
|
||||
BuilderAttestation,
|
||||
BuilderAttestationKeyRing,
|
||||
BuilderAttestationVerifier,
|
||||
CapabilityAuthorizationError,
|
||||
SilentReleaseActionService,
|
||||
TrustedCandidateBuilderUnavailable,
|
||||
)
|
||||
import silent_release_hardening as hardening # noqa: E402
|
||||
|
||||
|
||||
COMMIT = "a" * 40
|
||||
TREE = "b" * 40
|
||||
SHA = "c" * 64
|
||||
GITEA = {"consistent": True}
|
||||
|
||||
|
||||
def _af_inet_available() -> bool:
|
||||
try:
|
||||
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
except OSError:
|
||||
return False
|
||||
probe.close()
|
||||
return True
|
||||
|
||||
|
||||
def _canonical_digest(value: dict) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _instant(delta: timedelta = timedelta()) -> str:
|
||||
return (datetime.now(timezone.utc) + delta).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
|
||||
|
||||
def _keyring_payload(private: Ed25519PrivateKey, *, purpose: str) -> dict:
|
||||
public = private.public_key().public_bytes(
|
||||
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
||||
)
|
||||
record = {
|
||||
"algorithm": "Ed25519",
|
||||
"purpose": purpose,
|
||||
"key_id": "builder-key-001",
|
||||
"generation": 1,
|
||||
"not_before": _instant(timedelta(minutes=-1)),
|
||||
"expires_at": _instant(timedelta(days=1)),
|
||||
"status": "ACTIVE",
|
||||
"public_key": base64.b64encode(public).decode("ascii"),
|
||||
"public_key_sha256": hashlib.sha256(public).hexdigest(),
|
||||
}
|
||||
payload = {
|
||||
"algorithm": "Ed25519",
|
||||
"purpose": "builder_attestation",
|
||||
"keys": [record],
|
||||
}
|
||||
payload["keyring_sha256"] = hashlib.sha256(
|
||||
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
).hexdigest()
|
||||
return payload
|
||||
|
||||
|
||||
def _refresh_keyring_digest(payload: dict) -> dict:
|
||||
canonical = {
|
||||
"algorithm": payload["algorithm"],
|
||||
"purpose": payload["purpose"],
|
||||
"keys": payload["keys"],
|
||||
}
|
||||
payload["keyring_sha256"] = hashlib.sha256(
|
||||
json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
).hexdigest()
|
||||
return payload
|
||||
|
||||
|
||||
def _attestation(private: Ed25519PrivateKey) -> BuilderAttestation:
|
||||
unsigned = BuilderAttestation(
|
||||
builder_attestation_id="builder-attestation-001",
|
||||
builder_key_id="builder-key-001",
|
||||
source_commit=COMMIT,
|
||||
tree_oid=TREE,
|
||||
artifact_sha256=SHA,
|
||||
manifest_sha256=SHA,
|
||||
package_closure_sha256=SHA,
|
||||
test_evidence_sha256=SHA,
|
||||
canary_evidence_sha256=SHA,
|
||||
issued_at=_instant(),
|
||||
signature="",
|
||||
builder_key_generation=1,
|
||||
signature_algorithm="Ed25519",
|
||||
signature_purpose="builder_attestation",
|
||||
gitea_evidence_sha256=_canonical_digest(GITEA),
|
||||
rollback_evidence_sha256=_canonical_digest({
|
||||
"ready": True,
|
||||
"rollback_sha256": SHA,
|
||||
"source_commit": COMMIT,
|
||||
}),
|
||||
policy_evidence_sha256=SHA,
|
||||
secret_scan_evidence_sha256=SHA,
|
||||
)
|
||||
return BuilderAttestation(
|
||||
**{
|
||||
**unsigned.canonical_payload,
|
||||
"signature": base64.b64encode(
|
||||
private.sign(unsigned.canonical_bytes())
|
||||
).decode("ascii"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_builder_verifier_rejects_owner_key_record_cross_purpose(tmp_path):
|
||||
private = Ed25519PrivateKey.generate()
|
||||
payload = _keyring_payload(private, purpose="owner_approval")
|
||||
path = tmp_path / "builder-keyring.json"
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="purpose"):
|
||||
BuilderAttestationKeyRing.from_file(path)
|
||||
|
||||
|
||||
def test_builder_verifier_checks_domain_key_window_digest_and_signature(tmp_path):
|
||||
private = Ed25519PrivateKey.generate()
|
||||
path = tmp_path / "builder-keyring.json"
|
||||
path.write_text(
|
||||
json.dumps(_keyring_payload(private, purpose="builder_attestation")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
verifier = BuilderAttestationVerifier(
|
||||
BuilderAttestationKeyRing.from_file(path)
|
||||
)
|
||||
candidate = SimpleNamespace(
|
||||
source_commit=COMMIT,
|
||||
tree_oid=TREE,
|
||||
artifact_sha256=SHA,
|
||||
manifest_sha256=SHA,
|
||||
gitea_consistency=GITEA,
|
||||
rollback_ready=True,
|
||||
)
|
||||
plan = SimpleNamespace(
|
||||
deployment=SimpleNamespace(
|
||||
package_closure_sha256=SHA,
|
||||
rollback_sha256=SHA,
|
||||
),
|
||||
policy_sha256=SHA,
|
||||
)
|
||||
assert verifier.verify(_attestation(private), candidate, plan).builder_key_id == "builder-key-001"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", ["REVOKED", "EXPIRED", "FUTURE"])
|
||||
def test_builder_verifier_rejects_unavailable_key_lifecycle_states(tmp_path, state):
|
||||
private = Ed25519PrivateKey.generate()
|
||||
payload = _keyring_payload(private, purpose="builder_attestation")
|
||||
record = payload["keys"][0]
|
||||
if state == "REVOKED":
|
||||
record["status"] = "REVOKED"
|
||||
elif state == "EXPIRED":
|
||||
record["not_before"] = _instant(timedelta(days=-2))
|
||||
record["expires_at"] = _instant(timedelta(days=-1))
|
||||
else:
|
||||
record["not_before"] = _instant(timedelta(days=1))
|
||||
record["expires_at"] = _instant(timedelta(days=2))
|
||||
path = tmp_path / "builder-keyring.json"
|
||||
path.write_text(json.dumps(_refresh_keyring_digest(payload)), encoding="utf-8")
|
||||
verifier = BuilderAttestationVerifier(BuilderAttestationKeyRing.from_file(path))
|
||||
candidate = SimpleNamespace(
|
||||
source_commit=COMMIT, tree_oid=TREE,
|
||||
artifact_sha256=SHA, manifest_sha256=SHA,
|
||||
gitea_consistency=GITEA, rollback_ready=True,
|
||||
)
|
||||
plan = SimpleNamespace(deployment=SimpleNamespace(package_closure_sha256=SHA, rollback_sha256=SHA), policy_sha256=SHA)
|
||||
with pytest.raises(CapabilityAuthorizationError, match="validity|revoked|unavailable"):
|
||||
verifier.verify(_attestation(private), candidate, plan)
|
||||
|
||||
|
||||
def test_builder_verifier_rejects_unknown_key_invalid_signature_and_keyring_digest(tmp_path):
|
||||
private = Ed25519PrivateKey.generate()
|
||||
payload = _keyring_payload(private, purpose="builder_attestation")
|
||||
path = tmp_path / "builder-keyring.json"
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
verifier = BuilderAttestationVerifier(BuilderAttestationKeyRing.from_file(path))
|
||||
candidate = SimpleNamespace(
|
||||
source_commit=COMMIT, tree_oid=TREE,
|
||||
artifact_sha256=SHA, manifest_sha256=SHA,
|
||||
gitea_consistency=GITEA, rollback_ready=True,
|
||||
)
|
||||
plan = SimpleNamespace(deployment=SimpleNamespace(package_closure_sha256=SHA, rollback_sha256=SHA), policy_sha256=SHA)
|
||||
with pytest.raises(CapabilityAuthorizationError, match="not trusted"):
|
||||
verifier.verify(
|
||||
dataclasses.replace(_attestation(private), builder_key_id="unknown-builder-key"),
|
||||
candidate,
|
||||
plan,
|
||||
)
|
||||
with pytest.raises(CapabilityAuthorizationError, match="signature"):
|
||||
verifier.verify(
|
||||
dataclasses.replace(_attestation(private), signature=base64.b64encode(b"x" * 64).decode()),
|
||||
candidate,
|
||||
plan,
|
||||
)
|
||||
|
||||
payload["keyring_sha256"] = "0" * 64
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="keyring digest"):
|
||||
BuilderAttestationKeyRing.from_file(path)
|
||||
|
||||
|
||||
def test_builder_attestation_rejects_tampered_digest_and_signing_metadata():
|
||||
private = Ed25519PrivateKey.generate()
|
||||
raw = _attestation(private).to_dict()
|
||||
raw["fingerprint"] = "0" * 64
|
||||
with pytest.raises(CapabilityAuthorizationError, match="digest is invalid"):
|
||||
BuilderAttestation.from_dict(raw, require_fingerprint=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("builder_key_generation", 2),
|
||||
("signature_algorithm", "RSA"),
|
||||
("signature_purpose", "owner_approval"),
|
||||
],
|
||||
)
|
||||
def test_builder_keyring_rejects_attestation_metadata_mismatch(tmp_path, field, value):
|
||||
private = Ed25519PrivateKey.generate()
|
||||
path = tmp_path / "builder-keyring.json"
|
||||
path.write_text(
|
||||
json.dumps(_keyring_payload(private, purpose="builder_attestation")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
verifier = BuilderAttestationVerifier(
|
||||
BuilderAttestationKeyRing.from_file(path)
|
||||
)
|
||||
candidate = SimpleNamespace(
|
||||
source_commit=COMMIT, tree_oid=TREE,
|
||||
artifact_sha256=SHA, manifest_sha256=SHA,
|
||||
gitea_consistency=GITEA, rollback_ready=True,
|
||||
)
|
||||
plan = SimpleNamespace(deployment=SimpleNamespace(package_closure_sha256=SHA, rollback_sha256=SHA), policy_sha256=SHA)
|
||||
with pytest.raises(CapabilityAuthorizationError, match="metadata"):
|
||||
verifier.verify(
|
||||
dataclasses.replace(_attestation(private), **{field: value}),
|
||||
candidate,
|
||||
plan,
|
||||
)
|
||||
raw = _attestation(private).to_dict()
|
||||
raw["builder_key_generation"] = 2
|
||||
with pytest.raises(CapabilityAuthorizationError, match="digest is invalid"):
|
||||
BuilderAttestation.from_dict(raw, require_fingerprint=True)
|
||||
|
||||
|
||||
def test_builder_unavailable_intent_is_bounded_retryable_and_recovers_without_secret(tmp_path, monkeypatch):
|
||||
class UnavailableBuilder:
|
||||
def build(self, intent):
|
||||
raise TrustedCandidateBuilderUnavailable("socket token=should-not-leak")
|
||||
|
||||
monkeypatch.setattr(hardening, "_fsync_directory", lambda _path: None)
|
||||
service = SilentReleaseActionService(
|
||||
tmp_path,
|
||||
candidate_builder=UnavailableBuilder(),
|
||||
max_builder_attempts=2,
|
||||
)
|
||||
payload = {"event_id": "event-builder-001", "source_commit": COMMIT}
|
||||
|
||||
first = service.create_release_intent(payload)
|
||||
second = service.create_release_intent(payload)
|
||||
|
||||
assert first["status"] == "BUILDER_RETRYABLE_FAILURE"
|
||||
assert first["retryable"] is True
|
||||
assert first["attempt_count"] == 1
|
||||
assert "should-not-leak" not in json.dumps(first)
|
||||
assert second["status"] == "BUILDER_TERMINAL_FAILURE"
|
||||
assert second["retryable"] is False
|
||||
assert second["attempt_count"] == 2
|
||||
|
||||
|
||||
def test_stale_building_intent_recovers_after_restart_without_double_running_active_attempt(tmp_path, monkeypatch):
|
||||
class CountingUnavailableBuilder:
|
||||
calls = 0
|
||||
|
||||
def build(self, intent):
|
||||
self.calls += 1
|
||||
raise TrustedCandidateBuilderUnavailable("nonsecret")
|
||||
|
||||
monkeypatch.setattr(hardening, "_fsync_directory", lambda _path: None)
|
||||
builder = CountingUnavailableBuilder()
|
||||
service = SilentReleaseActionService(
|
||||
tmp_path,
|
||||
candidate_builder=builder,
|
||||
max_builder_attempts=3,
|
||||
builder_attempt_timeout_seconds=1,
|
||||
)
|
||||
payload = {"event_id": "event-builder-crash-001", "source_commit": COMMIT}
|
||||
intent_id = "intent-" + hashlib.sha256(
|
||||
f"{payload['event_id']}\0{COMMIT}".encode()
|
||||
).hexdigest()[:24]
|
||||
intent_path = tmp_path / "intents" / f"{intent_id}.json"
|
||||
intent_path.parent.mkdir(parents=True)
|
||||
active_record = {
|
||||
"attempt_count": 1,
|
||||
"attempt_started_at": _instant(),
|
||||
"created_at": _instant(timedelta(minutes=-1)),
|
||||
"event_id": payload["event_id"],
|
||||
"intent_id": intent_id,
|
||||
"source_commit": COMMIT,
|
||||
"status": "BUILDING",
|
||||
}
|
||||
intent_path.write_text(json.dumps(active_record), encoding="utf-8")
|
||||
|
||||
active = service.create_release_intent(payload)
|
||||
assert active["status"] == "BUILDING"
|
||||
assert active["idempotent"] is True
|
||||
assert builder.calls == 0
|
||||
|
||||
active_record["attempt_started_at"] = _instant(timedelta(minutes=-5))
|
||||
intent_path.write_text(json.dumps(active_record), encoding="utf-8")
|
||||
recovered = service.create_release_intent(payload)
|
||||
assert recovered["status"] == "BUILDER_RETRYABLE_FAILURE"
|
||||
assert recovered["attempt_count"] == 2
|
||||
assert recovered["failure_code"] == "TRUSTED_BUILDER_UNAVAILABLE"
|
||||
assert recovered["last_recovery_code"] == "TRUSTED_BUILDER_CRASH_RECOVERED"
|
||||
assert recovered["recovery_count"] == 1
|
||||
assert builder.calls == 1
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="model API runtime requires POSIX fcntl")
|
||||
@pytest.mark.skipif(
|
||||
not _af_inet_available(),
|
||||
reason="isolated executor permits AF_UNIX only; live HTTP probe runs at activation",
|
||||
)
|
||||
def test_model_api_keeps_process_liveness_separate_from_builder_readiness(monkeypatch):
|
||||
api = importlib.import_module("aios_model_api_staging")
|
||||
|
||||
class FakeService:
|
||||
ready = False
|
||||
|
||||
def get_builder_readiness(self):
|
||||
return {"status": "READY" if self.ready else "UNAVAILABLE"}
|
||||
|
||||
def create_release_candidate(self, payload):
|
||||
if not payload:
|
||||
raise ValueError("event_id is required")
|
||||
return {"status": "READY_FOR_APPROVAL"}
|
||||
|
||||
service = FakeService()
|
||||
monkeypatch.setattr(api, "silent_release_service", lambda: service)
|
||||
monkeypatch.setattr(
|
||||
api,
|
||||
"authenticate",
|
||||
lambda token: (
|
||||
{"account": "test", "role": "owner", "scopes": ["release:candidate"]}
|
||||
if token == "test-token"
|
||||
else None
|
||||
),
|
||||
)
|
||||
server = api.ThreadingHTTPServer(("127.0.0.1", 0), api.H)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
base = f"http://127.0.0.1:{server.server_port}"
|
||||
|
||||
def request(path, body=None):
|
||||
data = None if body is None else json.dumps(body).encode("utf-8")
|
||||
value = urllib.request.Request(
|
||||
base + path,
|
||||
data=data,
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
method="POST" if body is not None else "GET",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(value, timeout=5) as response:
|
||||
return response.status, json.load(response)
|
||||
except urllib.error.HTTPError as error:
|
||||
return error.code, json.load(error)
|
||||
|
||||
try:
|
||||
health_code, health = request("/health")
|
||||
unavailable_code, unavailable = request("/createReleaseCandidate", {})
|
||||
service.ready = True
|
||||
invalid_code, invalid = request("/createReleaseCandidate", {})
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
assert health_code == 200
|
||||
assert health["process_liveness"] == "PASS"
|
||||
assert health["builder_readiness"]["status"] == "UNAVAILABLE"
|
||||
assert unavailable_code == 503
|
||||
assert unavailable["error"] == "TRUSTED_BUILDER_UNAVAILABLE"
|
||||
assert invalid_code == 400
|
||||
assert invalid["error"] == "event_id is required"
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="model API runtime requires POSIX fcntl")
|
||||
def test_host_adapter_accepts_a_real_builder_bundle_response():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"trusted_builder_public_seam",
|
||||
APP.parents[1] / "tests" / "test_trusted_candidate_builder.py",
|
||||
)
|
||||
assert spec and spec.loader
|
||||
backend_seam = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(backend_seam)
|
||||
server, client, _ = backend_seam.make_seam()
|
||||
candidate_id = "r42-release-candidate-v2r2c2-20260819"
|
||||
source_commit = "71e736b05fab13efc06af7a4fe2dc022c1cf9a85"
|
||||
response = server.handle_request(
|
||||
client.create_request(
|
||||
"V2R2C2", candidate_id, source_commit, nonce="z" * 32
|
||||
),
|
||||
connection=object(),
|
||||
)
|
||||
|
||||
class ResponseClient:
|
||||
def build(self, version, supplied_candidate_id, supplied_source, *, nonce):
|
||||
assert version == "V2R2C2"
|
||||
assert supplied_candidate_id == candidate_id
|
||||
assert supplied_source == source_commit
|
||||
assert len(nonce) >= 32
|
||||
return response
|
||||
|
||||
api = importlib.import_module("aios_model_api_staging")
|
||||
bundle = api._HostTrustedCandidateBuilderAdapter(ResponseClient()).build(
|
||||
{
|
||||
"candidate_id": candidate_id,
|
||||
"source_commit": source_commit,
|
||||
"version": "V2R2C2",
|
||||
}
|
||||
)
|
||||
|
||||
assert bundle.validate().candidate.source_commit == response["base_oid"]
|
||||
assert bundle.attestation.builder_key_id == response["attestation"]["builder_key_id"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="model API runtime requires POSIX fcntl")
|
||||
def test_host_adapter_forwards_v2r2c3_to_the_policy_and_fails_closed_when_unavailable():
|
||||
from aios_trusted_candidate_builder import BuilderError
|
||||
api = importlib.import_module("aios_model_api_staging")
|
||||
calls = []
|
||||
|
||||
class DerivedOnlyClient:
|
||||
def build(self, version, candidate_id, source_commit, *, nonce):
|
||||
calls.append((version, candidate_id, source_commit, nonce))
|
||||
raise BuilderError("GOVERNED_V2R2C3_DERIVATION_REQUIRED")
|
||||
|
||||
with pytest.raises(TrustedCandidateBuilderUnavailable):
|
||||
api._HostTrustedCandidateBuilderAdapter(DerivedOnlyClient()).build(
|
||||
{
|
||||
"candidate_id": "r42-release-candidate-v2r2c3-20260820",
|
||||
"source_commit": COMMIT,
|
||||
"version": "V2R2C3",
|
||||
}
|
||||
)
|
||||
assert calls[0][:3] == (
|
||||
"V2R2C3",
|
||||
"r42-release-candidate-v2r2c3-20260820",
|
||||
COMMIT,
|
||||
)
|
||||
@@ -1,116 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0,str(ROOT/"active-runtime/app"))
|
||||
|
||||
import aios_release_preparation_supervisor_coordinator as coordinator
|
||||
|
||||
|
||||
def task():
|
||||
return {
|
||||
"task_id":coordinator.TASK_ID,
|
||||
"status":"QUEUED",
|
||||
"required_capabilities":coordinator.REQUIRED_CAPABILITIES,
|
||||
"metadata":{
|
||||
"execution":{
|
||||
"action":"agent_runtime",
|
||||
"adapter":"openclaw",
|
||||
"capability_scope":coordinator.REQUIRED_CAPABILITIES,
|
||||
"execution_digest":"e"*64,
|
||||
"change_set":[],
|
||||
"verification":[],
|
||||
"governance":{
|
||||
"writer_lease":False,
|
||||
"main_write":False,
|
||||
"production_write":False,
|
||||
"tag_write":False,
|
||||
"baseline_write":False,
|
||||
"force_push":False,
|
||||
"protected_ref_bypass":False,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def expected_digest(monkeypatch):
|
||||
monkeypatch.setattr(coordinator,"EXPECTED_EXECUTION_DIGEST","e"*64)
|
||||
|
||||
|
||||
def test_coordinator_accepts_only_exact_nonmutating_projection():
|
||||
execution=coordinator.validate_task(task())
|
||||
assert execution["adapter"]=="openclaw"
|
||||
assert execution["governance"]["writer_lease"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutate,error",
|
||||
[
|
||||
(lambda value:value.__setitem__("required_capabilities",["agent.runtime.openclaw"]),
|
||||
"CAPABILITIES_INVALID"),
|
||||
(lambda value:value["metadata"]["execution"].__setitem__("execution_digest","f"*64),
|
||||
"ENVELOPE_INVALID"),
|
||||
(lambda value:value["metadata"]["execution"]["governance"].__setitem__("production_write",True),
|
||||
"ENVELOPE_INVALID"),
|
||||
],
|
||||
)
|
||||
def test_coordinator_rejects_capability_or_governance_drift(mutate,error):
|
||||
value=task();mutate(value)
|
||||
with pytest.raises(coordinator.CoordinatorError,match=error):
|
||||
coordinator.validate_task(value)
|
||||
|
||||
|
||||
def test_heartbeat_is_control_plane_host_only_and_never_forwards_credentials():
|
||||
progress=coordinator.heartbeat_progress(7)
|
||||
assert progress["control_plane_access_granted"] is True
|
||||
assert progress["external_planner_role"]=="UNPRIVILEGED_PLANNER"
|
||||
assert progress["credential_forwarded_to_planner"] is False
|
||||
assert progress["main_write"] is False
|
||||
assert progress["production_write"] is False
|
||||
encoded=json.dumps(progress,sort_keys=True).lower()
|
||||
assert "bearer " not in encoded
|
||||
assert "lease_token" not in encoded
|
||||
|
||||
|
||||
def test_coordinate_claim_heartbeats_without_submitting_result(monkeypatch,tmp_path):
|
||||
calls=[]
|
||||
def fake(method,route,token,body=None):
|
||||
calls.append((method,route,token,body))
|
||||
return 200,{"status":"RUNNING","lease_expires_at":"2099-01-01T00:00:00Z"}
|
||||
monkeypatch.setattr(coordinator,"api_call",fake)
|
||||
monkeypatch.setattr(coordinator,"STATE_ROOT",tmp_path)
|
||||
monkeypatch.setattr(coordinator,"HEARTBEAT_SECONDS",0)
|
||||
count=coordinator.coordinate_claim(
|
||||
{"lease_token":"l"*32},"agent-token-value-for-test",max_heartbeats=2
|
||||
)
|
||||
assert count==2
|
||||
assert all(call[1].endswith("/heartbeat") for call in calls)
|
||||
assert not any(call[1].endswith("/result") for call in calls)
|
||||
assert all("agent-token" not in json.dumps(call[3]) for call in calls)
|
||||
liveness=json.loads((tmp_path/"liveness.json").read_text())
|
||||
assert liveness["event_type"]=="SUPERVISOR_COORDINATOR_HEARTBEAT"
|
||||
assert liveness["credential_material_exposed"] is False
|
||||
|
||||
|
||||
def test_units_use_existing_encrypted_agent_credential_and_no_planner_credential():
|
||||
coordinator_unit=(
|
||||
ROOT/"deploy/systemd/aios-release-preparation-supervisor-coordinator.service"
|
||||
).read_text()
|
||||
projection_unit=(
|
||||
ROOT/"deploy/systemd/aios-agent-task-supervisor-coordinator-projection.service"
|
||||
).read_text()
|
||||
assert "aios-autonomous-agent-register.service" in coordinator_unit
|
||||
assert "LoadCredentialEncrypted=agent_token:" in coordinator_unit
|
||||
assert "control_token" not in coordinator_unit
|
||||
assert "SupplementaryGroups=aios-agent-runtime" in coordinator_unit
|
||||
assert "LoadCredential=agent_task_control_token:" in projection_unit
|
||||
assert "User=aios-agent-task" in projection_unit
|
||||
assert "ReadWritePaths=/var/lib/aios-agent-task" in projection_unit
|
||||
@@ -0,0 +1,592 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
|
||||
import aios_trusted_candidate_builder as builder_runtime # noqa: E402
|
||||
from aios_trusted_candidate_builder import ( # noqa: E402
|
||||
BuilderError,
|
||||
BuilderKeyring,
|
||||
DeterministicEvidenceCollector,
|
||||
PeerIdentity,
|
||||
ServerOwnedEvidenceSource,
|
||||
TrustedCandidateBuilder,
|
||||
TrustedCandidateBuilderConfig,
|
||||
UnixTrustedCandidateBuilderClient,
|
||||
load_builder_policy,
|
||||
)
|
||||
from silent_release_controller import ReleaseCandidate, ReleaseState # noqa: E402
|
||||
from silent_release_hardening import ( # noqa: E402
|
||||
BuilderAttestation,
|
||||
BuilderAttestationKeyRecord,
|
||||
BuilderAttestationKeyRing,
|
||||
BuilderAttestationVerifier,
|
||||
DeploymentPlan,
|
||||
FinalizationPlan,
|
||||
ReleasePlan,
|
||||
TagPlan,
|
||||
TrustedCandidateBundle,
|
||||
)
|
||||
|
||||
|
||||
POLICY_PATH = ROOT / "config" / "gitops" / "trusted-candidate-builder-policy.json"
|
||||
NOW = 1_725_000_000
|
||||
|
||||
|
||||
class RecordingDomainSigner:
|
||||
key_id = "test-builder-key"
|
||||
key_generation = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, bytes]] = []
|
||||
|
||||
def sign_for_purpose(self, purpose: str, payload: bytes) -> bytes:
|
||||
self.calls.append((purpose, payload))
|
||||
return hashlib.sha256(b"test-signature\0" + payload).digest()
|
||||
|
||||
|
||||
def make_seam(*, peer: PeerIdentity | None = None, cgroup: str = "/system.slice/aios-gitops-model-api.service", process=("/usr/bin/python3.12", ("/usr/bin/python3", "/opt/aios/gitops-model-api/test/runtime/app/aios_model_api_staging.py")), server_clock=lambda: NOW, client_clock=lambda: NOW, collector=None, nonce_ledger_path=None, signer_override=None) -> tuple[TrustedCandidateBuilder, UnixTrustedCandidateBuilderClient, RecordingDomainSigner]:
|
||||
policy = load_builder_policy(POLICY_PATH)
|
||||
config = TrustedCandidateBuilderConfig.from_policy(policy)
|
||||
keyring = BuilderKeyring(builder_hmac=b"h" * 32)
|
||||
signer = signer_override or RecordingDomainSigner()
|
||||
server = TrustedCandidateBuilder(
|
||||
config,
|
||||
keyring,
|
||||
collector=collector or DeterministicEvidenceCollector(config, evidence_source=passing_bundle),
|
||||
signer=signer,
|
||||
peer_identity_resolver=lambda _connection: peer or PeerIdentity(pid=77, uid=1000, gid=1000),
|
||||
cgroup_resolver=lambda _pid: cgroup,
|
||||
process_identity_resolver=lambda _pid: process,
|
||||
clock=server_clock,
|
||||
nonce_ledger_path=nonce_ledger_path,
|
||||
)
|
||||
return server, UnixTrustedCandidateBuilderClient(config, keyring, clock=client_clock), signer
|
||||
|
||||
|
||||
def passing_bundle(recipe_name, recipe):
|
||||
base_oid = recipe["base_oid"]
|
||||
digest = lambda label: hashlib.sha256(f"{label}\0{base_oid}".encode()).hexdigest()
|
||||
candidate = ReleaseCandidate(
|
||||
candidate_id=recipe["candidate_id"], source_commit=base_oid,
|
||||
tree_oid=digest("tree")[:40], artifact_sha256=digest("artifact"),
|
||||
manifest_sha256=digest("manifest"), tests={"passed": True, "receipt": digest("tests")},
|
||||
canary={"passed": True, "receipt": digest("canary")}, rollback_ready=True,
|
||||
git_consistency={"consistent": True}, gitea_consistency={"consistent": True},
|
||||
state=ReleaseState.READY_FOR_APPROVAL,
|
||||
)
|
||||
policy_sha = digest("policy")
|
||||
plan = ReleasePlan(
|
||||
deployment=DeploymentPlan(
|
||||
service="aios-silent-release-controller", release_path="/srv/release/r1",
|
||||
release_root="/srv/release", cutover_link="/srv/release/current",
|
||||
dropin_path="/srv/release/r1/deploy/systemd/controller.service",
|
||||
dropin_sha256=digest("dropin"), systemd_unit_sha256=digest("unit"),
|
||||
rollback_path="/srv/release/rollback-r1", rollback_sha256=digest("rollback"),
|
||||
source_commit=base_oid, tree_oid=candidate.tree_oid,
|
||||
artifact_sha256=candidate.artifact_sha256, manifest_sha256=candidate.manifest_sha256,
|
||||
package_closure_sha256=digest("closure"), authoritative_git_dir="/data/aios-gitops/repo.git",
|
||||
repository_identity="aios/aios", baseline_before_sha256=digest("baseline"),
|
||||
registry_before_sha256=digest("registry"), production_tag_refset_before_sha256=digest("tagset"),
|
||||
health_contract_version="trusted-builder/v1", timeout_policy={"seconds": 10},
|
||||
retry_policy={"attempts": 1}, policy_sha256=policy_sha, health_contract={"checked": True},
|
||||
),
|
||||
tag=TagPlan(
|
||||
version="trusted-v2r2c2-001", tag_type="annotated", expected_tag_oid=digest("tag")[:40],
|
||||
expected_target_oid=base_oid, evidence_closure_oid=digest("closure-oid")[:40],
|
||||
approval_plan_id="owner-plan-trusted-v2r2c2", repository_identity="aios/aios",
|
||||
gitea_owner="aios", gitea_repository="aios", current_remote_tag_oid=None,
|
||||
remote_absent_evidence_sha256=digest("tag-absence"), verification_contract="trusted-builder/v1",
|
||||
),
|
||||
finalization=FinalizationPlan(
|
||||
version="trusted-v2r2c2-001", expected_tag_oid=digest("tag")[:40], expected_target_oid=base_oid,
|
||||
expected_previous_baseline_sha256=digest("baseline"), registry_expected_record={"base": base_oid},
|
||||
baseline_expected_record={"base": base_oid}, final_consistency_contract="trusted-builder/v1",
|
||||
), policy_sha256=policy_sha,
|
||||
)
|
||||
checks = {name: True for name in [
|
||||
"authoritative_git", "artifact", "canary", "gitea", "manifest",
|
||||
"package_closure", "rollback", "secret_scan", "tests",
|
||||
]}
|
||||
TrustedCandidateBundle(candidate, plan, checks).validate()
|
||||
canonical_digest = lambda value: hashlib.sha256(
|
||||
json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
evidence_digests = {
|
||||
"artifact": candidate.artifact_sha256,
|
||||
"manifest": candidate.manifest_sha256,
|
||||
"package_closure": plan.deployment.package_closure_sha256,
|
||||
"tests": canonical_digest(candidate.tests),
|
||||
"canary": canonical_digest(candidate.canary),
|
||||
"gitea": canonical_digest(candidate.gitea_consistency),
|
||||
"rollback": canonical_digest({
|
||||
"ready": True,
|
||||
"rollback_sha256": plan.deployment.rollback_sha256,
|
||||
"source_commit": candidate.source_commit,
|
||||
}),
|
||||
"policy": plan.policy_sha256,
|
||||
"secret_scan": digest("secret-scan"),
|
||||
}
|
||||
return {
|
||||
"candidate": candidate.to_dict(),
|
||||
"release_plan": plan.to_dict(),
|
||||
"checks": checks,
|
||||
"evidence_digests": evidence_digests,
|
||||
}
|
||||
|
||||
|
||||
def rehmac(request: dict) -> None:
|
||||
unsigned = {key: value for key, value in request.items() if key != "hmac"}
|
||||
request["hmac"] = hmac.new(
|
||||
b"h" * 32,
|
||||
json.dumps(unsigned, sort_keys=True, separators=(",", ":")).encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def test_public_build_seam_accepts_only_the_fixed_v2r2c2_recipe_and_returns_server_owned_evidence() -> None:
|
||||
server, client, signer = make_seam()
|
||||
|
||||
request = client.create_request("V2R2C2", "r42-release-candidate-v2r2c2-20260819", "71e736b05fab13efc06af7a4fe2dc022c1cf9a85", nonce="a" * 32)
|
||||
result = server.handle_request(request, connection=object())
|
||||
|
||||
assert result["schema"] == "aios.trusted-candidate-builder.response.v1"
|
||||
assert result["status"] == "BUILT"
|
||||
assert result["recipe"] == "V2R2C2"
|
||||
assert result["repository"] == "aios/aios"
|
||||
assert result["task_id"] == "r42-release-candidate-v2r2c2-live-20260819"
|
||||
assert result["silent_release_bundle"]["candidate"]["candidate_id"] == "r42-release-candidate-v2r2c2-20260819"
|
||||
assert result["base_oid"] == "71e736b05fab13efc06af7a4fe2dc022c1cf9a85"
|
||||
assert set(result["checks"]) == set(server.config.required_checks)
|
||||
assert all(result["checks"].values())
|
||||
assert result["attestation"]["trust_domain"] == "aios.trusted-builder.attestation.v1"
|
||||
assert signer.calls and signer.calls[0][0] == "builder_attestation"
|
||||
assert result["attestation"]["fingerprint"] == hashlib.sha256(signer.calls[0][1]).hexdigest()
|
||||
assert BuilderAttestation.from_dict(result["attestation"]).canonical_bytes() == signer.calls[0][1]
|
||||
silent_bundle = result["silent_release_bundle"]
|
||||
round_trip = TrustedCandidateBundle(
|
||||
ReleaseCandidate.from_dict(silent_bundle["candidate"]),
|
||||
ReleasePlan.from_dict(silent_bundle["release_plan"]),
|
||||
silent_bundle["checks"],
|
||||
)
|
||||
assert round_trip.validate().candidate.candidate_id == result["evidence"]["candidate_id"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutate, error",
|
||||
[
|
||||
(lambda request: request.update({"base_oid": "f" * 40}), "REQUEST_HMAC_INVALID"),
|
||||
(lambda request: request.update({"artifact_sha256": "0" * 64}), "REQUEST_SCHEMA_INVALID"),
|
||||
],
|
||||
)
|
||||
def test_public_build_seam_rejects_tampering_and_caller_owned_evidence(mutate, error) -> None:
|
||||
server, client, _ = make_seam()
|
||||
request = client.create_request("V2R2C2", "r42-release-candidate-v2r2c2-20260819", "71e736b05fab13efc06af7a4fe2dc022c1cf9a85", nonce="b" * 32)
|
||||
mutate(request)
|
||||
|
||||
with pytest.raises(BuilderError, match=error):
|
||||
server.handle_request(request, connection=object())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field,value", [
|
||||
("repository", "other/repository"),
|
||||
("task_id", "other-task"),
|
||||
("base_oid", "f" * 40),
|
||||
("purpose", "owner_approval"),
|
||||
])
|
||||
def test_public_build_seam_rejects_reauthenticated_scope_rebinding(field, value) -> None:
|
||||
server, client, _ = make_seam()
|
||||
request = client.create_request(
|
||||
"V2R2C2",
|
||||
"r42-release-candidate-v2r2c2-20260819",
|
||||
"71e736b05fab13efc06af7a4fe2dc022c1cf9a85",
|
||||
nonce="s" * 32,
|
||||
)
|
||||
request[field] = value
|
||||
rehmac(request)
|
||||
with pytest.raises(BuilderError, match="REQUEST_SCOPE_INVALID"):
|
||||
server.handle_request(request, connection=object())
|
||||
|
||||
|
||||
def test_public_build_seam_rejects_wrong_hmac() -> None:
|
||||
server, client, _ = make_seam()
|
||||
request = client.create_request(
|
||||
"V2R2C2",
|
||||
"r42-release-candidate-v2r2c2-20260819",
|
||||
"71e736b05fab13efc06af7a4fe2dc022c1cf9a85",
|
||||
nonce="w" * 32,
|
||||
)
|
||||
request["hmac"] = "0" * 64
|
||||
with pytest.raises(BuilderError, match="REQUEST_HMAC_INVALID"):
|
||||
server.handle_request(request, connection=object())
|
||||
|
||||
|
||||
def test_public_build_seam_returns_the_original_result_for_exact_nonce_replay_and_rejects_conflicts() -> None:
|
||||
server, client, _ = make_seam()
|
||||
request = client.create_request("V2R2C2", "r42-release-candidate-v2r2c2-20260819", "71e736b05fab13efc06af7a4fe2dc022c1cf9a85", nonce="c" * 32)
|
||||
first = server.handle_request(request, connection=object())
|
||||
assert server.handle_request(request, connection=object()) == first
|
||||
|
||||
conflict = client.create_request("V2R2C2", "r42-release-candidate-v2r2c2-20260819", "71e736b05fab13efc06af7a4fe2dc022c1cf9a85", nonce="c" * 32)
|
||||
conflict["issued_at"] += 1
|
||||
unsigned = {key: value for key, value in conflict.items() if key != "hmac"}
|
||||
conflict["hmac"] = hmac.new(b"h" * 32, json.dumps(unsigned, sort_keys=True, separators=(",", ":")).encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
with pytest.raises(BuilderError, match="REPLAY_CONFLICT"):
|
||||
server.handle_request(conflict, connection=object())
|
||||
|
||||
|
||||
def test_exact_replay_survives_restart_and_the_original_freshness_window(tmp_path) -> None:
|
||||
ledger = tmp_path / "nonce-ledger.json"
|
||||
server, client, _ = make_seam(nonce_ledger_path=ledger)
|
||||
request = client.create_request(
|
||||
"V2R2C2",
|
||||
"r42-release-candidate-v2r2c2-20260819",
|
||||
"71e736b05fab13efc06af7a4fe2dc022c1cf9a85",
|
||||
nonce="r" * 32,
|
||||
)
|
||||
first = server.handle_request(request, connection=object())
|
||||
|
||||
restarted, _, _ = make_seam(
|
||||
server_clock=lambda: NOW + 3600,
|
||||
nonce_ledger_path=ledger,
|
||||
)
|
||||
assert restarted.handle_request(request, connection=object()) == first
|
||||
|
||||
|
||||
def test_readiness_probe_checks_collector_and_signer_without_signing() -> None:
|
||||
server, client, signer = make_seam()
|
||||
request = client.create_request(
|
||||
"V2R2C2",
|
||||
"r42-release-candidate-v2r2c2-20260819",
|
||||
"71e736b05fab13efc06af7a4fe2dc022c1cf9a85",
|
||||
nonce="q" * 32,
|
||||
operation=server.config.readiness_operation,
|
||||
)
|
||||
result = server.handle_request(request, connection=object())
|
||||
|
||||
assert result["status"] == "READY"
|
||||
assert result["collector_ready"] is True
|
||||
assert result["signer_ready"] is True
|
||||
assert result["builder_key_id"] == signer.key_id
|
||||
assert result["policy_sha256"] == server.config.policy_sha256
|
||||
assert "signature" not in result
|
||||
assert "silent_release_bundle" not in result
|
||||
assert signer.calls == []
|
||||
|
||||
|
||||
def test_readiness_fails_closed_when_signer_is_unavailable() -> None:
|
||||
class UnavailableSigner:
|
||||
@property
|
||||
def key_id(self):
|
||||
raise BuilderError("SIGNER_UNAVAILABLE")
|
||||
|
||||
@property
|
||||
def key_generation(self):
|
||||
return 1
|
||||
|
||||
def sign_for_purpose(self, purpose, payload):
|
||||
raise AssertionError("readiness must not attempt signing")
|
||||
|
||||
server, client, _ = make_seam(signer_override=UnavailableSigner())
|
||||
request = client.create_request(
|
||||
"V2R2C2",
|
||||
"r42-release-candidate-v2r2c2-20260819",
|
||||
"71e736b05fab13efc06af7a4fe2dc022c1cf9a85",
|
||||
nonce="u" * 32,
|
||||
operation=server.config.readiness_operation,
|
||||
)
|
||||
with pytest.raises(BuilderError, match="SIGNER_UNAVAILABLE"):
|
||||
server.handle_request(request, connection=object())
|
||||
|
||||
|
||||
def test_unix_client_applies_bounded_timeout_and_fails_closed(monkeypatch) -> None:
|
||||
observed = {}
|
||||
|
||||
class TimeoutSocket:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def settimeout(self, seconds):
|
||||
observed["timeout"] = seconds
|
||||
|
||||
def connect(self, _path):
|
||||
raise TimeoutError("bounded test timeout")
|
||||
|
||||
monkeypatch.setattr(builder_runtime.socket, "socket", lambda *_args: TimeoutSocket())
|
||||
monkeypatch.setattr(builder_runtime.socket, "AF_UNIX", 1, raising=False)
|
||||
monkeypatch.setattr(builder_runtime.socket, "SOCK_STREAM", 1, raising=False)
|
||||
config = TrustedCandidateBuilderConfig.from_policy(load_builder_policy(POLICY_PATH))
|
||||
client = UnixTrustedCandidateBuilderClient(
|
||||
config,
|
||||
BuilderKeyring(builder_hmac=b"h" * 32),
|
||||
socket_path=Path("/run/aios-trusted-candidate-builder.sock"),
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
with pytest.raises(BuilderError, match="BUILDER_TRANSPORT_UNAVAILABLE"):
|
||||
client.build(
|
||||
"V2R2C2",
|
||||
"r42-release-candidate-v2r2c2-20260819",
|
||||
"71e736b05fab13efc06af7a4fe2dc022c1cf9a85",
|
||||
nonce="t" * 32,
|
||||
)
|
||||
assert observed["timeout"] == config.request_timeout_seconds
|
||||
|
||||
|
||||
def test_backend_attestation_round_trip_verifies_with_production_keyring() -> None:
|
||||
private = Ed25519PrivateKey.generate()
|
||||
|
||||
class Ed25519Signer:
|
||||
key_id = "builder-key-001"
|
||||
key_generation = 1
|
||||
|
||||
def sign_for_purpose(self, purpose, payload):
|
||||
assert purpose == "builder_attestation"
|
||||
return private.sign(payload)
|
||||
|
||||
server, client, _ = make_seam(signer_override=Ed25519Signer())
|
||||
response = server.handle_request(
|
||||
client.create_request(
|
||||
"V2R2C2",
|
||||
"r42-release-candidate-v2r2c2-20260819",
|
||||
"71e736b05fab13efc06af7a4fe2dc022c1cf9a85",
|
||||
nonce="v" * 32,
|
||||
),
|
||||
connection=object(),
|
||||
)
|
||||
public_key = private.public_key().public_bytes(
|
||||
serialization.Encoding.Raw,
|
||||
serialization.PublicFormat.Raw,
|
||||
)
|
||||
verifier = BuilderAttestationVerifier(
|
||||
BuilderAttestationKeyRing(
|
||||
[BuilderAttestationKeyRecord(
|
||||
key_id="builder-key-001",
|
||||
public_key=public_key,
|
||||
algorithm="Ed25519",
|
||||
purpose="builder_attestation",
|
||||
generation=1,
|
||||
not_before="2020-01-01T00:00:00Z",
|
||||
expires_at="2099-01-01T00:00:00Z",
|
||||
status="ACTIVE",
|
||||
)],
|
||||
require_attestation_metadata=True,
|
||||
)
|
||||
)
|
||||
raw = response["silent_release_bundle"]
|
||||
bundle = TrustedCandidateBundle(
|
||||
ReleaseCandidate.from_dict(raw["candidate"]),
|
||||
ReleasePlan.from_dict(raw["release_plan"]),
|
||||
raw["checks"],
|
||||
BuilderAttestation.from_dict(raw["attestation"], require_fingerprint=True),
|
||||
)
|
||||
assert bundle.validate(verifier).candidate.candidate_id == response["evidence"]["candidate_id"]
|
||||
|
||||
|
||||
def test_public_build_seam_rejects_direct_unbound_v2r2c3_and_authentication_boundaries() -> None:
|
||||
server, client, _ = make_seam()
|
||||
with pytest.raises(BuilderError, match="GOVERNED_V2R2C3_DERIVATION_REQUIRED"):
|
||||
client.create_request("V2R2C3", "unknown", "f" * 40, nonce="d" * 32)
|
||||
denied_peer, denied_client, _ = make_seam(peer=PeerIdentity(pid=77, uid=0, gid=0))
|
||||
with pytest.raises(BuilderError, match="PEER_IDENTITY_DENIED"):
|
||||
denied_peer.handle_request(denied_client.create_request("V2R2C2", "r42-release-candidate-v2r2c2-20260819", "71e736b05fab13efc06af7a4fe2dc022c1cf9a85", nonce="e" * 32), connection=object())
|
||||
denied_cgroup, denied_client, _ = make_seam(cgroup="/system.slice/untrusted.service")
|
||||
with pytest.raises(BuilderError, match="PEER_CGROUP_DENIED"):
|
||||
denied_cgroup.handle_request(denied_client.create_request("V2R2C2", "r42-release-candidate-v2r2c2-20260819", "71e736b05fab13efc06af7a4fe2dc022c1cf9a85", nonce="f" * 32), connection=object())
|
||||
denied_process, denied_client, _ = make_seam(
|
||||
process=("/usr/bin/python3.12", ("/usr/bin/python3", "/tmp/aios_model_api_staging.py"))
|
||||
)
|
||||
with pytest.raises(BuilderError, match="PEER_PROCESS_DENIED"):
|
||||
denied_process.handle_request(denied_client.create_request("V2R2C2", "r42-release-candidate-v2r2c2-20260819", "71e736b05fab13efc06af7a4fe2dc022c1cf9a85", nonce="p" * 32), connection=object())
|
||||
stale_server, stale_client, _ = make_seam(client_clock=lambda: NOW - 61)
|
||||
with pytest.raises(BuilderError, match="REQUEST_STALE"):
|
||||
stale_server.handle_request(stale_client.create_request("V2R2C2", "r42-release-candidate-v2r2c2-20260819", "71e736b05fab13efc06af7a4fe2dc022c1cf9a85", nonce="g" * 32), connection=object())
|
||||
|
||||
|
||||
def test_public_build_seam_fails_closed_when_server_owned_collector_is_incomplete() -> None:
|
||||
def incomplete_evidence(recipe_name, recipe):
|
||||
value = passing_bundle(recipe_name, recipe)
|
||||
value["checks"]["canary"] = False
|
||||
return value
|
||||
|
||||
policy = load_builder_policy(POLICY_PATH)
|
||||
config = TrustedCandidateBuilderConfig.from_policy(policy)
|
||||
server, client, _ = make_seam(
|
||||
collector=DeterministicEvidenceCollector(config, evidence_source=incomplete_evidence)
|
||||
)
|
||||
with pytest.raises(BuilderError, match="COLLECTOR_CHECKS_INCOMPLETE"):
|
||||
server.handle_request(client.create_request("V2R2C2", "r42-release-candidate-v2r2c2-20260819", "71e736b05fab13efc06af7a4fe2dc022c1cf9a85", nonce="h" * 32), connection=object())
|
||||
|
||||
|
||||
def test_server_owned_source_rehashes_every_fixed_evidence_file_and_rejects_tampering(tmp_path) -> None:
|
||||
config = TrustedCandidateBuilderConfig.from_policy(load_builder_policy(POLICY_PATH))
|
||||
recipe = config.fixed_recipe("V2R2C2")
|
||||
raw = passing_bundle("V2R2C2", recipe)
|
||||
candidate = ReleaseCandidate.from_dict(raw["candidate"])
|
||||
plan = ReleasePlan.from_dict(raw["release_plan"])
|
||||
evidence_root = tmp_path / "evidence"
|
||||
version_root = evidence_root / "V2R2C2"
|
||||
version_root.mkdir(parents=True)
|
||||
|
||||
def write_bytes(name: str, value: bytes) -> str:
|
||||
path = version_root / name
|
||||
path.write_bytes(value)
|
||||
path.chmod(0o600)
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
def canonical(value: dict) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
||||
|
||||
tests = {"passed": True, "receipt": "tests-fixed"}
|
||||
canary = {"passed": True, "receipt": "canary-fixed"}
|
||||
gitea = {
|
||||
"consistent": True,
|
||||
"remote_ref_oid": recipe["source_commit"],
|
||||
"repository": "aios/aios",
|
||||
"source_commit": recipe["source_commit"],
|
||||
"tree_oid": candidate.tree_oid,
|
||||
}
|
||||
git_consistency = {
|
||||
"consistent": True,
|
||||
"repository": "aios/aios",
|
||||
"source_commit": recipe["source_commit"],
|
||||
"tree_oid": candidate.tree_oid,
|
||||
}
|
||||
rollback = {
|
||||
"ready": True,
|
||||
"rollback_sha256": plan.deployment.rollback_sha256,
|
||||
"source_commit": recipe["source_commit"],
|
||||
}
|
||||
artifact_bytes = b"immutable-artifact"
|
||||
manifest_bytes = b'{"manifest":"fixed"}\n'
|
||||
closure_bytes = b"closure artifact.tar.gz\n"
|
||||
policy_bytes = b'{"policy":"fixed"}\n'
|
||||
artifact_sha = write_bytes("artifact.tar.gz", artifact_bytes)
|
||||
manifest_sha = write_bytes("manifest.json", manifest_bytes)
|
||||
closure_sha = write_bytes("checksums.sha256", closure_bytes)
|
||||
tests_sha = write_bytes("tests.json", canonical(tests))
|
||||
canary_sha = write_bytes("canary.json", canonical(canary))
|
||||
gitea_sha = write_bytes("gitea.json", canonical(gitea))
|
||||
rollback_sha = write_bytes("rollback.json", canonical(rollback))
|
||||
policy_sha = write_bytes("silent-release-policy.json", policy_bytes)
|
||||
secret_scan = {"artifact_sha256": artifact_sha, "passed": True}
|
||||
secret_sha = write_bytes("secret-scan.json", canonical(secret_scan))
|
||||
|
||||
candidate.artifact_sha256 = artifact_sha
|
||||
candidate.manifest_sha256 = manifest_sha
|
||||
candidate.tests = tests
|
||||
candidate.canary = canary
|
||||
candidate.git_consistency = git_consistency
|
||||
candidate.gitea_consistency = gitea
|
||||
deployment = dataclasses.replace(
|
||||
plan.deployment,
|
||||
artifact_sha256=artifact_sha,
|
||||
manifest_sha256=manifest_sha,
|
||||
package_closure_sha256=closure_sha,
|
||||
policy_sha256=policy_sha,
|
||||
)
|
||||
plan = dataclasses.replace(plan, deployment=deployment, policy_sha256=policy_sha)
|
||||
receipt = {
|
||||
"schema": "aios.trusted-candidate-builder.evidence.v1",
|
||||
"version": "V2R2C2",
|
||||
"candidate": candidate.to_dict(),
|
||||
"release_plan": plan.to_dict(),
|
||||
}
|
||||
write_bytes("receipt.json", canonical(receipt))
|
||||
|
||||
git_state = {"tree": candidate.tree_oid, "ancestry": 0}
|
||||
|
||||
def git_runner(argv, **_kwargs):
|
||||
if "rev-parse" in argv:
|
||||
return SimpleNamespace(stdout=git_state["tree"] + "\n", returncode=0)
|
||||
return SimpleNamespace(stdout="", returncode=git_state["ancestry"])
|
||||
|
||||
source = ServerOwnedEvidenceSource(
|
||||
config,
|
||||
tmp_path / "repo.git",
|
||||
evidence_root,
|
||||
expected_owner_uid=None,
|
||||
git_runner=git_runner,
|
||||
)
|
||||
collected = source("V2R2C2", recipe)
|
||||
assert collected["candidate"]["artifact_sha256"] == artifact_sha
|
||||
assert collected["evidence_digests"] == {
|
||||
"artifact": artifact_sha,
|
||||
"manifest": manifest_sha,
|
||||
"package_closure": closure_sha,
|
||||
"tests": tests_sha,
|
||||
"canary": canary_sha,
|
||||
"gitea": gitea_sha,
|
||||
"rollback": rollback_sha,
|
||||
"policy": policy_sha,
|
||||
"secret_scan": secret_sha,
|
||||
}
|
||||
|
||||
(version_root / "manifest.json").write_bytes(b"tampered")
|
||||
with pytest.raises(BuilderError, match="COLLECTOR_DIGEST_MISMATCH"):
|
||||
source("V2R2C2", recipe)
|
||||
(version_root / "manifest.json").write_bytes(manifest_bytes)
|
||||
|
||||
for name, tampered, expected in (
|
||||
("artifact.tar.gz", b"tampered", "COLLECTOR_DIGEST_MISMATCH"),
|
||||
("checksums.sha256", b"tampered", "COLLECTOR_DIGEST_MISMATCH"),
|
||||
("silent-release-policy.json", b"tampered", "COLLECTOR_DIGEST_MISMATCH"),
|
||||
("tests.json", canonical({"passed": True, "receipt": "wrong"}), "COLLECTOR_TESTS_MISMATCH"),
|
||||
("canary.json", canonical({"passed": True, "receipt": "wrong"}), "COLLECTOR_CANARY_MISMATCH"),
|
||||
("gitea.json", canonical({**gitea, "consistent": False}), "COLLECTOR_GITEA_MISMATCH"),
|
||||
("rollback.json", canonical({**rollback, "ready": False}), "COLLECTOR_ROLLBACK_MISMATCH"),
|
||||
("secret-scan.json", canonical({**secret_scan, "passed": False}), "COLLECTOR_SECRET_SCAN_MISMATCH"),
|
||||
):
|
||||
path = version_root / name
|
||||
original = path.read_bytes()
|
||||
path.write_bytes(tampered)
|
||||
with pytest.raises(BuilderError, match=expected):
|
||||
source("V2R2C2", recipe)
|
||||
path.write_bytes(original)
|
||||
|
||||
git_state["tree"] = "f" * 40
|
||||
with pytest.raises(BuilderError, match="COLLECTOR_GIT_MISMATCH"):
|
||||
source("V2R2C2", recipe)
|
||||
git_state["tree"] = candidate.tree_oid
|
||||
git_state["ancestry"] = 1
|
||||
with pytest.raises(BuilderError, match="COLLECTOR_GIT_MISMATCH"):
|
||||
source("V2R2C2", recipe)
|
||||
|
||||
|
||||
def test_systemd_socket_and_service_are_af_unix_hardened_and_credential_bound() -> None:
|
||||
socket_unit = (ROOT / "deploy/systemd/aios-trusted-candidate-builder.socket").read_text()
|
||||
service_unit = (ROOT / "deploy/systemd/aios-trusted-candidate-builder.service").read_text()
|
||||
|
||||
assert "ListenStream=/run/aios-trusted-candidate-builder.sock" in socket_unit
|
||||
assert "SocketMode=0660" in socket_unit
|
||||
assert "LoadCredentialEncrypted=signing_private_key:" in service_unit
|
||||
assert "LoadCredentialEncrypted=builder_hmac:" in service_unit
|
||||
assert "NoNewPrivileges=true" in service_unit
|
||||
assert "ProtectSystem=strict" in service_unit
|
||||
assert "PrivateNetwork=true" in service_unit
|
||||
assert "RestrictAddressFamilies=AF_UNIX" in service_unit
|
||||
assert "StateDirectory=aios-trusted-candidate-builder" in service_unit
|
||||
assert "AIOS_TRUSTED_BUILDER_KEY_GENERATION=1" in service_unit
|
||||
assert "aios-approved-release-executor" not in service_unit
|
||||
assert "--socket-fd 3" in service_unit
|
||||
policy = load_builder_policy(POLICY_PATH)
|
||||
assert policy["allowed_peer_uid"] == 1000
|
||||
assert policy["allowed_peer_cgroups"] == [
|
||||
"/system.slice/aios-gitops-model-api.service"
|
||||
]
|
||||
@@ -1,215 +0,0 @@
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_agent_task_protocol import TaskProtocolError, TaskStore
|
||||
from aios_chatgpt_bridge_v2_server import BridgeHandler
|
||||
import aios_durable_owner_authorization as owner_auth
|
||||
from aios_online_agent_execution import GovernedExecutionBinding, project_online_task
|
||||
from aios_online_agent_worker import WorkerError, WorkerSupervisor
|
||||
|
||||
CAPABILITIES = ["git.read", "git.test", "governed.write.branch", "governed.write.file"]
|
||||
|
||||
|
||||
def binding(task_id="unified-test-task-20260824"):
|
||||
return GovernedExecutionBinding.from_mapping({
|
||||
"task_id": task_id,
|
||||
"job_id": "job-" + "a" * 32,
|
||||
"repository": "aios/aios",
|
||||
"base_oid": "0" * 40,
|
||||
"expected_branch_oid": "0" * 40,
|
||||
"target_branch": "agent/unified-test-20260824",
|
||||
"branch_mode": "CREATE_NEW",
|
||||
"generation": 1,
|
||||
"idempotency_key": "unified-test-task-20260824-v1",
|
||||
})
|
||||
|
||||
|
||||
def projected(task_id="unified-test-task-20260824"):
|
||||
return project_online_task(
|
||||
binding(task_id),
|
||||
{
|
||||
"goal": "test governed task",
|
||||
"instructions": "test only",
|
||||
"success_criteria": [],
|
||||
"required_capabilities": CAPABILITIES,
|
||||
"priority": 50,
|
||||
"max_attempts": 3,
|
||||
},
|
||||
{
|
||||
"action": "source_capture",
|
||||
"adapter": "source_capture",
|
||||
"capability_scope": CAPABILITIES,
|
||||
"source_signals": ["durable_policy"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_write_task_rejected_before_persistence(tmp_path):
|
||||
store = TaskStore(tmp_path / "state.sqlite3")
|
||||
with pytest.raises(TaskProtocolError) as error:
|
||||
store.create_task(
|
||||
task_id="bare-write-task-20260824",
|
||||
goal="bare",
|
||||
instructions="must reject",
|
||||
success_criteria=[],
|
||||
required_capabilities=CAPABILITIES,
|
||||
metadata={},
|
||||
idempotency_key="bare-write-task-20260824-v1",
|
||||
)
|
||||
assert error.value.code == "GOVERNED_EXECUTION_BINDING_REQUIRED"
|
||||
with pytest.raises(TaskProtocolError) as missing:
|
||||
store.get_task("bare-write-task-20260824")
|
||||
assert missing.value.code == "TASK_NOT_FOUND"
|
||||
|
||||
|
||||
def test_complete_projection_persists_envelope(tmp_path):
|
||||
store = TaskStore(tmp_path / "state.sqlite3")
|
||||
task = projected()
|
||||
created = store.create_task(
|
||||
task_id=task["task_id"],
|
||||
goal=task["goal"],
|
||||
instructions=task["instructions"],
|
||||
success_criteria=task["success_criteria"],
|
||||
required_capabilities=task["required_capabilities"],
|
||||
constraints={},
|
||||
metadata=task["metadata"],
|
||||
priority=task["priority"],
|
||||
max_attempts=task["max_attempts"],
|
||||
idempotency_key=task["idempotency_key"],
|
||||
)
|
||||
execution = created["metadata"]["execution"]
|
||||
assert created["status"] == "QUEUED"
|
||||
assert execution["schema"] == "aios.online-agent.execution.v2"
|
||||
assert execution["job_id"].startswith("job-")
|
||||
assert execution["execution_digest"]
|
||||
assert created["metadata"]["execution_projection"]["execution_digest"] == execution["execution_digest"]
|
||||
|
||||
|
||||
def test_online_route_requires_governed_creation_for_write_caps():
|
||||
source = Path(__file__).parents[1] / "active-runtime" / "app" / "aios_model_api_staging.py"
|
||||
tree = ast.parse(source.read_text(encoding="utf-8"))
|
||||
function = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "requires_governed_execution")
|
||||
namespace = {
|
||||
"Mapping": Mapping,
|
||||
"Any": Any,
|
||||
"GOVERNED_EXECUTION_CAPABILITIES": {"governed.write.branch", "governed.write.file"},
|
||||
}
|
||||
exec(compile(ast.Module(body=[function], type_ignores=[]), str(source), "exec"), namespace)
|
||||
guard = namespace["requires_governed_execution"]
|
||||
assert guard({"required_capabilities": CAPABILITIES})
|
||||
assert not guard({"required_capabilities": ["git.read", "git.test"]})
|
||||
|
||||
|
||||
def test_bridge_requires_claim_for_projection_and_job(monkeypatch):
|
||||
handler = object.__new__(BridgeHandler)
|
||||
handler.headers = {"Authorization": "Bearer agent-credential"}
|
||||
handler.control_token = None
|
||||
handler.agent_task_api_url = "http://task-api"
|
||||
|
||||
class Response:
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
def __enter__(self):
|
||||
return self
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
def read(self):
|
||||
return json.dumps(self.value).encode()
|
||||
|
||||
responses = iter([
|
||||
{"agent_id": "worker-1"},
|
||||
{"task_id": "task-1", "claimed_by": None, "status": "QUEUED"},
|
||||
])
|
||||
monkeypatch.setattr("aios_chatgpt_bridge_v2_server.urllib.request.urlopen", lambda *_args, **_kwargs: Response(next(responses)))
|
||||
assert not handler._authorized("projectOnlineAgentExecution", {"task_id": "task-1"})
|
||||
|
||||
responses = iter([
|
||||
{"agent_id": "worker-1"},
|
||||
{"task_id": "task-1", "claimed_by": "worker-1", "status": "CLAIMED"},
|
||||
])
|
||||
assert handler._authorized("getGitOpsJob", {"task_id": "task-1"})
|
||||
|
||||
|
||||
def test_broker_uses_so_peercred_and_canonical_group(monkeypatch):
|
||||
class Connection:
|
||||
def getsockopt(self, *_args):
|
||||
return struct.pack("3i", os.getpid(), os.getuid(), os.getgid())
|
||||
|
||||
monkeypatch.setattr(owner_auth, "_peer_process_status", lambda _pid: (os.getuid(), os.getgid(), {os.getgid(), 4242}))
|
||||
monkeypatch.setattr(owner_auth, "CANONICAL_BROKER_GROUP", "aios-autonomous-agent-worker")
|
||||
monkeypatch.setattr(owner_auth.grp, "getgrnam", lambda _name: SimpleNamespace(gr_gid=4242))
|
||||
assert owner_auth.authorize_broker_peer(Connection())
|
||||
|
||||
monkeypatch.setattr(owner_auth, "_peer_process_status", lambda _pid: (os.getuid(), os.getgid(), {os.getgid()}))
|
||||
with pytest.raises(owner_auth.AuthorizationError) as error:
|
||||
owner_auth.authorize_broker_peer(Connection())
|
||||
assert error.value.code == "SOCKGROUP_NOT_AUTHORIZED"
|
||||
|
||||
|
||||
def test_bootstrap_rejects_bad_projection_before_job(tmp_path):
|
||||
from aios_governed_task_bootstrap import BootstrapError, GovernedTaskBootstrap
|
||||
|
||||
class Authority:
|
||||
def commit_exists(self, _oid):
|
||||
return True
|
||||
def read_ref(self, _ref):
|
||||
return None
|
||||
|
||||
bootstrap = GovernedTaskBootstrap(
|
||||
Authority(), tmp_path, remote_name="gitea-http",
|
||||
token_path=tmp_path / "token", ssh_key_path=tmp_path / "key",
|
||||
)
|
||||
with pytest.raises(BootstrapError) as error:
|
||||
bootstrap.create({
|
||||
"task_id": "atomic-projection-failure-20260824",
|
||||
"repository": "aios/aios",
|
||||
"base_oid": "0" * 40,
|
||||
"expected_branch_oid": "0" * 40,
|
||||
"target_branch": "agent/atomic-projection-failure-20260824",
|
||||
"risk": "engineering",
|
||||
"description": "invalid deterministic placeholder",
|
||||
"idempotency_key": "atomic-projection-failure-20260824-v1",
|
||||
"online_task": {
|
||||
"goal": "invalid",
|
||||
"instructions": "invalid",
|
||||
"success_criteria": [],
|
||||
"required_capabilities": CAPABILITIES,
|
||||
},
|
||||
"execution": {
|
||||
"action": "deterministic",
|
||||
"adapter": "deterministic",
|
||||
"capability_scope": CAPABILITIES,
|
||||
"change_set": [],
|
||||
"verification": [],
|
||||
},
|
||||
"require_online_task_projection": True,
|
||||
})
|
||||
assert error.value.args[0] == "EXECUTION_BINDING_INVALID"
|
||||
assert not list((tmp_path / "jobs").rglob("state.sqlite3"))
|
||||
assert not list((tmp_path / "task-bindings").glob("*.json"))
|
||||
|
||||
|
||||
def test_worker_rejects_bare_write_task():
|
||||
supervisor = object.__new__(WorkerSupervisor)
|
||||
with pytest.raises(WorkerError) as error:
|
||||
supervisor.resolve_envelope({
|
||||
"task_id": "bare-worker-task-20260824",
|
||||
"required_capabilities": CAPABILITIES,
|
||||
"metadata": {},
|
||||
})
|
||||
assert error.value.code == "TASK_SKIPPED_PROJECTION_MISSING"
|
||||
|
||||
|
||||
def test_continuation_scope_allows_existing_subset():
|
||||
source = Path(__file__).parents[1] / "active-runtime" / "app" / "aios_continuation_executor.py"
|
||||
text = source.read_text(encoding="utf-8")
|
||||
assert "if dirty and not dirty.issubset(expected_paths):" in text
|
||||
assert "if dirty and dirty != expected_paths:" not in text
|
||||
Reference in New Issue
Block a user