Compare commits
17
Commits
File diff suppressed because it is too large
Load Diff
@@ -1,811 +0,0 @@
|
||||
"""Canonical Task projection intent and same-identity recovery primitives.
|
||||
|
||||
This module is intentionally Task-Plane-owned. It does not open a network
|
||||
listener and it does not authenticate callers; existing control-plane services
|
||||
must enforce their normal service-side ``agent-task:control`` boundary before
|
||||
calling it.
|
||||
|
||||
The recovery primitive has one purpose: converge a durable, complete
|
||||
ProjectionIntent to exactly one canonical Task row. It never fabricates task
|
||||
semantics from a Bridge binding and it never generates a replacement task id.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
|
||||
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
||||
JOB_ID_RE = TASK_ID_RE
|
||||
SHA40_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SHA64_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9._/-]{1,220}$")
|
||||
SCHEMA = "aios.canonical-task-projection-intent.v1"
|
||||
RECEIPT_SCHEMA = "aios.canonical-task-projection-receipt.v1"
|
||||
|
||||
|
||||
class ProjectionRecoveryError(RuntimeError):
|
||||
def __init__(self, code: str, status: int = 409) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
|
||||
class ProjectionState(str, Enum):
|
||||
PENDING = "PENDING"
|
||||
CONVERGED = "CONVERGED"
|
||||
DRIFT = "DRIFT"
|
||||
FAILED = "FAILED"
|
||||
BLOCKED = "BLOCKED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
BACKFILL_REQUIRED = "BACKFILL_REQUIRED"
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _canonical(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sha256_json(value: object) -> str:
|
||||
return hashlib.sha256(_canonical(value)).hexdigest()
|
||||
|
||||
|
||||
def _string(value: object, code: str, pattern: re.Pattern[str] | None = None) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ProjectionRecoveryError(code, 400)
|
||||
result = value.strip()
|
||||
if pattern is not None and pattern.fullmatch(result) is None:
|
||||
raise ProjectionRecoveryError(code, 400)
|
||||
return result
|
||||
|
||||
|
||||
def _integer(value: object, code: str, *, minimum: int = 0) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
||||
raise ProjectionRecoveryError(code, 400)
|
||||
return value
|
||||
|
||||
|
||||
def _plain_mapping(value: object, code: str) -> dict[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ProjectionRecoveryError(code, 400)
|
||||
return {str(k): v for k, v in value.items()}
|
||||
|
||||
|
||||
def _plain_list(value: object, code: str) -> list[Any]:
|
||||
if not isinstance(value, list):
|
||||
raise ProjectionRecoveryError(code, 400)
|
||||
return list(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CanonicalTaskPayload:
|
||||
task_id: str
|
||||
goal: str
|
||||
instructions: str
|
||||
success_criteria: tuple[Mapping[str, Any], ...]
|
||||
constraints: Mapping[str, Any]
|
||||
required_capabilities: tuple[str, ...]
|
||||
metadata: Mapping[str, Any]
|
||||
priority: int = 100
|
||||
max_attempts: int = 20
|
||||
deadline: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "CanonicalTaskPayload":
|
||||
allowed = {
|
||||
"task_id",
|
||||
"goal",
|
||||
"instructions",
|
||||
"success_criteria",
|
||||
"constraints",
|
||||
"required_capabilities",
|
||||
"metadata",
|
||||
"priority",
|
||||
"max_attempts",
|
||||
"deadline",
|
||||
}
|
||||
if set(value) - allowed:
|
||||
raise ProjectionRecoveryError("TASK_PAYLOAD_UNKNOWN_FIELDS", 400)
|
||||
|
||||
task_id = _string(value.get("task_id"), "TASK_ID_INVALID", TASK_ID_RE)
|
||||
goal = _string(value.get("goal"), "TASK_GOAL_INVALID")
|
||||
instructions = _string(value.get("instructions"), "TASK_INSTRUCTIONS_INVALID")
|
||||
if len(goal) > 8192 or len(instructions) > 65536:
|
||||
raise ProjectionRecoveryError("TASK_TEXT_TOO_LARGE", 413)
|
||||
|
||||
criteria_raw = _plain_list(value.get("success_criteria"), "TASK_SUCCESS_CRITERIA_INVALID")
|
||||
criteria: list[Mapping[str, Any]] = []
|
||||
for item in criteria_raw:
|
||||
if not isinstance(item, Mapping):
|
||||
raise ProjectionRecoveryError("TASK_SUCCESS_CRITERIA_INVALID", 400)
|
||||
criteria.append(dict(item))
|
||||
if not criteria:
|
||||
raise ProjectionRecoveryError("TASK_SUCCESS_CRITERIA_REQUIRED", 400)
|
||||
|
||||
constraints = _plain_mapping(value.get("constraints"), "TASK_CONSTRAINTS_INVALID")
|
||||
capabilities_raw = _plain_list(
|
||||
value.get("required_capabilities", []),
|
||||
"TASK_CAPABILITIES_INVALID",
|
||||
)
|
||||
capabilities: list[str] = []
|
||||
for item in capabilities_raw:
|
||||
capabilities.append(_string(item, "TASK_CAPABILITY_INVALID"))
|
||||
|
||||
# Recovery recreates the pristine canonical row. Initial execution
|
||||
# projection remains the sole owner of runtime capabilities.
|
||||
if capabilities:
|
||||
raise ProjectionRecoveryError(
|
||||
"TASK_RECOVERY_REQUIRES_PRISTINE_CAPABILITIES",
|
||||
409,
|
||||
)
|
||||
|
||||
metadata = _plain_mapping(value.get("metadata", {}), "TASK_METADATA_INVALID")
|
||||
if "execution" in metadata or "execution_projection" in metadata:
|
||||
raise ProjectionRecoveryError(
|
||||
"TASK_RECOVERY_REQUIRES_PRISTINE_METADATA",
|
||||
409,
|
||||
)
|
||||
|
||||
priority = _integer(value.get("priority", 100), "TASK_PRIORITY_INVALID", minimum=0)
|
||||
max_attempts = _integer(
|
||||
value.get("max_attempts", 20),
|
||||
"TASK_MAX_ATTEMPTS_INVALID",
|
||||
minimum=1,
|
||||
)
|
||||
deadline = value.get("deadline")
|
||||
if deadline is not None:
|
||||
deadline = _string(deadline, "TASK_DEADLINE_INVALID")
|
||||
|
||||
return cls(
|
||||
task_id=task_id,
|
||||
goal=goal,
|
||||
instructions=instructions,
|
||||
success_criteria=tuple(criteria),
|
||||
constraints=constraints,
|
||||
required_capabilities=tuple(capabilities),
|
||||
metadata=metadata,
|
||||
priority=priority,
|
||||
max_attempts=max_attempts,
|
||||
deadline=deadline,
|
||||
)
|
||||
|
||||
def public(self) -> dict[str, Any]:
|
||||
return {
|
||||
"task_id": self.task_id,
|
||||
"goal": self.goal,
|
||||
"instructions": self.instructions,
|
||||
"success_criteria": [dict(x) for x in self.success_criteria],
|
||||
"constraints": dict(self.constraints),
|
||||
"required_capabilities": list(self.required_capabilities),
|
||||
"metadata": dict(self.metadata),
|
||||
"priority": self.priority,
|
||||
"max_attempts": self.max_attempts,
|
||||
"deadline": self.deadline,
|
||||
}
|
||||
|
||||
@property
|
||||
def payload_sha256(self) -> str:
|
||||
return sha256_json(self.public())
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovernedProjectionBinding:
|
||||
governed_task_id: str
|
||||
bridge_job_id: str
|
||||
repository: str
|
||||
branch: str
|
||||
generation: int
|
||||
base_oid: str
|
||||
expected_branch_oid: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "GovernedProjectionBinding":
|
||||
allowed = {
|
||||
"governed_task_id",
|
||||
"bridge_job_id",
|
||||
"repository",
|
||||
"branch",
|
||||
"generation",
|
||||
"base_oid",
|
||||
"expected_branch_oid",
|
||||
}
|
||||
if set(value) != allowed:
|
||||
raise ProjectionRecoveryError("GOVERNED_BINDING_FIELDS_INVALID", 400)
|
||||
repository = _string(value["repository"], "REPOSITORY_INVALID")
|
||||
if repository != "aios/aios":
|
||||
raise ProjectionRecoveryError("REPOSITORY_MISMATCH", 409)
|
||||
return cls(
|
||||
governed_task_id=_string(
|
||||
value["governed_task_id"],
|
||||
"GOVERNED_TASK_ID_INVALID",
|
||||
TASK_ID_RE,
|
||||
),
|
||||
bridge_job_id=_string(
|
||||
value["bridge_job_id"],
|
||||
"BRIDGE_JOB_ID_INVALID",
|
||||
JOB_ID_RE,
|
||||
),
|
||||
repository=repository,
|
||||
branch=_string(value["branch"], "BRANCH_INVALID", BRANCH_RE),
|
||||
generation=_integer(value["generation"], "GENERATION_INVALID", minimum=1),
|
||||
base_oid=_string(value["base_oid"], "BASE_OID_INVALID", SHA40_RE),
|
||||
expected_branch_oid=_string(
|
||||
value["expected_branch_oid"],
|
||||
"EXPECTED_OID_INVALID",
|
||||
SHA40_RE,
|
||||
),
|
||||
)
|
||||
|
||||
def public(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProjectionSourceArtifact:
|
||||
ref: str
|
||||
sha256: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "ProjectionSourceArtifact":
|
||||
if set(value) != {"ref", "sha256"}:
|
||||
raise ProjectionRecoveryError("SOURCE_ARTIFACT_FIELDS_INVALID", 400)
|
||||
return cls(
|
||||
ref=_string(value["ref"], "SOURCE_ARTIFACT_REF_INVALID"),
|
||||
sha256=_string(value["sha256"], "SOURCE_ARTIFACT_SHA_INVALID", SHA64_RE),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CanonicalTaskProjectionIntent:
|
||||
projection_id: str
|
||||
projection_generation: int
|
||||
idempotency_key: str
|
||||
task: CanonicalTaskPayload
|
||||
binding: GovernedProjectionBinding
|
||||
source_artifact: ProjectionSourceArtifact
|
||||
schema: str = SCHEMA
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "CanonicalTaskProjectionIntent":
|
||||
allowed = {
|
||||
"schema",
|
||||
"projection_id",
|
||||
"projection_generation",
|
||||
"idempotency_key",
|
||||
"task",
|
||||
"governed_binding",
|
||||
"source_artifact",
|
||||
"task_payload_sha256",
|
||||
}
|
||||
if set(value) != allowed:
|
||||
raise ProjectionRecoveryError("PROJECTION_INTENT_FIELDS_INVALID", 400)
|
||||
if value["schema"] != SCHEMA:
|
||||
raise ProjectionRecoveryError("PROJECTION_INTENT_SCHEMA_INVALID", 400)
|
||||
|
||||
task = CanonicalTaskPayload.from_mapping(
|
||||
_plain_mapping(value["task"], "TASK_PAYLOAD_INVALID")
|
||||
)
|
||||
binding = GovernedProjectionBinding.from_mapping(
|
||||
_plain_mapping(value["governed_binding"], "GOVERNED_BINDING_INVALID")
|
||||
)
|
||||
source = ProjectionSourceArtifact.from_mapping(
|
||||
_plain_mapping(value["source_artifact"], "SOURCE_ARTIFACT_INVALID")
|
||||
)
|
||||
if binding.governed_task_id != task.task_id:
|
||||
raise ProjectionRecoveryError("PROJECTION_TASK_IDENTITY_MISMATCH", 409)
|
||||
|
||||
expected_hash = _string(
|
||||
value["task_payload_sha256"],
|
||||
"TASK_PAYLOAD_SHA_INVALID",
|
||||
SHA64_RE,
|
||||
)
|
||||
if task.payload_sha256 != expected_hash:
|
||||
raise ProjectionRecoveryError("TASK_PAYLOAD_SHA_MISMATCH", 409)
|
||||
|
||||
projection_id = _string(
|
||||
value["projection_id"],
|
||||
"PROJECTION_ID_INVALID",
|
||||
TASK_ID_RE,
|
||||
)
|
||||
idempotency_key = _string(
|
||||
value["idempotency_key"],
|
||||
"IDEMPOTENCY_KEY_INVALID",
|
||||
TASK_ID_RE,
|
||||
)
|
||||
generation = _integer(
|
||||
value["projection_generation"],
|
||||
"PROJECTION_GENERATION_INVALID",
|
||||
minimum=1,
|
||||
)
|
||||
return cls(
|
||||
projection_id=projection_id,
|
||||
projection_generation=generation,
|
||||
idempotency_key=idempotency_key,
|
||||
task=task,
|
||||
binding=binding,
|
||||
source_artifact=source,
|
||||
)
|
||||
|
||||
def public(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": self.schema,
|
||||
"projection_id": self.projection_id,
|
||||
"projection_generation": self.projection_generation,
|
||||
"idempotency_key": self.idempotency_key,
|
||||
"task": self.task.public(),
|
||||
"governed_binding": self.binding.public(),
|
||||
"source_artifact": asdict(self.source_artifact),
|
||||
"task_payload_sha256": self.task.payload_sha256,
|
||||
}
|
||||
|
||||
@property
|
||||
def request_sha256(self) -> str:
|
||||
return sha256_json(self.public())
|
||||
|
||||
|
||||
class CanonicalTaskProjectionStore:
|
||||
"""Task-Plane-owned intent/recovery store.
|
||||
|
||||
The caller must already be inside the Task Plane authorization boundary.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path | str) -> None:
|
||||
self.db_path = Path(db_path)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.db_path, timeout=30.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA busy_timeout=30000")
|
||||
return connection
|
||||
|
||||
@staticmethod
|
||||
def _columns(connection: sqlite3.Connection, table: str) -> dict[str, sqlite3.Row]:
|
||||
return {
|
||||
str(row["name"]): row
|
||||
for row in connection.execute(f'PRAGMA table_info("{table}")')
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _table_names(connection: sqlite3.Connection) -> set[str]:
|
||||
return {
|
||||
str(row[0])
|
||||
for row in connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
)
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _event_table(
|
||||
cls,
|
||||
connection: sqlite3.Connection,
|
||||
) -> tuple[str, dict[str, sqlite3.Row]]:
|
||||
tables = cls._table_names(connection)
|
||||
for name in ("task_events", "events"):
|
||||
if name in tables:
|
||||
columns = cls._columns(connection, name)
|
||||
if "task_id" in columns and "event_type" in columns:
|
||||
return name, columns
|
||||
raise ProjectionRecoveryError("TASK_EVENT_TABLE_UNSUPPORTED", 503)
|
||||
|
||||
@staticmethod
|
||||
def _ensure_control_tables(connection: sqlite3.Connection) -> None:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_projection_intents (
|
||||
projection_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
bridge_job_id TEXT NOT NULL,
|
||||
projection_generation INTEGER NOT NULL,
|
||||
idempotency_key TEXT NOT NULL,
|
||||
request_sha256 TEXT NOT NULL,
|
||||
task_payload_sha256 TEXT NOT NULL,
|
||||
intent_json TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(task_id, projection_generation),
|
||||
UNIQUE(task_id, idempotency_key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_projection_receipts (
|
||||
projection_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
bridge_job_id TEXT NOT NULL,
|
||||
projection_generation INTEGER NOT NULL,
|
||||
task_payload_sha256 TEXT NOT NULL,
|
||||
canonical_revision INTEGER NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
created_new_row INTEGER NOT NULL,
|
||||
observed_at TEXT NOT NULL,
|
||||
receipt_json TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def persist_intent(
|
||||
self,
|
||||
intent: CanonicalTaskProjectionIntent,
|
||||
) -> dict[str, Any]:
|
||||
now = _now()
|
||||
request_sha = intent.request_sha256
|
||||
intent_text = _canonical(intent.public()).decode("utf-8")
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._ensure_control_tables(connection)
|
||||
existing = connection.execute(
|
||||
"""
|
||||
SELECT * FROM task_projection_intents
|
||||
WHERE task_id=? AND projection_generation=?
|
||||
""",
|
||||
(intent.task.task_id, intent.projection_generation),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if (
|
||||
str(existing["projection_id"]) != intent.projection_id
|
||||
or str(existing["idempotency_key"]) != intent.idempotency_key
|
||||
or str(existing["request_sha256"]) != request_sha
|
||||
):
|
||||
raise ProjectionRecoveryError(
|
||||
"PROJECTION_INTENT_IDEMPOTENCY_CONFLICT",
|
||||
409,
|
||||
)
|
||||
connection.commit()
|
||||
return {
|
||||
"projection_id": intent.projection_id,
|
||||
"task_id": intent.task.task_id,
|
||||
"state": str(existing["state"]),
|
||||
"idempotent": True,
|
||||
}
|
||||
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO task_projection_intents(
|
||||
projection_id,task_id,bridge_job_id,projection_generation,
|
||||
idempotency_key,request_sha256,task_payload_sha256,intent_json,
|
||||
state,last_error,created_at,updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
intent.projection_id,
|
||||
intent.task.task_id,
|
||||
intent.binding.bridge_job_id,
|
||||
intent.projection_generation,
|
||||
intent.idempotency_key,
|
||||
request_sha,
|
||||
intent.task.payload_sha256,
|
||||
intent_text,
|
||||
ProjectionState.PENDING.value,
|
||||
None,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
return {
|
||||
"projection_id": intent.projection_id,
|
||||
"task_id": intent.task.task_id,
|
||||
"state": ProjectionState.PENDING.value,
|
||||
"idempotent": False,
|
||||
}
|
||||
|
||||
def _task_row_payload(
|
||||
self,
|
||||
row: sqlite3.Row,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
success = json.loads(row["success_criteria_json"])
|
||||
constraints = json.loads(row["constraints_json"])
|
||||
capabilities = json.loads(row["required_capabilities_json"])
|
||||
metadata = json.loads(row["metadata_json"])
|
||||
except (TypeError, json.JSONDecodeError) as exc:
|
||||
raise ProjectionRecoveryError("CANONICAL_TASK_JSON_INVALID", 409) from exc
|
||||
|
||||
if (
|
||||
not isinstance(success, list)
|
||||
or not isinstance(constraints, Mapping)
|
||||
or not isinstance(capabilities, list)
|
||||
or not isinstance(metadata, Mapping)
|
||||
):
|
||||
raise ProjectionRecoveryError("CANONICAL_TASK_SHAPE_INVALID", 409)
|
||||
|
||||
return {
|
||||
"task_id": str(row["task_id"]),
|
||||
"goal": str(row["goal"]),
|
||||
"instructions": str(row["instructions"]),
|
||||
"success_criteria": success,
|
||||
"constraints": dict(constraints),
|
||||
"required_capabilities": capabilities,
|
||||
"metadata": dict(metadata),
|
||||
"priority": int(row["priority"]),
|
||||
"max_attempts": int(row["max_attempts"]),
|
||||
"deadline": row["deadline"],
|
||||
}
|
||||
|
||||
def _verify_existing_task(
|
||||
self,
|
||||
row: sqlite3.Row,
|
||||
intent: CanonicalTaskProjectionIntent,
|
||||
) -> int:
|
||||
observed = self._task_row_payload(row)
|
||||
observed_hash = sha256_json(observed)
|
||||
if observed_hash != intent.task.payload_sha256:
|
||||
raise ProjectionRecoveryError(
|
||||
"CANONICAL_TASK_PROJECTION_IDENTITY_CONFLICT",
|
||||
409,
|
||||
)
|
||||
return int(row["revision"])
|
||||
|
||||
@staticmethod
|
||||
def _required_task_columns() -> set[str]:
|
||||
return {
|
||||
"task_id",
|
||||
"goal",
|
||||
"instructions",
|
||||
"success_criteria_json",
|
||||
"required_capabilities_json",
|
||||
"constraints_json",
|
||||
"metadata_json",
|
||||
"priority",
|
||||
"max_attempts",
|
||||
"attempt",
|
||||
"status",
|
||||
"claimed_by",
|
||||
"lease_token_hash",
|
||||
"lease_expires_at",
|
||||
"result_json",
|
||||
"verification_json",
|
||||
"revision",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
}
|
||||
|
||||
def _insert_task(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
intent: CanonicalTaskProjectionIntent,
|
||||
) -> int:
|
||||
columns = self._columns(connection, "tasks")
|
||||
if not self._required_task_columns().issubset(columns):
|
||||
raise ProjectionRecoveryError("TASK_SCHEMA_RECOVERY_UNSUPPORTED", 503)
|
||||
|
||||
now = _now()
|
||||
values: dict[str, Any] = {
|
||||
"task_id": intent.task.task_id,
|
||||
"goal": intent.task.goal,
|
||||
"instructions": intent.task.instructions,
|
||||
"success_criteria_json": _canonical(
|
||||
[dict(x) for x in intent.task.success_criteria]
|
||||
).decode("utf-8"),
|
||||
"required_capabilities_json": "[]",
|
||||
"constraints_json": _canonical(dict(intent.task.constraints)).decode("utf-8"),
|
||||
"metadata_json": _canonical(dict(intent.task.metadata)).decode("utf-8"),
|
||||
"deadline": intent.task.deadline,
|
||||
"priority": intent.task.priority,
|
||||
"max_attempts": intent.task.max_attempts,
|
||||
"attempt": 0,
|
||||
"status": "QUEUED",
|
||||
"claimed_by": None,
|
||||
"lease_token_hash": None,
|
||||
"lease_expires_at": None,
|
||||
"result_json": None,
|
||||
"verification_json": None,
|
||||
"revision": 1,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
for optional in (
|
||||
"result_digest",
|
||||
"result_idempotency_key",
|
||||
):
|
||||
if optional in columns:
|
||||
values[optional] = None
|
||||
|
||||
names = [name for name in values if name in columns]
|
||||
placeholders = ",".join("?" for _ in names)
|
||||
connection.execute(
|
||||
f'INSERT INTO tasks ({",".join(chr(34)+n+chr(34) for n in names)}) '
|
||||
f"VALUES ({placeholders})",
|
||||
tuple(values[name] for name in names),
|
||||
)
|
||||
self._insert_task_created_event(connection, intent)
|
||||
return 1
|
||||
|
||||
def _insert_task_created_event(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
intent: CanonicalTaskProjectionIntent,
|
||||
) -> None:
|
||||
table, columns = self._event_table(connection)
|
||||
event = {
|
||||
"schema": "aios.task-projection-recovery.event.v1",
|
||||
"task_id": intent.task.task_id,
|
||||
"bridge_job_id": intent.binding.bridge_job_id,
|
||||
"projection_id": intent.projection_id,
|
||||
"projection_generation": intent.projection_generation,
|
||||
"task_payload_sha256": intent.task.payload_sha256,
|
||||
"source_artifact_ref": intent.source_artifact.ref,
|
||||
"source_artifact_sha256": intent.source_artifact.sha256,
|
||||
"recovery": True,
|
||||
}
|
||||
values: dict[str, Any] = {
|
||||
"task_id": intent.task.task_id,
|
||||
"event_type": "TASK_CREATED",
|
||||
}
|
||||
payload_text = _canonical(event).decode("utf-8")
|
||||
if "payload_json" in columns:
|
||||
values["payload_json"] = payload_text
|
||||
elif "payload" in columns:
|
||||
values["payload"] = payload_text
|
||||
else:
|
||||
raise ProjectionRecoveryError("TASK_EVENT_PAYLOAD_UNSUPPORTED", 503)
|
||||
if "created_at" in columns:
|
||||
values["created_at"] = _now()
|
||||
if "agent_id" in columns:
|
||||
values["agent_id"] = "control-plane-projection-recovery"
|
||||
if "actor_id" in columns:
|
||||
values["actor_id"] = "control-plane-projection-recovery"
|
||||
|
||||
names = list(values)
|
||||
connection.execute(
|
||||
f'INSERT INTO "{table}" ({",".join(chr(34)+n+chr(34) for n in names)}) '
|
||||
f'VALUES ({",".join("?" for _ in names)})',
|
||||
tuple(values[name] for name in names),
|
||||
)
|
||||
|
||||
def reconcile(
|
||||
self,
|
||||
intent: CanonicalTaskProjectionIntent,
|
||||
) -> dict[str, Any]:
|
||||
# Persist first so a crash before Task creation leaves a durable PENDING
|
||||
# intent for startup/supervisor reconciliation.
|
||||
self.persist_intent(intent)
|
||||
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._ensure_control_tables(connection)
|
||||
existing_receipt = connection.execute(
|
||||
"SELECT * FROM task_projection_receipts WHERE projection_id=?",
|
||||
(intent.projection_id,),
|
||||
).fetchone()
|
||||
if existing_receipt is not None:
|
||||
if (
|
||||
str(existing_receipt["task_id"]) != intent.task.task_id
|
||||
or str(existing_receipt["bridge_job_id"]) != intent.binding.bridge_job_id
|
||||
or str(existing_receipt["task_payload_sha256"])
|
||||
!= intent.task.payload_sha256
|
||||
):
|
||||
raise ProjectionRecoveryError(
|
||||
"PROJECTION_RECEIPT_IDENTITY_CONFLICT",
|
||||
409,
|
||||
)
|
||||
connection.commit()
|
||||
receipt = json.loads(existing_receipt["receipt_json"])
|
||||
receipt["idempotent"] = True
|
||||
return receipt
|
||||
|
||||
row = connection.execute(
|
||||
"SELECT * FROM tasks WHERE task_id=?",
|
||||
(intent.task.task_id,),
|
||||
).fetchone()
|
||||
created_new = False
|
||||
if row is None:
|
||||
revision = self._insert_task(connection, intent)
|
||||
created_new = True
|
||||
row = connection.execute(
|
||||
"SELECT * FROM tasks WHERE task_id=?",
|
||||
(intent.task.task_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ProjectionRecoveryError(
|
||||
"TASK_RECOVERY_OBSERVATION_FAILED",
|
||||
503,
|
||||
)
|
||||
else:
|
||||
revision = self._verify_existing_task(row, intent)
|
||||
|
||||
# Observation after mutation is mandatory.
|
||||
revision = self._verify_existing_task(row, intent)
|
||||
observed_at = _now()
|
||||
receipt = {
|
||||
"schema": RECEIPT_SCHEMA,
|
||||
"projection_id": intent.projection_id,
|
||||
"governed_task_id": intent.binding.governed_task_id,
|
||||
"canonical_task_id": intent.task.task_id,
|
||||
"bridge_job_id": intent.binding.bridge_job_id,
|
||||
"projection_generation": intent.projection_generation,
|
||||
"task_payload_sha256": intent.task.payload_sha256,
|
||||
"canonical_revision": revision,
|
||||
"state": ProjectionState.CONVERGED.value,
|
||||
"created_new_row": created_new,
|
||||
"source_artifact": asdict(intent.source_artifact),
|
||||
"observed_at": observed_at,
|
||||
"idempotent": False,
|
||||
}
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO task_projection_receipts(
|
||||
projection_id,task_id,bridge_job_id,projection_generation,
|
||||
task_payload_sha256,canonical_revision,state,created_new_row,
|
||||
observed_at,receipt_json
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
intent.projection_id,
|
||||
intent.task.task_id,
|
||||
intent.binding.bridge_job_id,
|
||||
intent.projection_generation,
|
||||
intent.task.payload_sha256,
|
||||
revision,
|
||||
ProjectionState.CONVERGED.value,
|
||||
1 if created_new else 0,
|
||||
observed_at,
|
||||
_canonical(receipt).decode("utf-8"),
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE task_projection_intents
|
||||
SET state=?, last_error=NULL, updated_at=?
|
||||
WHERE projection_id=?
|
||||
""",
|
||||
(
|
||||
ProjectionState.CONVERGED.value,
|
||||
observed_at,
|
||||
intent.projection_id,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
return receipt
|
||||
|
||||
def get_projection(
|
||||
self,
|
||||
task_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
task_id = _string(task_id, "TASK_ID_INVALID", TASK_ID_RE)
|
||||
with self._connect() as connection:
|
||||
self._ensure_control_tables(connection)
|
||||
intent = connection.execute(
|
||||
"""
|
||||
SELECT * FROM task_projection_intents
|
||||
WHERE task_id=?
|
||||
ORDER BY projection_generation DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
if intent is None:
|
||||
return None
|
||||
receipt = connection.execute(
|
||||
"SELECT receipt_json FROM task_projection_receipts WHERE projection_id=?",
|
||||
(intent["projection_id"],),
|
||||
).fetchone()
|
||||
return {
|
||||
"projection_id": intent["projection_id"],
|
||||
"task_id": task_id,
|
||||
"bridge_job_id": intent["bridge_job_id"],
|
||||
"projection_generation": int(intent["projection_generation"]),
|
||||
"state": intent["state"],
|
||||
"last_error": intent["last_error"],
|
||||
"task_payload_sha256": intent["task_payload_sha256"],
|
||||
"receipt": json.loads(receipt[0]) if receipt else None,
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
"""HTTP integration helpers for canonical Task projection recovery.
|
||||
|
||||
This module intentionally contains no HTTP server. Existing Task Plane /
|
||||
projection-owner handlers should call ``recover_from_json`` only after their
|
||||
normal service-side ``agent-task:control`` authorization succeeds.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_agent_task_projection_recovery import (
|
||||
CanonicalTaskProjectionIntent,
|
||||
CanonicalTaskProjectionStore,
|
||||
ProjectionRecoveryError,
|
||||
)
|
||||
|
||||
|
||||
def recover_from_json(
|
||||
db_path: Path | str,
|
||||
task_id: str,
|
||||
body: Mapping[str, Any],
|
||||
) -> tuple[int, dict[str, Any]]:
|
||||
try:
|
||||
intent = CanonicalTaskProjectionIntent.from_mapping(body)
|
||||
if intent.task.task_id != task_id:
|
||||
raise ProjectionRecoveryError("PROJECTION_TASK_IDENTITY_MISMATCH", 409)
|
||||
result = CanonicalTaskProjectionStore(db_path).reconcile(intent)
|
||||
return (200 if result.get("idempotent") else 201), result
|
||||
except ProjectionRecoveryError as exc:
|
||||
return exc.status, {"error": exc.code}
|
||||
|
||||
|
||||
def get_projection(
|
||||
db_path: Path | str,
|
||||
task_id: str,
|
||||
) -> tuple[int, dict[str, Any]]:
|
||||
try:
|
||||
result = CanonicalTaskProjectionStore(db_path).get_projection(task_id)
|
||||
except ProjectionRecoveryError as exc:
|
||||
return exc.status, {"error": exc.code}
|
||||
if result is None:
|
||||
return 404, {"error": "TASK_PROJECTION_NOT_FOUND"}
|
||||
return 200, result
|
||||
@@ -9,6 +9,8 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
import base64
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
@@ -58,6 +60,82 @@ class AuthorityGitExecutor:
|
||||
value = self._git(["rev-parse", "--verify", ref], check=False)
|
||||
return value if value and OID_RE.fullmatch(value) else None
|
||||
|
||||
@staticmethod
|
||||
def _read_path(path: str) -> str:
|
||||
if not isinstance(path, str) or not path or len(path) > 512:
|
||||
raise AuthorityGitError("SOURCE_PATH_INVALID")
|
||||
if "\\" in path or "\x00" in path:
|
||||
raise AuthorityGitError("SOURCE_PATH_INVALID")
|
||||
parts = Path(path).parts
|
||||
if not parts or path.startswith("/") or ".." in parts or ".git" in parts:
|
||||
raise AuthorityGitError("SOURCE_PATH_INVALID")
|
||||
return "/".join(parts)
|
||||
|
||||
def _read_revision(self, ref: str) -> str:
|
||||
if not isinstance(ref, str) or not ref or len(ref) > 256 or "\x00" in ref:
|
||||
raise AuthorityGitError("SOURCE_REF_INVALID")
|
||||
if OID_RE.fullmatch(ref):
|
||||
if not self.commit_exists(ref):
|
||||
raise AuthorityGitError("SOURCE_REF_NOT_FOUND")
|
||||
return ref
|
||||
if ref == "main":
|
||||
ref = "refs/heads/main"
|
||||
elif ref.startswith("agent/"):
|
||||
ref = "refs/heads/" + ref
|
||||
elif ref.startswith("refs/heads/agent/"):
|
||||
pass
|
||||
elif ref == "V2R2C1":
|
||||
ref = "refs/tags/V2R2C1"
|
||||
elif ref.startswith("refs/tags/V"):
|
||||
pass
|
||||
else:
|
||||
raise AuthorityGitError("SOURCE_REF_SCOPE_DENIED")
|
||||
if ".." in ref or not re.fullmatch(r"refs/(heads/(?:agent/[A-Za-z0-9._/-]+|main)|tags/V[0-9]+R[0-9]+C[0-9]+)", ref):
|
||||
raise AuthorityGitError("SOURCE_REF_SCOPE_DENIED")
|
||||
value = self._git(["rev-parse", "--verify", ref + "^{commit}"], check=False)
|
||||
if not OID_RE.fullmatch(value):
|
||||
raise AuthorityGitError("SOURCE_REF_NOT_FOUND")
|
||||
return value
|
||||
|
||||
def read_file(self, ref: str, path: str, max_bytes: int) -> dict[str, object]:
|
||||
if not isinstance(max_bytes, int) or isinstance(max_bytes, bool) or not 1 <= max_bytes <= 262_144:
|
||||
raise AuthorityGitError("SOURCE_MAX_BYTES_INVALID")
|
||||
revision = self._read_revision(ref)
|
||||
relative = self._read_path(path)
|
||||
spec = f"{revision}:{relative}"
|
||||
size_result = subprocess.run(
|
||||
["git", f"--git-dir={self.git_dir}", "cat-file", "-s", spec],
|
||||
text=True, capture_output=True, check=False,
|
||||
)
|
||||
if size_result.returncode != 0:
|
||||
raise AuthorityGitError("SOURCE_FILE_NOT_FOUND")
|
||||
try:
|
||||
size = int(size_result.stdout.strip())
|
||||
except ValueError as exc:
|
||||
raise AuthorityGitError("SOURCE_FILE_READ_FAILED") from exc
|
||||
if size > max_bytes:
|
||||
raise AuthorityGitError("SOURCE_FILE_TOO_LARGE")
|
||||
result = subprocess.run(
|
||||
["git", f"--git-dir={self.git_dir}", "show", spec],
|
||||
capture_output=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise AuthorityGitError("SOURCE_FILE_NOT_FOUND")
|
||||
content = result.stdout
|
||||
if len(content) != size:
|
||||
raise AuthorityGitError("SOURCE_FILE_READ_FAILED")
|
||||
return {
|
||||
"repository": "aios/aios",
|
||||
"ref": ref,
|
||||
"resolved_commit_oid": revision,
|
||||
"path": relative,
|
||||
"size_bytes": size,
|
||||
"sha256": hashlib.sha256(content).hexdigest(),
|
||||
"content_base64": base64.b64encode(content).decode("ascii"),
|
||||
"truncated": False,
|
||||
"read_only": True,
|
||||
}
|
||||
|
||||
def commit_exists(self, oid: str) -> bool:
|
||||
_oid(oid, "commit_oid")
|
||||
result = subprocess.run(["git", f"--git-dir={self.git_dir}", "cat-file", "-e", oid + "^{commit}"], capture_output=True, check=False)
|
||||
|
||||
@@ -11,8 +11,8 @@ BRIDGE_SCHEMA_VERSION = "aios.chatgpt-gitops-bridge.v2"
|
||||
BRIDGE_OPERATIONS = (
|
||||
"getRuntimeStatus",
|
||||
"getGovernedWriteCapabilities",
|
||||
"readRepositoryFile",
|
||||
"createGovernedTask",
|
||||
"projectOnlineAgentExecution",
|
||||
"acquireWriterLease",
|
||||
"commitFileGoverned",
|
||||
"commitFilesGoverned",
|
||||
@@ -31,7 +31,6 @@ BRIDGE_OPERATIONS = (
|
||||
|
||||
OPERATION_REQUIRED_FIELDS = {
|
||||
"createGovernedTask": ("task_id", "repository", "base_oid", "target_branch", "risk", "description", "idempotency_key"),
|
||||
"projectOnlineAgentExecution": ("task_id", "required_capabilities"),
|
||||
"acquireWriterLease": ("schema", "task_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "lease_seconds"),
|
||||
"commitFileGoverned": ("task_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "path", "content_base64", "message"),
|
||||
"commitFilesGoverned": ("task_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "files", "message"),
|
||||
@@ -46,11 +45,11 @@ OPERATION_REQUIRED_FIELDS = {
|
||||
"getReleaseConsistency": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id"),
|
||||
"runReleaseAcceptance": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id"),
|
||||
"getControlPlaneSnapshot": (),
|
||||
"readRepositoryFile": ("repository", "ref", "path", "max_bytes"),
|
||||
}
|
||||
|
||||
_OPERATION_PATHS = {
|
||||
"createGovernedTask": "/v2/governed-task",
|
||||
"projectOnlineAgentExecution": "/v2/execution-envelope",
|
||||
"acquireWriterLease": "/v2/writer-lease",
|
||||
"commitFileGoverned": "/v2/commit-file",
|
||||
"commitFilesGoverned": "/v2/commit-files",
|
||||
@@ -60,6 +59,7 @@ _OPERATION_PATHS = {
|
||||
"getTagPublicationStatus": "/v2/tag/status",
|
||||
"getReleaseConsistency": "/v2/release/consistency",
|
||||
"runReleaseAcceptance": "/v2/release/acceptance",
|
||||
"readRepositoryFile": "/v2/source/file",
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ def render_openapi_contract_fragment() -> str:
|
||||
" post:\n"
|
||||
" operationId: commitFilesGoverned\n"
|
||||
" summary: Submit one atomic governed commit containing multiple files\n"
|
||||
" security: [{bearerAuth: []}]\n"
|
||||
" requestBody:\n"
|
||||
" required: true\n"
|
||||
" content:\n"
|
||||
@@ -96,16 +97,15 @@ def render_openapi_contract_fragment() -> str:
|
||||
f"{required}\n"
|
||||
" properties:\n"
|
||||
" task_id: {type: string}\n"
|
||||
" job_id: {type: string, description: External durable Job identity for exact completed-replay reconciliation}\n"
|
||||
" source_lease_epoch: {type: integer, minimum: 1}\n"
|
||||
" repository: {type: string, example: aios/aios}\n"
|
||||
" expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}\n"
|
||||
" target_branch: {type: string, pattern: '^agent/[A-Za-z0-9._/-]+$'}\n"
|
||||
" generation: {type: integer, minimum: 1}\n"
|
||||
" idempotency_key: {type: string}\n"
|
||||
" idempotency_key: {type: string, minLength: 8, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$'}\n"
|
||||
" files:\n"
|
||||
" type: array\n"
|
||||
" minItems: 1\n"
|
||||
" maxItems: 50\n"
|
||||
" items:\n"
|
||||
" type: object\n"
|
||||
" additionalProperties: false\n"
|
||||
@@ -118,6 +118,7 @@ def render_openapi_contract_fragment() -> str:
|
||||
" '200': {description: Governed commit job and verified branch result}\n"
|
||||
" '400': {description: Contract or policy rejection}\n"
|
||||
" '409': {description: Branch CAS or idempotency conflict}\n"
|
||||
" x-openai-isConsequential: true\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -225,10 +226,10 @@ class BridgeV2Adapter:
|
||||
return handler(dict(request))
|
||||
|
||||
def createGovernedTask(self, request: Mapping[str, Any]) -> Any: return self._call("createGovernedTask", request)
|
||||
def projectOnlineAgentExecution(self, request: Mapping[str, Any]) -> Any: return self._call("projectOnlineAgentExecution", request)
|
||||
def acquireWriterLease(self, request: Mapping[str, Any]) -> Any: return self._call("acquireWriterLease", request)
|
||||
def commitFileGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("commitFileGoverned", request)
|
||||
def commitFilesGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("commitFilesGoverned", request)
|
||||
def readRepositoryFile(self, request: Mapping[str, Any]) -> Any: return self._call("readRepositoryFile", request)
|
||||
def getGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("getGitOpsJob", request)
|
||||
def resumeGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("resumeGitOpsJob", request)
|
||||
def reconcileGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("reconcileGitOpsJob", request)
|
||||
|
||||
@@ -5,6 +5,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
@@ -18,20 +19,22 @@ from aios_chatgpt_bridge_v2 import BridgeV2Adapter, BridgeV2Error, OPERATION_REQ
|
||||
from gitea_tag_publication import validate_gitea_api_url
|
||||
from aios_continuation_executor import AuthorityGitAdapter, ContinuationCommit, ContinuationExecutor, ContinuationPermissions, ContinuationRuntimeBinding, DetachedGitWorkspace, GiteaContinuationRemote, ServerSideCredentialProjection
|
||||
from aios_continuation_lease import WriterLeaseRequestV2
|
||||
from scoped_lease_store import ScopedLeaseError, ScopedLeaseStore
|
||||
from aios_control_plane_snapshot import build_control_plane_snapshot
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobState, JobStore, _RECOVERABLE, sha256_json
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobState, JobStore, _RECOVERABLE
|
||||
from aios_governed_task_bootstrap import BootstrapError, GovernedTaskBootstrap
|
||||
from governed_production_release import build_executor_config
|
||||
|
||||
MAX_BODY = 2 * 1024 * 1024
|
||||
MAX_SOURCE_BYTES = 262_144
|
||||
ROUTES = {
|
||||
"/v2/runtime": "getRuntimeStatus",
|
||||
"/v2/capabilities": "getGovernedWriteCapabilities",
|
||||
"/v2/governed-task": "createGovernedTask",
|
||||
"/v2/execution-envelope": "projectOnlineAgentExecution",
|
||||
"/v2/writer-lease": "acquireWriterLease",
|
||||
"/v2/commit-file": "commitFileGoverned",
|
||||
"/v2/commit-files": "commitFilesGoverned",
|
||||
"/v2/source/file": "readRepositoryFile",
|
||||
"/v2/job": "getGitOpsJob",
|
||||
"/v2/job/resume": "resumeGitOpsJob",
|
||||
"/v2/job/reconcile": "reconcileGitOpsJob",
|
||||
@@ -62,7 +65,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"
|
||||
release_plan_path: Path | None = None
|
||||
|
||||
|
||||
def _safe(value: str) -> str:
|
||||
@@ -78,6 +80,7 @@ class GovernedRuntime:
|
||||
self.runtimes: dict[tuple[str, int], tuple[Any, GitOpsJobController, ContinuationExecutor]] = {}
|
||||
self.controllers: dict[str, GitOpsJobController] = {}
|
||||
self.bindings: dict[str, dict[str, Any]] = {}
|
||||
self.leases = ScopedLeaseStore(config.state_root / "scoped-leases.sqlite3")
|
||||
self.release = build_executor_config(config)
|
||||
metadata_root = config.state_root / "task-bindings"
|
||||
if metadata_root.is_dir():
|
||||
@@ -152,36 +155,102 @@ class GovernedRuntime:
|
||||
workspace = DetachedGitWorkspace(worktree_path)
|
||||
if workspace.head_oid() != lease.expected_branch_oid or not workspace.is_detached(): raise RuntimeError("WORKTREE_HEAD_MISMATCH")
|
||||
job_root = self._job_root(lease.task_id); controller = GitOpsJobController(JobStore(job_root, payload_root=job_root / "payloads")); credential = ServerSideCredentialProjection(self.config.ssh_key_path, self.config.token_path); remote = GiteaContinuationRemote(workspace=worktree_path, credential=credential, remote_name=self.config.remote_name, repository=lease.repository, api_base=self.config.api_base); executor = ContinuationExecutor(controller, AuthorityGitAdapter(self.authority), workspace, remote, credential)
|
||||
binding = ContinuationRuntimeBinding(request=lease, workspace_path=worktree_path, lease_not_before=datetime.now(timezone.utc) - timedelta(seconds=1), lease_expires_at=datetime.now(timezone.utc) + timedelta(seconds=lease.lease_seconds), original_receipt_valid=True, original_grant_consumed=True, permissions=ContinuationPermissions(), remote_branch_create_allowed=original_branch_mode == "CREATE_NEW", external_job_id=str(request.get("job_id") or "") or None)
|
||||
binding = ContinuationRuntimeBinding(request=lease, workspace_path=worktree_path, lease_not_before=datetime.now(timezone.utc) - timedelta(seconds=1), lease_expires_at=datetime.now(timezone.utc) + timedelta(seconds=lease.lease_seconds), original_receipt_valid=True, original_grant_consumed=True, permissions=ContinuationPermissions(), remote_branch_create_allowed=original_branch_mode == "CREATE_NEW")
|
||||
result = (binding, controller, executor); self.runtimes[key] = result; self.controllers[lease.task_id] = controller; return result
|
||||
|
||||
def _validate_task_binding(self, request: Mapping[str, Any]) -> None:
|
||||
stored = self.bindings.get(str(request.get("task_id")))
|
||||
if not stored:
|
||||
raise RuntimeError("TASK_BINDING_NOT_FOUND")
|
||||
if (
|
||||
str(request.get("repository")) != str(stored.get("repository"))
|
||||
or str(request.get("target_branch")) != str(stored.get("target_branch"))
|
||||
or str(request.get("base_oid")) != str(stored.get("base_oid"))
|
||||
):
|
||||
raise RuntimeError("TASK_LEASE_BINDING_CONFLICT")
|
||||
if str(request.get("branch_mode")) != "CONTINUE_EXISTING":
|
||||
raise RuntimeError("TASK_BRANCH_MODE_INVALID")
|
||||
try:
|
||||
generation = int(request.get("generation"))
|
||||
stored_generation = int(stored.get("generation") or 1)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RuntimeError("TASK_LEASE_BINDING_CONFLICT") from exc
|
||||
if generation < stored_generation:
|
||||
raise RuntimeError("TASK_GENERATION_STALE")
|
||||
current = self.authority.read_ref("refs/heads/" + str(request["target_branch"]))
|
||||
if current != str(request.get("expected_branch_oid")):
|
||||
raise RuntimeError("LEASE_EXPECTED_BRANCH_OID_STALE")
|
||||
if not self.authority.ancestry_check(str(stored.get("expected_branch_oid")), str(request["expected_branch_oid"])):
|
||||
raise RuntimeError("TASK_LEASE_LINEAGE_INVALID")
|
||||
|
||||
def create_task(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
result = self.bootstrap.create(request); self.bindings[result["task_id"]] = result; return result
|
||||
|
||||
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"])
|
||||
expected_job_id = str(request.get("job_id") or "") or None
|
||||
return self.bootstrap.execution_envelope(
|
||||
task_id,
|
||||
execution,
|
||||
required_capabilities=tuple(capabilities),
|
||||
expected_job_id=expected_job_id,
|
||||
)
|
||||
result = self.bootstrap.create(request); self.bindings[result["task_id"]] = result; return result
|
||||
|
||||
def acquire_lease(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
replay = self._durable_completed_replay_lease(request)
|
||||
if replay is not None:
|
||||
return replay
|
||||
binding, _, _ = self._runtime(request)
|
||||
if os.environ.get("AIOS_GITOPS_WRITES_ENABLED", "false").lower() not in {"1", "true", "yes", "on"}:
|
||||
raise RuntimeError("GITOPS_WRITES_DISABLED")
|
||||
self._validate_task_binding(request)
|
||||
try:
|
||||
record, created = self.leases.acquire(request)
|
||||
except ScopedLeaseError:
|
||||
raise
|
||||
if not created:
|
||||
if record["state"] == "EXPIRED":
|
||||
raise RuntimeError("LEASE_EXPIRED")
|
||||
record = {**record, "created": False}
|
||||
return self._lease_response(record["binding"], record)
|
||||
try:
|
||||
binding, _, _ = self._runtime(request)
|
||||
except Exception:
|
||||
# The lease cannot be usable if its detached runtime could not be
|
||||
# established. Leave no durable orphan behind.
|
||||
self.leases.delete(record["lease_key"])
|
||||
raise
|
||||
req = binding.request
|
||||
return {"schema": WriterLeaseRequestV2.schema, "task_id": req.task_id, "repository": req.repository, "branch_mode": req.branch_mode, "base_oid": req.base_oid, "expected_branch_oid": req.expected_branch_oid, "target_branch": req.target_branch, "generation": req.generation, "lease_seconds": req.lease_seconds, "create_target_branch": req.branch_mode == "CREATE_NEW", "worktree_mode": "detached_at_base_oid" if req.branch_mode == "CREATE_NEW" else "detached_at_expected_head", "cas_ref_update_required": True}
|
||||
return self._lease_response(
|
||||
{
|
||||
"schema": WriterLeaseRequestV2.schema,
|
||||
"task_id": req.task_id,
|
||||
"repository": req.repository,
|
||||
"branch_mode": req.branch_mode,
|
||||
"base_oid": req.base_oid,
|
||||
"expected_branch_oid": req.expected_branch_oid,
|
||||
"target_branch": req.target_branch,
|
||||
"generation": req.generation,
|
||||
"lease_seconds": req.lease_seconds,
|
||||
},
|
||||
{**record, "created": True},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _lease_response(request: Mapping[str, Any], record: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": WriterLeaseRequestV2.schema,
|
||||
"task_id": request["task_id"],
|
||||
"repository": request["repository"],
|
||||
"branch_mode": request["branch_mode"],
|
||||
"base_oid": request["base_oid"],
|
||||
"expected_branch_oid": request["expected_branch_oid"],
|
||||
"target_branch": request["target_branch"],
|
||||
"generation": int(request["generation"]),
|
||||
"lease_seconds": int(request["lease_seconds"]),
|
||||
"lease_key": record["lease_key"],
|
||||
"lease_state": record["state"],
|
||||
"lease_expires_at": record["expires_at"],
|
||||
"idempotent": not bool(record.get("created", True)),
|
||||
"create_target_branch": request["branch_mode"] == "CREATE_NEW",
|
||||
"worktree_mode": "detached_at_base_oid" if request["branch_mode"] == "CREATE_NEW" else "detached_at_expected_head",
|
||||
"cas_ref_update_required": True,
|
||||
}
|
||||
|
||||
def read_source_file(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
repository = str(request.get("repository") or "")
|
||||
if repository != self.config.repository:
|
||||
raise RuntimeError("REPOSITORY_NOT_ALLOWED")
|
||||
max_bytes = request.get("max_bytes")
|
||||
if not isinstance(max_bytes, int) or isinstance(max_bytes, bool) or not 1 <= max_bytes <= MAX_SOURCE_BYTES:
|
||||
raise RuntimeError("SOURCE_MAX_BYTES_INVALID")
|
||||
return self.authority.read_file(str(request.get("ref") or ""), str(request.get("path") or ""), max_bytes)
|
||||
|
||||
@staticmethod
|
||||
def _files(request: Mapping[str, Any]) -> dict[str, bytes]:
|
||||
@@ -190,326 +259,29 @@ class GovernedRuntime:
|
||||
return files
|
||||
|
||||
def commit_files(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
replay = self._durable_completed_replay(request)
|
||||
if replay is not None:
|
||||
return replay
|
||||
if os.environ.get("AIOS_GITOPS_WRITES_ENABLED", "false").lower() not in {"1", "true", "yes", "on"}:
|
||||
raise RuntimeError("GITOPS_WRITES_DISABLED")
|
||||
self._validate_task_binding(request)
|
||||
record = self.leases.for_commit(request)
|
||||
if record is None:
|
||||
raise RuntimeError("LEASE_NOT_FOUND")
|
||||
if record["state"] == "EXPIRED":
|
||||
raise RuntimeError("LEASE_EXPIRED")
|
||||
if record["state"] == "USED":
|
||||
if record.get("idempotency_key") == str(request["idempotency_key"]):
|
||||
return dict(record["result"])
|
||||
raise RuntimeError("LEASE_ALREADY_USED")
|
||||
runtime = self.runtimes.get((str(request["task_id"]), int(request["generation"])))
|
||||
if runtime is None: raise RuntimeError("LEASE_NOT_FOUND")
|
||||
if runtime is None:
|
||||
runtime = self._runtime(record["binding"])
|
||||
binding, _, executor = runtime
|
||||
if request["target_branch"] != binding.request.target_branch or request["expected_branch_oid"] != binding.request.expected_branch_oid: raise RuntimeError("LEASE_BINDING_CONFLICT")
|
||||
if request["repository"] != binding.request.repository or request["target_branch"] != binding.request.target_branch or request["expected_branch_oid"] != binding.request.expected_branch_oid:
|
||||
raise RuntimeError("LEASE_BINDING_CONFLICT")
|
||||
commit = ContinuationCommit(idempotency_key=str(request["idempotency_key"]), files=self._files(request), message=str(request["message"]))
|
||||
executor.workspace.materialize_files(commit.files)
|
||||
return executor.execute(binding, commit)
|
||||
|
||||
def _find_completed_replay_job(self, external_job_id: str) -> tuple[Path, dict[str, Any]] | None:
|
||||
matches: list[tuple[Path, dict[str, Any]]] = []
|
||||
roots = [self.config.state_root / "jobs"]
|
||||
for controller in self.controllers.values():
|
||||
roots.append(controller.store.root.parent)
|
||||
seen: set[str] = set()
|
||||
for jobs_root in roots:
|
||||
if not jobs_root.is_dir():
|
||||
continue
|
||||
candidates = []
|
||||
if (jobs_root / "state.sqlite3").is_file():
|
||||
candidates.append(jobs_root)
|
||||
candidates.extend(root for root in jobs_root.iterdir() if root.is_dir())
|
||||
for root in candidates:
|
||||
db = root / "state.sqlite3"
|
||||
key = str(db.resolve()) if db.exists() else str(db)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
if not db.is_file() or db.is_symlink():
|
||||
continue
|
||||
try:
|
||||
connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
rows = connection.execute("SELECT * FROM jobs").fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
for raw in rows:
|
||||
row = dict(raw)
|
||||
durable = json.loads(row["request_json"])
|
||||
binding = durable.get("binding", durable)
|
||||
if str(binding.get("external_job_id") or "") == external_job_id:
|
||||
matches.append((root, row))
|
||||
except (OSError, ValueError, TypeError, sqlite3.Error):
|
||||
continue
|
||||
if not matches:
|
||||
return None
|
||||
if len(matches) != 1:
|
||||
raise RuntimeError("EXTERNAL_JOB_MAPPING_AMBIGUOUS")
|
||||
return matches[0]
|
||||
|
||||
def _durable_completed_replay_lease(self, request: Mapping[str, Any]) -> dict[str, Any] | None:
|
||||
"""Admit an exact completed replay before ordinary lease validation."""
|
||||
external_job_id = str(request.get("job_id") or "").strip()
|
||||
if not external_job_id:
|
||||
if request.get("source_lease_epoch") is not None:
|
||||
raise RuntimeError("COMPLETED_REPLAY_EXTERNAL_JOB_ID_REQUIRED")
|
||||
return None
|
||||
match = self._find_completed_replay_job(external_job_id)
|
||||
if match is None:
|
||||
return None
|
||||
root, row = match
|
||||
if str(row["state"]) != JobState.COMPLETED.value:
|
||||
return None
|
||||
db = root / "state.sqlite3"
|
||||
connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
evidence_rows = connection.execute(
|
||||
"SELECT * FROM completed_replay_reconciliations WHERE job_id=? ORDER BY created_at ASC",
|
||||
(row["job_id"],),
|
||||
).fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
if not evidence_rows:
|
||||
return None
|
||||
evidence = dict(evidence_rows[0])
|
||||
immutable = tuple(key for key in evidence if key != "created_at")
|
||||
if any(any(item[key] != evidence[key] for key in immutable) for item in evidence_rows[1:]):
|
||||
raise RuntimeError("COMPLETED_REPLAY_AMBIGUOUS")
|
||||
durable = json.loads(row["request_json"])
|
||||
binding = durable.get("binding", durable)
|
||||
target_branch = str(binding.get("target_branch") or binding.get("branch") or "")
|
||||
for field, incoming in (
|
||||
("task_id", request.get("task_id")),
|
||||
("repository", request.get("repository")),
|
||||
("target_branch", request.get("target_branch")),
|
||||
("generation", request.get("generation")),
|
||||
("branch_mode", request.get("branch_mode")),
|
||||
):
|
||||
durable_value = target_branch if field == "target_branch" else binding.get(field)
|
||||
if str(durable_value) != str(incoming):
|
||||
raise RuntimeError("COMPLETED_REPLAY_BINDING_CONFLICT")
|
||||
historical_head = str(evidence.get("historical_lease_head_oid") or "")
|
||||
if str(request.get("base_oid") or "") != historical_head or str(request.get("expected_branch_oid") or "") != historical_head:
|
||||
raise RuntimeError("COMPLETED_REPLAY_EXPECTED_HEAD_CONFLICT")
|
||||
if request.get("source_lease_epoch") is not None and int(request["source_lease_epoch"]) != int(evidence["source_lease_epoch"]):
|
||||
raise RuntimeError("COMPLETED_REPLAY_LEASE_EPOCH_CONFLICT")
|
||||
receipt = json.loads(row["receipt_json"] or "null")
|
||||
if not isinstance(receipt, Mapping) or receipt.get("commit_oid") != receipt.get("authority_oid"):
|
||||
raise RuntimeError("COMPLETED_REPLAY_RECEIPT_INVALID")
|
||||
authority_head = self.authority.read_ref("refs/heads/" + target_branch)
|
||||
if authority_head != evidence.get("current_head_oid"):
|
||||
raise RuntimeError("COMPLETED_REPLAY_AUTHORITY_CONFLICT")
|
||||
if not (
|
||||
evidence.get("commit_oid")
|
||||
== evidence.get("authority_head_oid")
|
||||
== evidence.get("current_head_oid")
|
||||
== receipt.get("commit_oid")
|
||||
and evidence.get("commit_oid") != historical_head
|
||||
):
|
||||
raise RuntimeError("COMPLETED_REPLAY_EVIDENCE_INCOHERENT")
|
||||
return {
|
||||
"schema": WriterLeaseRequestV2.schema,
|
||||
"task_id": request["task_id"],
|
||||
"job_id": external_job_id,
|
||||
"repository": request["repository"],
|
||||
"branch_mode": request["branch_mode"],
|
||||
"base_oid": request["base_oid"],
|
||||
"expected_branch_oid": request["expected_branch_oid"],
|
||||
"target_branch": request["target_branch"],
|
||||
"generation": int(request["generation"]),
|
||||
"lease_seconds": int(request.get("lease_seconds") or 900),
|
||||
"create_target_branch": False,
|
||||
"worktree_mode": "detached_at_expected_head",
|
||||
"cas_ref_update_required": True,
|
||||
"lease_created": False,
|
||||
"completed_replay": True,
|
||||
"current_head_oid": evidence["current_head_oid"],
|
||||
"completed_replay_reconciliation_id": evidence["reconciliation_id"],
|
||||
}
|
||||
|
||||
def _durable_completed_replay(self, request: Mapping[str, Any]) -> dict[str, Any] | None:
|
||||
"""Reconcile an exact completed replay before any worktree mutation."""
|
||||
external_job_id = str(request.get("job_id") or "").strip()
|
||||
if not external_job_id:
|
||||
if request.get("source_lease_epoch") is not None:
|
||||
raise RuntimeError("COMPLETED_REPLAY_EXTERNAL_JOB_ID_REQUIRED")
|
||||
return None
|
||||
matches: list[tuple[Path, dict[str, Any]]] = []
|
||||
roots = [self.config.state_root / "jobs"]
|
||||
for controller in self.controllers.values():
|
||||
roots.append(controller.store.root.parent)
|
||||
seen: set[str] = set()
|
||||
for jobs_root in roots:
|
||||
if not jobs_root.is_dir():
|
||||
continue
|
||||
candidates = []
|
||||
if (jobs_root / "state.sqlite3").is_file():
|
||||
candidates.append(jobs_root)
|
||||
candidates.extend(root for root in jobs_root.iterdir() if root.is_dir())
|
||||
for root in candidates:
|
||||
db = root / "state.sqlite3"
|
||||
key = str(db.resolve()) if db.exists() else str(db)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
if not db.is_file() or db.is_symlink():
|
||||
continue
|
||||
try:
|
||||
connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
rows = connection.execute("SELECT * FROM jobs").fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
for raw in rows:
|
||||
row = dict(raw)
|
||||
durable = json.loads(row["request_json"])
|
||||
binding = durable.get("binding", durable)
|
||||
if str(binding.get("external_job_id") or "") == external_job_id:
|
||||
matches.append((root, row))
|
||||
except (OSError, ValueError, TypeError, sqlite3.Error):
|
||||
continue
|
||||
if not matches:
|
||||
return None
|
||||
if len(matches) != 1:
|
||||
raise RuntimeError("EXTERNAL_JOB_MAPPING_AMBIGUOUS")
|
||||
root, row = matches[0]
|
||||
if str(row["state"]) != JobState.COMPLETED.value:
|
||||
return None
|
||||
durable = json.loads(row["request_json"])
|
||||
binding = durable.get("binding", durable)
|
||||
source_lease_epoch = request.get("source_lease_epoch")
|
||||
if source_lease_epoch is None:
|
||||
db = root / "state.sqlite3"
|
||||
connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
evidence = connection.execute(
|
||||
"SELECT source_lease_epoch FROM completed_replay_reconciliations WHERE job_id=? ORDER BY created_at ASC LIMIT 1",
|
||||
(row["job_id"],),
|
||||
).fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
if evidence is not None:
|
||||
source_lease_epoch = int(evidence["source_lease_epoch"])
|
||||
files = self._files(request)
|
||||
commit = ContinuationCommit(str(request["idempotency_key"]), files, str(request["message"]))
|
||||
payload = commit.payload()
|
||||
receipt = json.loads(row["receipt_json"] or "null")
|
||||
if not isinstance(receipt, Mapping):
|
||||
raise RuntimeError("COMPLETED_REPLAY_RECEIPT_INVALID")
|
||||
incoming = {
|
||||
"task_id": str(request.get("task_id") or ""),
|
||||
"external_job_id": external_job_id,
|
||||
"internal_job_id": str(row["job_id"]),
|
||||
"repository": str(request.get("repository") or ""),
|
||||
"target_branch": str(request.get("target_branch") or ""),
|
||||
"generation": int(request.get("generation") or 0),
|
||||
"branch_mode": str(request.get("branch_mode") or ""),
|
||||
"payload_digest": hashlib.sha256(payload).hexdigest(),
|
||||
"files_digest": hashlib.sha256(json.dumps({path: hashlib.sha256(content).hexdigest() for path, content in sorted(files.items())}, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest(),
|
||||
"message_digest": hashlib.sha256(str(request["message"]).encode("utf-8")).hexdigest(),
|
||||
"worker_principal": str(binding.get("worker_principal") or ""),
|
||||
"worker_lineage": str(binding.get("worker_lineage") or ""),
|
||||
"source_lease_epoch": int(source_lease_epoch or 0),
|
||||
"durable_revision": int(row["revision"]),
|
||||
"completion_identity": sha256_json({"job_id": row["job_id"], "revision": int(row["revision"]), "receipt": receipt}),
|
||||
"reason": "COMPLETED_REPLAY_LEASE_EVIDENCE_RECONCILIATION",
|
||||
"provenance": {"source": "governed-completed-replay", "authority_readback": True},
|
||||
}
|
||||
for field in ("task_id", "repository", "target_branch", "generation", "branch_mode", "payload_digest", "files_digest", "message_digest", "worker_principal", "worker_lineage"):
|
||||
durable_value = binding.get(field)
|
||||
if field == "target_branch":
|
||||
durable_value = binding.get("target_branch") or binding.get("branch")
|
||||
elif field in {"worker_principal", "worker_lineage"}:
|
||||
durable_value = durable_value or ""
|
||||
if durable_value not in (None, "") and str(durable_value) != str(incoming[field]):
|
||||
raise RuntimeError("COMPLETED_REPLAY_BINDING_CONFLICT")
|
||||
db = root / "state.sqlite3"
|
||||
connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
columns = {str(item[1]) for item in connection.execute("PRAGMA table_info(branch_leases)").fetchall()}
|
||||
if "epoch" in columns:
|
||||
lease = connection.execute("SELECT lease_id, state, current_head_oid, generation FROM branch_leases WHERE job_id=? AND epoch=? ORDER BY rowid DESC LIMIT 1", (row["job_id"], incoming["source_lease_epoch"])).fetchone()
|
||||
else:
|
||||
if incoming["source_lease_epoch"] != 1:
|
||||
raise RuntimeError("COMPLETED_REPLAY_LEASE_EPOCH_UNAVAILABLE")
|
||||
lease = connection.execute("SELECT lease_id, state, current_head_oid, generation FROM branch_leases WHERE job_id=? ORDER BY rowid DESC LIMIT 1", (row["job_id"],)).fetchone()
|
||||
if lease is None:
|
||||
raise RuntimeError("COMPLETED_REPLAY_LEASE_MISSING")
|
||||
historical_lease_head = str(lease["current_head_oid"])
|
||||
event_tables = {str(item[0]) for item in connection.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='branch_lease_events'").fetchall()}
|
||||
lease_state = str(lease["state"])
|
||||
lease_generation = int(lease["generation"])
|
||||
if "branch_lease_events" in event_tables:
|
||||
event = connection.execute("SELECT state, current_head_oid, generation FROM branch_lease_events WHERE lease_id=? ORDER BY rowid DESC LIMIT 1", (lease["lease_id"],)).fetchone()
|
||||
if event is not None:
|
||||
lease_state = str(event["state"])
|
||||
historical_lease_head = str(event["current_head_oid"])
|
||||
lease_generation = int(event["generation"])
|
||||
if lease_state not in {"ACTIVE", "USED", "EXPIRED"}:
|
||||
raise RuntimeError("COMPLETED_REPLAY_LEASE_STATE_INVALID")
|
||||
if lease_generation != incoming["generation"]:
|
||||
raise RuntimeError("COMPLETED_REPLAY_LEASE_GENERATION_CONFLICT")
|
||||
finally:
|
||||
connection.close()
|
||||
incoming["historical_lease_head_oid"] = historical_lease_head
|
||||
authority_head = self.authority.read_ref("refs/heads/" + incoming["target_branch"])
|
||||
if authority_head is None:
|
||||
raise RuntimeError("COMPLETED_REPLAY_AUTHORITY_MISSING")
|
||||
if receipt.get("commit_oid") != receipt.get("authority_oid"):
|
||||
raise RuntimeError("COMPLETED_REPLAY_RECEIPT_INVALID")
|
||||
incoming["commit_oid"] = str(receipt["commit_oid"])
|
||||
incoming["authority_head_oid"] = authority_head
|
||||
|
||||
connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
evidence_rows = connection.execute("SELECT * FROM completed_replay_reconciliations WHERE job_id=? ORDER BY created_at ASC", (row["job_id"],)).fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
if evidence_rows:
|
||||
evidence = dict(evidence_rows[0])
|
||||
immutable = tuple(key for key in evidence if key != "created_at")
|
||||
if any(any(item[key] != evidence[key] for key in immutable) for item in evidence_rows[1:]):
|
||||
raise RuntimeError("COMPLETED_REPLAY_AMBIGUOUS")
|
||||
normalized = JobStore._completed_replay_identity(incoming)
|
||||
columns = {
|
||||
"task_id": "task_id",
|
||||
"external_job_id": "external_job_id",
|
||||
"internal_job_id": "internal_job_id",
|
||||
"repository": "repository",
|
||||
"target_branch": "target_branch",
|
||||
"generation": "generation",
|
||||
"branch_mode": "branch_mode",
|
||||
"payload_digest": "payload_digest",
|
||||
"files_digest": "files_digest",
|
||||
"message_digest": "message_digest",
|
||||
"commit_oid": "commit_oid",
|
||||
"authority_head_oid": "authority_head_oid",
|
||||
"historical_lease_head_oid": "historical_lease_head_oid",
|
||||
"worker_principal": "worker_principal",
|
||||
"worker_lineage": "worker_lineage",
|
||||
"source_lease_epoch": "source_lease_epoch",
|
||||
"durable_revision": "durable_revision",
|
||||
"completion_identity": "completion_identity",
|
||||
"reason": "reason",
|
||||
}
|
||||
if any(str(evidence[column]) != str(normalized[field]) for field, column in columns.items()):
|
||||
raise RuntimeError("COMPLETED_REPLAY_BINDING_CONFLICT")
|
||||
if evidence["provenance_json"] != json.dumps(normalized["provenance"], sort_keys=True, separators=(",", ":")):
|
||||
raise RuntimeError("COMPLETED_REPLAY_PROVENANCE_CONFLICT")
|
||||
if evidence["identity_digest"] != sha256_json(normalized):
|
||||
raise RuntimeError("COMPLETED_REPLAY_IDENTITY_CONFLICT")
|
||||
if not (evidence["commit_oid"] == evidence["authority_head_oid"] == evidence["current_head_oid"] and evidence["commit_oid"] != evidence["historical_lease_head_oid"]):
|
||||
raise RuntimeError("COMPLETED_REPLAY_EVIDENCE_INCOHERENT")
|
||||
if self.authority.read_ref("refs/heads/" + incoming["target_branch"]) != evidence["current_head_oid"]:
|
||||
raise RuntimeError("COMPLETED_REPLAY_AUTHORITY_CONFLICT")
|
||||
result = {"job_id": external_job_id, "task_id": incoming["task_id"], "repository": incoming["repository"], "branch_mode": incoming["branch_mode"], "base_oid": request.get("base_oid"), "expected_branch_oid": request.get("expected_branch_oid"), "target_branch": incoming["target_branch"], "generation": incoming["generation"], "commit_oid": evidence["commit_oid"], "idempotency_key": row["idempotency_key"], "state": JobState.COMPLETED.value, "side_effect_state": row["side_effect_state"], "http_status": 200, "revision": int(row["revision"]), "receipt": {**receipt, "commit_oid": evidence["commit_oid"], "authority_oid": evidence["authority_head_oid"], "current_head_oid": evidence["current_head_oid"], "external_job_id": evidence["external_job_id"], "completed_replay_reconciliation_id": evidence["reconciliation_id"]}, "current_head_oid": evidence["current_head_oid"], "completed_replay_reconciliation_id": evidence["reconciliation_id"]}
|
||||
return result
|
||||
|
||||
controller = GitOpsJobController(JobStore.for_existing_append(root))
|
||||
reconciliation = controller.store.append_completed_replay_reconciliation(str(row["job_id"]), incoming, authority_head_oid=authority_head)
|
||||
evidence = {**receipt, "commit_oid": reconciliation["commit_oid"], "authority_oid": reconciliation["authority_head_oid"], "current_head_oid": reconciliation["current_head_oid"], "completed_replay_reconciliation_id": reconciliation["reconciliation_id"], "external_job_id": external_job_id}
|
||||
return {"job_id": external_job_id, "task_id": incoming["task_id"], "repository": incoming["repository"], "branch_mode": incoming["branch_mode"], "base_oid": request.get("base_oid"), "expected_branch_oid": request.get("expected_branch_oid"), "target_branch": incoming["target_branch"], "generation": incoming["generation"], "commit_oid": reconciliation["commit_oid"], "idempotency_key": row["idempotency_key"], "state": JobState.COMPLETED.value, "side_effect_state": row["side_effect_state"], "http_status": 200, "revision": int(row["revision"]), "receipt": evidence, "current_head_oid": reconciliation["current_head_oid"], "completed_replay_reconciliation_id": reconciliation["reconciliation_id"]}
|
||||
result = executor.execute(binding, commit)
|
||||
self.leases.mark_used(record["lease_key"], commit.idempotency_key, result)
|
||||
return result
|
||||
|
||||
def _controller_for_job(self, job_id: str) -> GitOpsJobController:
|
||||
for controller in self.controllers.values():
|
||||
@@ -523,32 +295,12 @@ class GovernedRuntime:
|
||||
for root in jobs_root.iterdir():
|
||||
if not root.is_dir():
|
||||
continue
|
||||
db = root / "state.sqlite3"
|
||||
if not db.is_file() or db.is_symlink():
|
||||
continue
|
||||
controller = GitOpsJobController(JobStore(root, payload_root=root / "payloads"))
|
||||
try:
|
||||
connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
rows = connection.execute("SELECT job_id, request_json FROM jobs").fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
matched = False
|
||||
for row in rows:
|
||||
if str(row["job_id"]) == job_id:
|
||||
matched = True
|
||||
break
|
||||
durable = json.loads(row["request_json"])
|
||||
binding = durable.get("binding", durable)
|
||||
if str(binding.get("external_job_id") or "") == job_id:
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
continue
|
||||
controller = GitOpsJobController(JobStore(root, payload_root=root / "payloads"))
|
||||
controller.store.read(job_id)
|
||||
self.controllers[str(job_id)] = controller
|
||||
return controller
|
||||
except (OSError, ValueError, TypeError, sqlite3.Error):
|
||||
except Exception:
|
||||
continue
|
||||
raise RuntimeError("JOB_NOT_FOUND")
|
||||
|
||||
@@ -658,26 +410,7 @@ class GovernedRuntime:
|
||||
controller = self._controller_for_job(job_id)
|
||||
row = controller.store.read(job_id)
|
||||
if action == "get":
|
||||
result = controller.store._receipt(row, duplicate=True).as_dict()
|
||||
evidence_reader = getattr(controller.store, "completed_replay_evidence", None)
|
||||
evidence = evidence_reader(job_id) if callable(evidence_reader) else None
|
||||
if evidence is not None:
|
||||
branch = str(json.loads(row["request_json"]).get("binding", {}).get("branch") or row["branch"])
|
||||
authority_head = self.authority.read_ref("refs/heads/" + branch)
|
||||
if authority_head != evidence["authority_head_oid"] or evidence["current_head_oid"] != authority_head:
|
||||
raise RuntimeError("COMPLETED_REPLAY_AUTHORITY_CONFLICT")
|
||||
result["job_id"] = evidence["external_job_id"]
|
||||
result["current_head_oid"] = evidence["current_head_oid"]
|
||||
result["completed_replay_reconciliation_id"] = evidence["reconciliation_id"]
|
||||
result["receipt"] = {
|
||||
**(result.get("receipt") or {}),
|
||||
"commit_oid": evidence["commit_oid"],
|
||||
"authority_oid": evidence["authority_head_oid"],
|
||||
"current_head_oid": evidence["current_head_oid"],
|
||||
"external_job_id": evidence["external_job_id"],
|
||||
"completed_replay_reconciliation_id": evidence["reconciliation_id"],
|
||||
}
|
||||
return result
|
||||
return controller.store._receipt(row, duplicate=True).as_dict()
|
||||
if action == "resume":
|
||||
return controller.resume(job_id).as_dict()
|
||||
if JobState(row["state"]) not in _RECOVERABLE:
|
||||
@@ -697,38 +430,45 @@ class GovernedRuntime:
|
||||
except (TypeError, ValueError):
|
||||
generation = 0
|
||||
reconciliation_state = "OBSERVATION_REQUIRED"
|
||||
projection: dict[str, Any] = {}
|
||||
db = self.config.state_root / "jobs" / _safe(task_id) / "state.sqlite3"
|
||||
if db.is_file() and not db.is_symlink():
|
||||
if db.is_file():
|
||||
connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
lease = connection.execute("SELECT MAX(generation) FROM branch_leases WHERE branch=?", (branch,)).fetchone()
|
||||
lease = connection.execute(
|
||||
"SELECT MAX(generation) FROM branch_leases WHERE branch=?",
|
||||
(branch,),
|
||||
).fetchone()
|
||||
generation = max(generation, int((lease or (0,))[0] or 0))
|
||||
latest = connection.execute("SELECT job_id, state, side_effect_state FROM jobs WHERE branch=? ORDER BY updated_at DESC LIMIT 1", (branch,)).fetchone()
|
||||
latest = connection.execute(
|
||||
"SELECT state, side_effect_state FROM jobs WHERE branch=? ORDER BY updated_at DESC LIMIT 1",
|
||||
(branch,),
|
||||
).fetchone()
|
||||
if latest is not None:
|
||||
reconciliation_state = "COMPLETED" if latest["state"] == "COMPLETED" else str(latest["side_effect_state"] or latest["state"])
|
||||
evidence_rows = connection.execute("SELECT * FROM completed_replay_reconciliations WHERE job_id=? ORDER BY created_at ASC", (latest["job_id"],)).fetchall()
|
||||
if evidence_rows:
|
||||
evidence = dict(evidence_rows[0])
|
||||
immutable = tuple(key for key in evidence if key != "created_at")
|
||||
if any(any(item[key] != evidence[key] for key in immutable) for item in evidence_rows[1:]):
|
||||
raise RuntimeError("COMPLETED_REPLAY_AMBIGUOUS")
|
||||
if not (evidence["commit_oid"] == evidence["authority_head_oid"] == evidence["current_head_oid"] and evidence["commit_oid"] != evidence["historical_lease_head_oid"]):
|
||||
raise RuntimeError("COMPLETED_REPLAY_EVIDENCE_INCOHERENT")
|
||||
current = self.authority.read_ref("refs/heads/" + branch)
|
||||
if current != evidence["authority_head_oid"] or current != evidence["current_head_oid"]:
|
||||
raise RuntimeError("COMPLETED_REPLAY_AUTHORITY_CONFLICT")
|
||||
reconciliation_state = "COMPLETED_REPLAY_RECONCILED"
|
||||
projection = {"external_job_id": evidence["external_job_id"], "commit_oid": evidence["commit_oid"], "current_head_oid": evidence["current_head_oid"], "completed_replay_reconciliation_id": evidence["reconciliation_id"], "receipt_authority_oid": evidence["authority_head_oid"]}
|
||||
reconciliation_state = (
|
||||
"COMPLETED"
|
||||
if latest[0] == "COMPLETED"
|
||||
else str(latest[1] or latest[0])
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
candidate = {"task_id": task_id, "branch": branch, "authority_branch_oid": authority, "gitea_git_oid": None, "gitea_api_oid": None, "commit_visible": None, "generation": generation, "reconciliation_state": reconciliation_state}
|
||||
candidate.update(projection)
|
||||
candidates.append(candidate)
|
||||
candidates.append(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"branch": branch,
|
||||
"authority_branch_oid": authority,
|
||||
"gitea_git_oid": None,
|
||||
"gitea_api_oid": None,
|
||||
"commit_visible": None,
|
||||
"generation": generation,
|
||||
"reconciliation_state": reconciliation_state,
|
||||
}
|
||||
)
|
||||
if not candidates:
|
||||
return {}
|
||||
return max(candidates, key=lambda item: (int(item["generation"] or 0), bool(item["authority_branch_oid"])))
|
||||
return max(
|
||||
candidates,
|
||||
key=lambda item: (int(item["generation"] or 0), bool(item["authority_branch_oid"])),
|
||||
)
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
task = self._snapshot_task()
|
||||
@@ -736,11 +476,11 @@ class GovernedRuntime:
|
||||
|
||||
def handlers(self) -> dict[str, Any]:
|
||||
return {
|
||||
"projectOnlineAgentExecution": self.project_online_agent_execution,
|
||||
"createGovernedTask": self.create_task,
|
||||
"acquireWriterLease": self.acquire_lease,
|
||||
"commitFileGoverned": lambda request: self.commit_files({**request, "files": {request["path"]: request["content_base64"]}}),
|
||||
"commitFilesGoverned": self.commit_files,
|
||||
"readRepositoryFile": self.read_source_file,
|
||||
"getGitOpsJob": lambda request: self.job(request, "get"),
|
||||
"resumeGitOpsJob": lambda request: self.job(request, "resume"),
|
||||
"reconcileGitOpsJob": lambda request: self.job(request, "reconcile"),
|
||||
@@ -765,6 +505,7 @@ class _HTTPError(RuntimeError): pass
|
||||
|
||||
class BridgeHandler(BaseHTTPRequestHandler):
|
||||
adapter: BridgeV2Adapter | None = None
|
||||
action_token_path: Path | None = None
|
||||
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)
|
||||
@@ -775,10 +516,47 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
||||
value = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
if not isinstance(value, dict): raise _HTTPError("JSON_OBJECT_REQUIRED")
|
||||
return value
|
||||
def _authorized(self, operation: str) -> bool:
|
||||
if operation not in {"readRepositoryFile", "acquireWriterLease", "commitFilesGoverned", "commitFileGoverned"}:
|
||||
return True
|
||||
header = self.headers.get("Authorization", "")
|
||||
token = header[7:].strip() if header.startswith("Bearer ") else self.headers.get("X-AIOS-Token", "")
|
||||
if not token or self.action_token_path is None or not self.action_token_path.is_file():
|
||||
self._json({"error": "UNAUTHORIZED", "hint": "Bearer token required"}, 401)
|
||||
return False
|
||||
digest = hashlib.sha256(token.encode()).hexdigest()
|
||||
try:
|
||||
records = self.action_token_path.read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
self._json({"error": "AUTHORITY_UNAVAILABLE"}, 503)
|
||||
return False
|
||||
for raw in records:
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
try:
|
||||
record = json.loads(line) if line.startswith("{") else {"token_plain": line, "scopes": ["*"]}
|
||||
except ValueError:
|
||||
continue
|
||||
if record.get("disabled"):
|
||||
continue
|
||||
valid = bool(record.get("token_sha256") and hmac.compare_digest(str(record["token_sha256"]), digest))
|
||||
valid = valid or bool(record.get("token_plain") and hmac.compare_digest(str(record["token_plain"]), token))
|
||||
scopes = set(record.get("scopes") or [])
|
||||
required = {
|
||||
"readRepositoryFile": {"repository:read", "file:write"},
|
||||
"acquireWriterLease": {"agent-task:control"},
|
||||
"commitFilesGoverned": {"file:write"},
|
||||
"commitFileGoverned": {"file:write"},
|
||||
}[operation]
|
||||
if valid and ("*" in scopes or required.intersection(scopes)):
|
||||
return True
|
||||
self._json({"error": "FORBIDDEN", "required_scope": sorted(required)}, 403)
|
||||
return False
|
||||
def _allowed(self, operation: str, body: Mapping[str, Any]) -> None:
|
||||
allowed = set(OPERATION_REQUIRED_FIELDS[operation]) | {"schema", "lease_seconds", "create_target_branch", "worktree_mode", "cas_ref_update_required", "expected_branch_oid", "generation", "branch_mode", "source_lease_epoch"}
|
||||
allowed = set(OPERATION_REQUIRED_FIELDS[operation]) | {"schema", "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", "job_id", "historical_lease_head_oid"})
|
||||
allowed.update({"owner_approval", "execute"})
|
||||
if set(body) - allowed: raise _HTTPError("UNKNOWN_FIELDS")
|
||||
def do_GET(self) -> None:
|
||||
if self.adapter is None: self._json({"error": "RUNTIME_UNAVAILABLE"}, 503); return
|
||||
@@ -790,14 +568,28 @@ 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:
|
||||
if not self._authorized(operation):
|
||||
return
|
||||
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, 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)
|
||||
except Exception as exc:
|
||||
code = str(exc) or type(exc).__name__
|
||||
if code.endswith("NOT_FOUND") or code in {"SOURCE_FILE_NOT_FOUND", "SOURCE_REF_NOT_FOUND", "LEASE_NOT_FOUND"}:
|
||||
status = 404
|
||||
elif code in {"SOURCE_FILE_TOO_LARGE", "SOURCE_MAX_BYTES_INVALID", "SOURCE_PATH_INVALID", "SOURCE_REF_INVALID", "SOURCE_REF_SCOPE_DENIED", "REPOSITORY_NOT_ALLOWED", "GITOPS_WRITES_DISABLED"}:
|
||||
status = 400 if code != "GITOPS_WRITES_DISABLED" else 423
|
||||
elif any(token in code for token in ("CONFLICT", "EXPECTATION", "MONOTONIC", "EXPIRED", "ALREADY_USED", "STALE", "BINDING")):
|
||||
status = 409
|
||||
else:
|
||||
status = 503
|
||||
self._json({"error": code, "retryable": status >= 500}, status)
|
||||
|
||||
|
||||
def serve(adapter: BridgeV2Adapter, host: str = "127.0.0.1", port: int = 18892) -> None:
|
||||
BridgeHandler.adapter = adapter; ThreadingHTTPServer((host, port), BridgeHandler).serve_forever()
|
||||
BridgeHandler.adapter = adapter
|
||||
BridgeHandler.action_token_path = Path(os.environ.get("AIOS_GITOPS_TOKEN_FILE", "/var/lib/aios-gitops/runtime/secrets/gitops.tokens"))
|
||||
ThreadingHTTPServer((host, port), BridgeHandler).serve_forever()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -337,9 +337,6 @@ class ContinuationRuntimeBinding:
|
||||
original_grant_consumed: bool
|
||||
permissions: ContinuationPermissions
|
||||
remote_branch_create_allowed: bool = False
|
||||
external_job_id: str | None = None
|
||||
worker_principal: str = "aios-autonomy-agent"
|
||||
worker_lineage: str = "governed-continuation"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -456,16 +453,6 @@ class ContinuationExecutor:
|
||||
"lineage_anchor_oid": request.base_oid,
|
||||
"generation": request.generation,
|
||||
"remote_branch_create_allowed": binding.remote_branch_create_allowed,
|
||||
"external_job_id": binding.external_job_id,
|
||||
"files_digest": hashlib.sha256(
|
||||
json.dumps(
|
||||
{path: hashlib.sha256(content).hexdigest() for path, content in sorted(commit.files.items())},
|
||||
sort_keys=True, separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
).hexdigest(),
|
||||
"message_digest": hashlib.sha256(commit.message.encode("utf-8")).hexdigest(),
|
||||
"worker_principal": binding.worker_principal,
|
||||
"worker_lineage": binding.worker_lineage,
|
||||
}
|
||||
|
||||
def execute(self, binding: ContinuationRuntimeBinding, commit: ContinuationCommit) -> dict[str, object]:
|
||||
@@ -529,7 +516,6 @@ class ContinuationExecutor:
|
||||
def _remote_pending(self, job_id: str, authority_oid: str, new_oid: str) -> dict[str, object]:
|
||||
receipt = {
|
||||
"authority_oid": authority_oid,
|
||||
"commit_oid": new_oid,
|
||||
"desired_remote_oid": new_oid,
|
||||
"remote_verified": False,
|
||||
"force_push": False,
|
||||
@@ -552,7 +538,6 @@ class ContinuationExecutor:
|
||||
return self._remote_pending(job_id, authority_oid, new_oid)
|
||||
receipt = {
|
||||
"authority_oid": authority_oid,
|
||||
"commit_oid": new_oid,
|
||||
"git_remote_oid": git_oid,
|
||||
"api_branch_oid": api_oid,
|
||||
"commit_visible": True,
|
||||
|
||||
@@ -10,16 +10,12 @@ def _copy(value: Mapping[str, Any] | None, keys: tuple[str, ...]) -> dict[str, A
|
||||
|
||||
|
||||
def build_control_plane_snapshot(*, target_version: str, runtime: Mapping[str, Any] | None = None, repository: Mapping[str, Any] | None = None, production: Mapping[str, Any] | None = None, task: Mapping[str, Any] | None = None, capabilities: Mapping[str, Any] | None = None, credential_broker: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
||||
task_snapshot = _copy(task, ("task_id", "branch", "authority_branch_oid", "gitea_git_oid", "gitea_api_oid", "commit_visible", "generation", "reconciliation_state"))
|
||||
for key in ("external_job_id", "commit_oid", "current_head_oid", "completed_replay_reconciliation_id", "receipt_authority_oid"):
|
||||
if task is not None and key in task:
|
||||
task_snapshot[key] = task[key]
|
||||
return {
|
||||
"version": {"target_release": target_version},
|
||||
"runtime": _copy(runtime, ("bridge_schema_version", "control_plane_release_version", "runtime_release_commit", "runtime_path", "artifact_sha256", "drift_status")),
|
||||
"repository": _copy(repository, ("identity", "authority_git_dir", "authority_main_oid")),
|
||||
"production": _copy(production, ("baseline_version", "target_oid", "deployment_state")),
|
||||
"task": task_snapshot,
|
||||
"task": _copy(task, ("task_id", "branch", "authority_branch_oid", "gitea_git_oid", "gitea_api_oid", "commit_visible", "generation", "reconciliation_state")),
|
||||
"capabilities": _copy(capabilities, ("legacy_writes_enabled", "governed_writes_enabled", "force_push", "main_write", "tag_write", "baseline_write", "production_write")),
|
||||
"credential_broker": _copy(credential_broker, ("status", "provider")),
|
||||
"secret_material_exposed": False,
|
||||
|
||||
@@ -1,478 +0,0 @@
|
||||
"""Minimal convergence contracts for AIOS V2R2C2.
|
||||
|
||||
This module deliberately contains no external side effects. It provides the shared
|
||||
data contracts and pure evaluation rules used by domain controllers. Domain
|
||||
controllers remain authoritative for their own final states.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
|
||||
|
||||
class ConvergenceContractError(ValueError):
|
||||
"""Raised when an invalid convergence contract is constructed."""
|
||||
|
||||
|
||||
class EvidenceState(str, Enum):
|
||||
PASS = "PASS"
|
||||
FAIL = "FAIL"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
BLOCKED = "BLOCKED"
|
||||
NOT_APPLICABLE = "NOT_APPLICABLE"
|
||||
|
||||
|
||||
class AcceptanceDecision(str, Enum):
|
||||
ACCEPT = "ACCEPT"
|
||||
REJECT = "REJECT"
|
||||
BLOCKED = "BLOCKED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class ObservedHealth(str, Enum):
|
||||
HEALTHY = "HEALTHY"
|
||||
DEGRADED = "DEGRADED"
|
||||
FAILED = "FAILED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class ReconciliationState(str, Enum):
|
||||
PENDING = "PENDING"
|
||||
OBSERVING = "OBSERVING"
|
||||
DIFF_FOUND = "DIFF_FOUND"
|
||||
APPLYING = "APPLYING"
|
||||
VERIFYING = "VERIFYING"
|
||||
CONVERGED = "CONVERGED"
|
||||
DEGRADED = "DEGRADED"
|
||||
BLOCKED = "BLOCKED"
|
||||
|
||||
|
||||
class ContinuationStopReason(str, Enum):
|
||||
CONTEXT_BOUNDARY = "context_boundary"
|
||||
WORKER_RESTART = "worker_restart"
|
||||
HANDOFF = "handoff"
|
||||
AUTHORITY_GATE = "authority_gate"
|
||||
WAIT_EXTERNAL = "wait_external"
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _canonical_json(value: Mapping[str, Any]) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256(value: Mapping[str, Any]) -> str:
|
||||
return "sha256:" + hashlib.sha256(_canonical_json(value)).hexdigest()
|
||||
|
||||
|
||||
def _nonempty(value: object, field_name: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
raise ConvergenceContractError(f"{field_name}_required")
|
||||
return text
|
||||
|
||||
|
||||
def _generation(value: object, field_name: str = "generation") -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ConvergenceContractError(f"{field_name}_invalid") from exc
|
||||
if parsed < 1:
|
||||
raise ConvergenceContractError(f"{field_name}_invalid")
|
||||
return parsed
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StateOwnership:
|
||||
object_type: str
|
||||
authoritative_service: str
|
||||
authoritative_store: str
|
||||
final_writer: str | None = None
|
||||
projections: tuple[str, ...] = ()
|
||||
schema_version: str = "aios.state-ownership.v1"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_nonempty(self.object_type, "object_type")
|
||||
_nonempty(self.authoritative_service, "authoritative_service")
|
||||
_nonempty(self.authoritative_store, "authoritative_store")
|
||||
if self.final_writer is not None:
|
||||
_nonempty(self.final_writer, "final_writer")
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
DEFAULT_STATE_OWNERSHIP: tuple[StateOwnership, ...] = (
|
||||
StateOwnership(
|
||||
object_type="goal",
|
||||
authoritative_service="goal-kernel",
|
||||
authoritative_store="goal-store",
|
||||
final_writer="goal-kernel",
|
||||
projections=("admin-web", "context-pack"),
|
||||
),
|
||||
StateOwnership(
|
||||
object_type="task_run",
|
||||
authoritative_service="task-plane-18940",
|
||||
authoritative_store="durable-task-store",
|
||||
final_writer="task-plane-18940",
|
||||
projections=("admin-web", "context-pack"),
|
||||
),
|
||||
StateOwnership(
|
||||
object_type="release",
|
||||
authoritative_service="release-controller",
|
||||
authoritative_store="release-registry",
|
||||
final_writer="release-controller",
|
||||
projections=("baseline-authority", "admin-web"),
|
||||
),
|
||||
StateOwnership(
|
||||
object_type="git_branch",
|
||||
authoritative_service="authority-git-executor",
|
||||
authoritative_store="authoritative-git",
|
||||
final_writer="authority-git-executor",
|
||||
projections=("gitea",),
|
||||
),
|
||||
StateOwnership(
|
||||
object_type="external_projection",
|
||||
authoritative_service="projection-controller",
|
||||
authoritative_store="projection-store",
|
||||
final_writer="projection-controller",
|
||||
projections=("openapi", "gpt-action", "nginx"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def ownership_index(
|
||||
ownership: Iterable[StateOwnership] = DEFAULT_STATE_OWNERSHIP,
|
||||
) -> dict[str, StateOwnership]:
|
||||
result: dict[str, StateOwnership] = {}
|
||||
for row in ownership:
|
||||
if row.object_type in result:
|
||||
raise ConvergenceContractError(f"duplicate_state_owner:{row.object_type}")
|
||||
result[row.object_type] = row
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AcceptanceCriterion:
|
||||
criterion_id: str
|
||||
required: bool
|
||||
state: EvidenceState
|
||||
verifier_id: str
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_nonempty(self.criterion_id, "criterion_id")
|
||||
_nonempty(self.verifier_id, "verifier_id")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AcceptanceInvariant:
|
||||
invariant_id: str
|
||||
state: EvidenceState
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_nonempty(self.invariant_id, "invariant_id")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AcceptanceVector:
|
||||
subject_type: str
|
||||
subject_id: str
|
||||
subject_revision: int
|
||||
criteria: tuple[AcceptanceCriterion, ...]
|
||||
invariants: tuple[AcceptanceInvariant, ...] = ()
|
||||
unresolved_side_effects: tuple[str, ...] = ()
|
||||
unresolved_blockers: tuple[str, ...] = ()
|
||||
runtime_proofs: tuple[str, ...] = ()
|
||||
schema: str = "aios.acceptance-vector.v1"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_nonempty(self.subject_type, "subject_type")
|
||||
_nonempty(self.subject_id, "subject_id")
|
||||
_generation(self.subject_revision, "subject_revision")
|
||||
ids = [row.criterion_id for row in self.criteria]
|
||||
if len(ids) != len(set(ids)):
|
||||
raise ConvergenceContractError("duplicate_acceptance_criterion")
|
||||
invariant_ids = [row.invariant_id for row in self.invariants]
|
||||
if len(invariant_ids) != len(set(invariant_ids)):
|
||||
raise ConvergenceContractError("duplicate_acceptance_invariant")
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
for row in value["criteria"]:
|
||||
row["state"] = row["state"].value
|
||||
for row in value["invariants"]:
|
||||
row["state"] = row["state"].value
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AcceptanceResult:
|
||||
decision: AcceptanceDecision
|
||||
reasons: tuple[str, ...]
|
||||
vector_hash: str
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"decision": self.decision.value,
|
||||
"reasons": list(self.reasons),
|
||||
"vector_hash": self.vector_hash,
|
||||
}
|
||||
|
||||
|
||||
def evaluate_acceptance(vector: AcceptanceVector) -> AcceptanceResult:
|
||||
"""Pure fail-closed acceptance evaluator.
|
||||
|
||||
The evaluator never writes a domain final state. The domain owner consumes this
|
||||
result and remains the only final writer for Goal/Release/Product state.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
required = [row for row in vector.criteria if row.required]
|
||||
|
||||
for row in required:
|
||||
if row.state == EvidenceState.FAIL:
|
||||
reasons.append(f"criterion_failed:{row.criterion_id}")
|
||||
elif row.state == EvidenceState.BLOCKED:
|
||||
reasons.append(f"criterion_blocked:{row.criterion_id}")
|
||||
elif row.state == EvidenceState.UNKNOWN:
|
||||
reasons.append(f"criterion_unknown:{row.criterion_id}")
|
||||
|
||||
for row in vector.invariants:
|
||||
if row.state == EvidenceState.FAIL:
|
||||
reasons.append(f"invariant_failed:{row.invariant_id}")
|
||||
elif row.state in {EvidenceState.UNKNOWN, EvidenceState.BLOCKED}:
|
||||
reasons.append(f"invariant_unknown:{row.invariant_id}")
|
||||
|
||||
if vector.unresolved_side_effects:
|
||||
reasons.append("unresolved_side_effects")
|
||||
if vector.unresolved_blockers:
|
||||
reasons.append("unresolved_blockers")
|
||||
|
||||
digest = _sha256(vector.as_dict())
|
||||
|
||||
if any(reason.startswith(("criterion_failed:", "invariant_failed:")) for reason in reasons):
|
||||
decision = AcceptanceDecision.REJECT
|
||||
elif any(reason.startswith("criterion_blocked:") for reason in reasons) or vector.unresolved_blockers:
|
||||
decision = AcceptanceDecision.BLOCKED
|
||||
elif reasons:
|
||||
decision = AcceptanceDecision.UNKNOWN
|
||||
else:
|
||||
decision = AcceptanceDecision.ACCEPT
|
||||
|
||||
return AcceptanceResult(decision=decision, reasons=tuple(reasons), vector_hash=digest)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DesiredState:
|
||||
resource_id: str
|
||||
resource_type: str
|
||||
desired_generation: int
|
||||
desired_spec: Mapping[str, Any]
|
||||
owner_goal_id: str | None = None
|
||||
lifecycle: str = "active"
|
||||
schema: str = "aios.desired-state.v1"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_nonempty(self.resource_id, "resource_id")
|
||||
_nonempty(self.resource_type, "resource_type")
|
||||
_generation(self.desired_generation, "desired_generation")
|
||||
if self.lifecycle not in {"active", "suspended", "deleted"}:
|
||||
raise ConvergenceContractError("desired_lifecycle_invalid")
|
||||
|
||||
def identity(self) -> tuple[str, int]:
|
||||
return self.resource_id, self.desired_generation
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ObservedState:
|
||||
resource_id: str
|
||||
observed_generation: int | None
|
||||
observed_spec: Mapping[str, Any]
|
||||
health: ObservedHealth
|
||||
source_identity: str
|
||||
observed_at: str
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
schema: str = "aios.observed-state.v1"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_nonempty(self.resource_id, "resource_id")
|
||||
_nonempty(self.source_identity, "source_identity")
|
||||
_nonempty(self.observed_at, "observed_at")
|
||||
if self.observed_generation is not None:
|
||||
_generation(self.observed_generation, "observed_generation")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReconciliationPlan:
|
||||
resource_id: str
|
||||
desired_generation: int
|
||||
state: ReconciliationState
|
||||
diff: Mapping[str, Any]
|
||||
observation_required: bool
|
||||
idempotency_key: str
|
||||
reason: str
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value["state"] = self.state.value
|
||||
return value
|
||||
|
||||
|
||||
def plan_reconciliation(
|
||||
desired: DesiredState,
|
||||
observed: ObservedState | None,
|
||||
) -> ReconciliationPlan:
|
||||
"""Return a side-effect-free reconciliation decision.
|
||||
|
||||
UNKNOWN/missing observed state always requires observation and never converges.
|
||||
"""
|
||||
identity = {
|
||||
"resource_id": desired.resource_id,
|
||||
"desired_generation": desired.desired_generation,
|
||||
"desired_spec": desired.desired_spec,
|
||||
}
|
||||
key = _sha256(identity)
|
||||
|
||||
if observed is None:
|
||||
return ReconciliationPlan(
|
||||
desired.resource_id,
|
||||
desired.desired_generation,
|
||||
ReconciliationState.OBSERVING,
|
||||
{"observed": None},
|
||||
True,
|
||||
key,
|
||||
"observed_state_missing",
|
||||
)
|
||||
if observed.resource_id != desired.resource_id:
|
||||
raise ConvergenceContractError("resource_identity_mismatch")
|
||||
if observed.health == ObservedHealth.UNKNOWN:
|
||||
return ReconciliationPlan(
|
||||
desired.resource_id,
|
||||
desired.desired_generation,
|
||||
ReconciliationState.OBSERVING,
|
||||
{"health": observed.health.value},
|
||||
True,
|
||||
key,
|
||||
"observed_state_unknown",
|
||||
)
|
||||
|
||||
same_generation = observed.observed_generation == desired.desired_generation
|
||||
same_spec = dict(observed.observed_spec) == dict(desired.desired_spec)
|
||||
if same_generation and same_spec and observed.health == ObservedHealth.HEALTHY:
|
||||
return ReconciliationPlan(
|
||||
desired.resource_id,
|
||||
desired.desired_generation,
|
||||
ReconciliationState.CONVERGED,
|
||||
{},
|
||||
False,
|
||||
key,
|
||||
"desired_equals_observed",
|
||||
)
|
||||
|
||||
return ReconciliationPlan(
|
||||
desired.resource_id,
|
||||
desired.desired_generation,
|
||||
ReconciliationState.DIFF_FOUND,
|
||||
{
|
||||
"desired_generation": desired.desired_generation,
|
||||
"observed_generation": observed.observed_generation,
|
||||
"desired_spec": dict(desired.desired_spec),
|
||||
"observed_spec": dict(observed.observed_spec),
|
||||
"observed_health": observed.health.value,
|
||||
},
|
||||
False,
|
||||
key,
|
||||
"desired_observed_drift",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SideEffectReceipt:
|
||||
mutation_id: str
|
||||
external_system: str
|
||||
state: str
|
||||
observed_at: str
|
||||
external_request_id: str | None = None
|
||||
external_object_id: str | None = None
|
||||
observed_result_hash: str | None = None
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
schema: str = "aios.side-effect-receipt.v1"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_nonempty(self.mutation_id, "mutation_id")
|
||||
_nonempty(self.external_system, "external_system")
|
||||
_nonempty(self.observed_at, "observed_at")
|
||||
if self.state not in {
|
||||
"confirmed_applied",
|
||||
"confirmed_not_applied",
|
||||
"unknown",
|
||||
}:
|
||||
raise ConvergenceContractError("side_effect_receipt_state_invalid")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContinuationManifest:
|
||||
trace_id: str
|
||||
goal_id: str
|
||||
goal_revision: int
|
||||
task_id: str
|
||||
run_id: str
|
||||
current_node_id: str
|
||||
acceptance_vector_ref: str
|
||||
next_action: str
|
||||
stop_reason: ContinuationStopReason
|
||||
checkpoint_ref: str | None = None
|
||||
unresolved_blocker_refs: tuple[str, ...] = ()
|
||||
authoritative_decision_refs: tuple[str, ...] = ()
|
||||
resource_refs: tuple[str, ...] = ()
|
||||
artifact_refs: tuple[str, ...] = ()
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
created_at: str = field(default_factory=_utc_now)
|
||||
schema: str = "aios.continuation-manifest.v1"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"trace_id",
|
||||
"goal_id",
|
||||
"task_id",
|
||||
"run_id",
|
||||
"current_node_id",
|
||||
"acceptance_vector_ref",
|
||||
"next_action",
|
||||
"created_at",
|
||||
):
|
||||
_nonempty(getattr(self, name), name)
|
||||
_generation(self.goal_revision, "goal_revision")
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value["stop_reason"] = self.stop_reason.value
|
||||
return value
|
||||
|
||||
@property
|
||||
def content_hash(self) -> str:
|
||||
return _sha256(self.as_dict())
|
||||
|
||||
|
||||
def validate_state_ownership(
|
||||
object_type: str,
|
||||
writer: str,
|
||||
ownership: Iterable[StateOwnership] = DEFAULT_STATE_OWNERSHIP,
|
||||
*,
|
||||
final_state: bool = False,
|
||||
) -> None:
|
||||
row = ownership_index(ownership).get(object_type)
|
||||
if row is None:
|
||||
raise ConvergenceContractError(f"state_owner_missing:{object_type}")
|
||||
expected = row.final_writer if final_state and row.final_writer else row.authoritative_service
|
||||
if writer != expected:
|
||||
raise ConvergenceContractError(
|
||||
f"STATE_OWNERSHIP_VIOLATION:{object_type}:{writer}:{expected}"
|
||||
)
|
||||
@@ -178,63 +178,6 @@ class JobStore:
|
||||
connection.execute("PRAGMA busy_timeout=30000")
|
||||
return connection
|
||||
|
||||
@classmethod
|
||||
def for_existing_append(cls, root: str | os.PathLike[str]) -> "JobStore":
|
||||
"""Open an existing job database without initialization side effects."""
|
||||
path = Path(root)
|
||||
if (
|
||||
not path.is_absolute()
|
||||
or path.is_symlink()
|
||||
or not path.is_dir()
|
||||
or not (path / "state.sqlite3").is_file()
|
||||
or (path / "state.sqlite3").is_symlink()
|
||||
):
|
||||
raise JobError("JOB_ROOT_NOT_FOUND")
|
||||
instance = cls.__new__(cls)
|
||||
instance.root = path
|
||||
instance.db_path = path / "state.sqlite3"
|
||||
return instance
|
||||
|
||||
@staticmethod
|
||||
def _ensure_completed_replay_schema(connection: sqlite3.Connection) -> None:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS completed_replay_reconciliations (
|
||||
reconciliation_id TEXT PRIMARY KEY, job_id TEXT NOT NULL,
|
||||
identity_digest TEXT NOT NULL UNIQUE,
|
||||
source_lease_epoch INTEGER NOT NULL,
|
||||
task_id TEXT NOT NULL, external_job_id TEXT NOT NULL,
|
||||
internal_job_id TEXT NOT NULL, repository TEXT NOT NULL,
|
||||
target_branch TEXT NOT NULL, generation INTEGER NOT NULL,
|
||||
branch_mode TEXT NOT NULL, payload_digest TEXT NOT NULL,
|
||||
files_digest TEXT NOT NULL, message_digest TEXT NOT NULL,
|
||||
commit_oid TEXT NOT NULL, authority_head_oid TEXT NOT NULL,
|
||||
current_head_oid TEXT NOT NULL, historical_lease_head_oid TEXT NOT NULL,
|
||||
worker_principal TEXT NOT NULL, worker_lineage TEXT NOT NULL,
|
||||
durable_revision INTEGER NOT NULL, completion_identity TEXT NOT NULL,
|
||||
reason TEXT NOT NULL, provenance_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL, FOREIGN KEY(job_id) REFERENCES jobs(job_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS completed_replay_job_idx
|
||||
ON completed_replay_reconciliations(job_id);
|
||||
CREATE TRIGGER IF NOT EXISTS completed_replay_no_update
|
||||
BEFORE UPDATE ON completed_replay_reconciliations
|
||||
BEGIN SELECT RAISE(ABORT, 'COMPLETED_REPLAY_APPEND_ONLY'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS completed_replay_no_delete
|
||||
BEFORE DELETE ON completed_replay_reconciliations
|
||||
BEGIN SELECT RAISE(ABORT, 'COMPLETED_REPLAY_APPEND_ONLY'); END;
|
||||
"""
|
||||
)
|
||||
columns = {
|
||||
str(item[1]) for item in connection.execute(
|
||||
"PRAGMA table_info(completed_replay_reconciliations)"
|
||||
).fetchall()
|
||||
}
|
||||
if columns and "historical_lease_head_oid" not in columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE completed_replay_reconciliations ADD COLUMN historical_lease_head_oid TEXT"
|
||||
)
|
||||
|
||||
def _initialize(self) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
@@ -273,43 +216,9 @@ class JobStore:
|
||||
kind TEXT NOT NULL, digest TEXT NOT NULL, payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL, FOREIGN KEY(job_id) REFERENCES jobs(job_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS completed_replay_reconciliations (
|
||||
reconciliation_id TEXT PRIMARY KEY, job_id TEXT NOT NULL,
|
||||
identity_digest TEXT NOT NULL UNIQUE,
|
||||
source_lease_epoch INTEGER NOT NULL,
|
||||
task_id TEXT NOT NULL, external_job_id TEXT NOT NULL,
|
||||
internal_job_id TEXT NOT NULL, repository TEXT NOT NULL,
|
||||
target_branch TEXT NOT NULL, generation INTEGER NOT NULL,
|
||||
branch_mode TEXT NOT NULL, payload_digest TEXT NOT NULL,
|
||||
files_digest TEXT NOT NULL, message_digest TEXT NOT NULL,
|
||||
commit_oid TEXT NOT NULL, authority_head_oid TEXT NOT NULL,
|
||||
current_head_oid TEXT NOT NULL, historical_lease_head_oid TEXT NOT NULL,
|
||||
worker_principal TEXT NOT NULL, worker_lineage TEXT NOT NULL,
|
||||
durable_revision INTEGER NOT NULL, completion_identity TEXT NOT NULL,
|
||||
reason TEXT NOT NULL, provenance_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL, FOREIGN KEY(job_id) REFERENCES jobs(job_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS completed_replay_job_idx
|
||||
ON completed_replay_reconciliations(job_id);
|
||||
CREATE TRIGGER IF NOT EXISTS completed_replay_no_update
|
||||
BEFORE UPDATE ON completed_replay_reconciliations
|
||||
BEGIN SELECT RAISE(ABORT, 'COMPLETED_REPLAY_APPEND_ONLY'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS completed_replay_no_delete
|
||||
BEFORE DELETE ON completed_replay_reconciliations
|
||||
BEGIN SELECT RAISE(ABORT, 'COMPLETED_REPLAY_APPEND_ONLY'); END;
|
||||
INSERT OR REPLACE INTO metadata(key, value) VALUES('schema_version', '1');
|
||||
"""
|
||||
)
|
||||
columns = {
|
||||
str(item[1]) for item in connection.execute(
|
||||
"PRAGMA table_info(completed_replay_reconciliations)"
|
||||
).fetchall()
|
||||
}
|
||||
if columns and "historical_lease_head_oid" not in columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE completed_replay_reconciliations "
|
||||
"ADD COLUMN historical_lease_head_oid TEXT"
|
||||
)
|
||||
except sqlite3.Error as exc:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||
|
||||
@@ -342,227 +251,6 @@ class JobStore:
|
||||
raise JobError("JOB_NOT_FOUND")
|
||||
return dict(row)
|
||||
|
||||
@staticmethod
|
||||
def _completed_replay_identity(envelope: Mapping[str, Any]) -> dict[str, Any]:
|
||||
required = (
|
||||
"task_id", "external_job_id", "internal_job_id", "repository",
|
||||
"target_branch", "generation", "branch_mode", "payload_digest",
|
||||
"files_digest", "message_digest", "commit_oid", "authority_head_oid",
|
||||
"historical_lease_head_oid", "worker_principal", "worker_lineage", "source_lease_epoch",
|
||||
"durable_revision", "completion_identity", "reason", "provenance",
|
||||
)
|
||||
if any(key not in envelope for key in required):
|
||||
raise BindingConflict("COMPLETED_REPLAY_BINDING_INCOMPLETE")
|
||||
text_fields = (
|
||||
"task_id", "external_job_id", "internal_job_id", "repository",
|
||||
"target_branch", "branch_mode", "payload_digest", "files_digest",
|
||||
"message_digest", "commit_oid", "authority_head_oid", "worker_principal",
|
||||
"worker_lineage", "historical_lease_head_oid", "completion_identity", "reason",
|
||||
)
|
||||
result: dict[str, Any] = {}
|
||||
for key in text_fields:
|
||||
value = envelope[key]
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise BindingConflict("COMPLETED_REPLAY_BINDING_INVALID")
|
||||
result[key] = value
|
||||
for key in ("generation", "source_lease_epoch", "durable_revision"):
|
||||
value = envelope[key]
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
||||
raise BindingConflict("COMPLETED_REPLAY_BINDING_INVALID")
|
||||
result[key] = value
|
||||
for key in ("commit_oid", "authority_head_oid", "historical_lease_head_oid"):
|
||||
if len(result[key]) != 40 or any(ch not in "0123456789abcdef" for ch in result[key]):
|
||||
raise BindingConflict("COMPLETED_REPLAY_BINDING_INVALID")
|
||||
for key in ("payload_digest", "files_digest", "message_digest"):
|
||||
if len(result[key]) != 64 or any(ch not in "0123456789abcdef" for ch in result[key]):
|
||||
raise BindingConflict("COMPLETED_REPLAY_BINDING_INVALID")
|
||||
if result["repository"] != "aios/aios" or result["branch_mode"] not in {"CREATE_NEW", "CONTINUE_EXISTING"}:
|
||||
raise BindingConflict("COMPLETED_REPLAY_BINDING_INVALID")
|
||||
if not result["target_branch"].startswith("agent/") or result["target_branch"] in {"main", "master"}:
|
||||
raise BindingConflict("COMPLETED_REPLAY_BINDING_INVALID")
|
||||
provenance = envelope["provenance"]
|
||||
if not isinstance(provenance, Mapping) or not provenance:
|
||||
raise BindingConflict("COMPLETED_REPLAY_PROVENANCE_INVALID")
|
||||
result["provenance"] = dict(provenance)
|
||||
if result["commit_oid"] != result["authority_head_oid"]:
|
||||
raise BindingConflict("COMPLETED_REPLAY_AUTHORITY_CONFLICT")
|
||||
return result
|
||||
|
||||
def append_completed_replay_reconciliation(
|
||||
self,
|
||||
job_id: str,
|
||||
envelope: Mapping[str, Any],
|
||||
*,
|
||||
authority_head_oid: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Append exact completed-replay evidence without rewriting history."""
|
||||
identity = self._completed_replay_identity(envelope)
|
||||
if authority_head_oid != identity["authority_head_oid"]:
|
||||
raise BindingConflict("COMPLETED_REPLAY_AUTHORITY_CONFLICT")
|
||||
if identity["internal_job_id"] != job_id:
|
||||
raise BindingConflict("COMPLETED_REPLAY_INTERNAL_JOB_CONFLICT")
|
||||
identity_digest = sha256_json(identity)
|
||||
reconciliation_id = "reconcile-" + identity_digest[:32]
|
||||
now = utc_now()
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
self._ensure_completed_replay_schema(connection)
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone()
|
||||
if row is None:
|
||||
raise JobError("JOB_NOT_FOUND")
|
||||
if JobState(row["state"]) != JobState.COMPLETED:
|
||||
raise BindingConflict("COMPLETED_REPLAY_JOB_NOT_COMPLETED")
|
||||
durable_request = json.loads(row["request_json"])
|
||||
binding = durable_request.get("binding", durable_request)
|
||||
exact_binding = {
|
||||
"task_id": binding.get("task_id") or binding.get("campaign_id"),
|
||||
"external_job_id": binding.get("external_job_id"),
|
||||
"internal_job_id": job_id,
|
||||
"repository": binding.get("repository") or row["repository"],
|
||||
"target_branch": binding.get("target_branch") or binding.get("branch"),
|
||||
"generation": binding.get("generation"),
|
||||
"branch_mode": binding.get("branch_mode"),
|
||||
"payload_digest": binding.get("payload_digest") or row["payload_digest"],
|
||||
"files_digest": binding.get("files_digest"),
|
||||
"message_digest": binding.get("message_digest"),
|
||||
"worker_principal": binding.get("worker_principal"),
|
||||
"worker_lineage": binding.get("worker_lineage"),
|
||||
}
|
||||
for key, value in exact_binding.items():
|
||||
if str(value) != str(identity[key]):
|
||||
raise BindingConflict("COMPLETED_REPLAY_BINDING_CONFLICT")
|
||||
receipt = json.loads(row["receipt_json"]) if row["receipt_json"] else None
|
||||
if not isinstance(receipt, Mapping):
|
||||
raise BindingConflict("COMPLETED_REPLAY_RECEIPT_MISSING")
|
||||
if receipt.get("commit_oid") != identity["commit_oid"] or receipt.get("authority_oid") != identity["authority_head_oid"]:
|
||||
raise BindingConflict("COMPLETED_REPLAY_RECEIPT_AUTHORITY_CONFLICT")
|
||||
completion_identity = sha256_json({
|
||||
"job_id": row["job_id"],
|
||||
"revision": int(row["revision"]),
|
||||
"receipt": receipt,
|
||||
})
|
||||
if identity["completion_identity"] != completion_identity:
|
||||
raise BindingConflict("COMPLETED_REPLAY_COMPLETION_IDENTITY_CONFLICT")
|
||||
lease_columns = {
|
||||
str(item[1]) for item in connection.execute("PRAGMA table_info(branch_leases)").fetchall()
|
||||
}
|
||||
if "epoch" in lease_columns:
|
||||
lease = connection.execute(
|
||||
"SELECT lease_id, state, current_head_oid, generation FROM branch_leases WHERE job_id=? AND epoch=? ORDER BY rowid DESC LIMIT 1",
|
||||
(job_id, identity["source_lease_epoch"]),
|
||||
).fetchone()
|
||||
else:
|
||||
if identity["source_lease_epoch"] != 1:
|
||||
raise BindingConflict("COMPLETED_REPLAY_LEASE_EPOCH_UNAVAILABLE")
|
||||
lease = connection.execute(
|
||||
"SELECT lease_id, state, current_head_oid, generation FROM branch_leases WHERE job_id=? ORDER BY rowid DESC LIMIT 1",
|
||||
(job_id,),
|
||||
).fetchone()
|
||||
if lease is None:
|
||||
raise BindingConflict("COMPLETED_REPLAY_LEASE_MISSING")
|
||||
lease_state = str(lease["state"])
|
||||
lease_head = str(lease["current_head_oid"])
|
||||
lease_generation = int(lease["generation"])
|
||||
event_tables = {
|
||||
str(item[0]) for item in connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='branch_lease_events'"
|
||||
).fetchall()
|
||||
}
|
||||
if "branch_lease_events" in event_tables:
|
||||
event = connection.execute(
|
||||
"SELECT state, current_head_oid, generation FROM branch_lease_events WHERE lease_id=? ORDER BY rowid DESC LIMIT 1",
|
||||
(lease["lease_id"],),
|
||||
).fetchone()
|
||||
if event is not None:
|
||||
lease_state = str(event["state"])
|
||||
lease_head = str(event["current_head_oid"])
|
||||
lease_generation = int(event["generation"])
|
||||
if lease_state not in {"ACTIVE", "USED", "EXPIRED"}:
|
||||
raise BindingConflict("COMPLETED_REPLAY_LEASE_STATE_INVALID")
|
||||
if lease_generation != identity["generation"]:
|
||||
raise BindingConflict("COMPLETED_REPLAY_LEASE_GENERATION_CONFLICT")
|
||||
if len(lease_head) != 40 or any(ch not in "0123456789abcdef" for ch in lease_head):
|
||||
raise BindingConflict("COMPLETED_REPLAY_LEASE_HEAD_INVALID")
|
||||
if lease_head != identity["historical_lease_head_oid"]:
|
||||
raise BindingConflict("COMPLETED_REPLAY_HISTORICAL_LEASE_CONFLICT")
|
||||
if identity["commit_oid"] == identity["historical_lease_head_oid"]:
|
||||
raise BindingConflict("COMPLETED_REPLAY_EVIDENCE_INCOHERENT")
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM completed_replay_reconciliations WHERE job_id=? ORDER BY created_at ASC",
|
||||
(job_id,),
|
||||
).fetchall()
|
||||
for prior in existing:
|
||||
if prior["identity_digest"] != identity_digest:
|
||||
raise BindingConflict("COMPLETED_REPLAY_AMBIGUOUS")
|
||||
if existing:
|
||||
result = dict(existing[0])
|
||||
connection.commit()
|
||||
return result
|
||||
provenance_json = json.dumps(identity["provenance"], sort_keys=True, separators=(",", ":"))
|
||||
connection.execute(
|
||||
"INSERT INTO completed_replay_reconciliations(reconciliation_id,job_id,identity_digest,source_lease_epoch,task_id,external_job_id,internal_job_id,repository,target_branch,generation,branch_mode,payload_digest,files_digest,message_digest,commit_oid,authority_head_oid,worker_principal,worker_lineage,current_head_oid,historical_lease_head_oid,durable_revision,completion_identity,reason,provenance_json,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
reconciliation_id, job_id, identity["identity_digest"] if "identity_digest" in identity else identity_digest, identity["source_lease_epoch"],
|
||||
identity["task_id"], identity["external_job_id"], identity["internal_job_id"],
|
||||
identity["repository"], identity["target_branch"], identity["generation"],
|
||||
identity["branch_mode"], identity["payload_digest"], identity["files_digest"],
|
||||
identity["message_digest"], identity["commit_oid"], identity["authority_head_oid"],
|
||||
identity["worker_principal"], identity["worker_lineage"], identity["authority_head_oid"], identity["historical_lease_head_oid"], identity["durable_revision"],
|
||||
identity["completion_identity"], identity["reason"], provenance_json, now,
|
||||
),
|
||||
)
|
||||
result = dict(connection.execute(
|
||||
"SELECT * FROM completed_replay_reconciliations WHERE reconciliation_id=?",
|
||||
(reconciliation_id,),
|
||||
).fetchone())
|
||||
connection.commit()
|
||||
return result
|
||||
except (JobError, BindingConflict):
|
||||
raise
|
||||
except sqlite3.IntegrityError as exc:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM completed_replay_reconciliations WHERE identity_digest=?",
|
||||
(identity_digest,),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
return dict(existing)
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
raise BindingConflict("COMPLETED_REPLAY_IDEMPOTENCY_CONFLICT") from exc
|
||||
except sqlite3.Error as exc:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||
|
||||
def completed_replay_evidence(self, job_id: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM completed_replay_reconciliations WHERE job_id=? ORDER BY created_at ASC",
|
||||
(job_id,),
|
||||
).fetchall()
|
||||
except sqlite3.Error as exc:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||
if not rows:
|
||||
return None
|
||||
if len({str(row["identity_digest"]) for row in rows}) != 1:
|
||||
raise BindingConflict("COMPLETED_REPLAY_AMBIGUOUS")
|
||||
first = dict(rows[0])
|
||||
immutable_fields = tuple(
|
||||
key for key in first
|
||||
if key != "created_at"
|
||||
)
|
||||
for row in rows[1:]:
|
||||
if any(row[key] != first[key] for key in immutable_fields):
|
||||
raise BindingConflict("COMPLETED_REPLAY_AMBIGUOUS")
|
||||
if not (
|
||||
first["commit_oid"] == first["authority_head_oid"] == first["current_head_oid"]
|
||||
and first["commit_oid"] != first["historical_lease_head_oid"]
|
||||
):
|
||||
raise BindingConflict("COMPLETED_REPLAY_EVIDENCE_INCOHERENT")
|
||||
return first
|
||||
|
||||
def _receipt(self, row: Mapping[str, Any], *, duplicate: bool) -> JobReceipt:
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.COMPLETED:
|
||||
@@ -614,11 +302,6 @@ class GitOpsJobController:
|
||||
"check_context",
|
||||
"check_state",
|
||||
"check_description",
|
||||
"external_job_id",
|
||||
"files_digest",
|
||||
"message_digest",
|
||||
"worker_principal",
|
||||
"worker_lineage",
|
||||
)
|
||||
for field in optional:
|
||||
value = request.get(field)
|
||||
|
||||
@@ -221,10 +221,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not a: return
|
||||
ver=data.get('version') or data.get('tag')
|
||||
try:
|
||||
validate_release_version(ver)
|
||||
except ValueError:
|
||||
self.send_json({'error':'version must match VxRxCx'},400)
|
||||
return
|
||||
validate_release_version(ver)
|
||||
except ValueError:
|
||||
self.send_json({'error':'version must match VxRxCx'},400)
|
||||
return
|
||||
msg=data.get('message') or f'release {ver}'; out=git_cmd(['tag','-a',ver,'-m',msg]);
|
||||
if data.get('push',False): git_cmd(['push','origin',ver])
|
||||
level='V' if re.match(r'^V[1-9]\d*R0C0', ver) else ('R' if re.match(r'^V\d+R[1-9]\d*C0', ver) else 'C'); exec_sql('INSERT OR REPLACE INTO gitops_versions(version,tag,level,summary,status,created_by,created_at) VALUES(?,?,?,?,?,?,?)',(ver,ver,level,msg,'ready',a['username'],now())); exec_sql('UPDATE gitops_baseline SET active_version=?,active_tag=?,updated_at=? WHERE id=?',(ver,ver,now(),'current')); audit(a['username'],'git_tag',ver,{'level':level}); self.send_json({'status':'tagged','version':ver,'git':out}); return
|
||||
|
||||
@@ -9,7 +9,6 @@ import re
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobStore
|
||||
from aios_online_agent_execution import GovernedExecutionBinding, project_execution_envelope
|
||||
|
||||
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}$")
|
||||
@@ -54,46 +53,7 @@ class GovernedTaskBootstrap:
|
||||
def _read_existing(self, task_id: str) -> GovernedTaskBinding | None:
|
||||
path = self._metadata(task_id)
|
||||
if not path.is_file() or path.is_symlink(): return None
|
||||
|
||||
return GovernedTaskBinding(**json.loads(path.read_text(encoding="utf-8")))
|
||||
def get_binding(self, task_id: str) -> GovernedTaskBinding:
|
||||
"""Read the exact durable binding; never synthesize missing lineage."""
|
||||
binding = self._read_existing(str(task_id))
|
||||
if binding is None or not binding.job_id:
|
||||
raise BootstrapError("TASK_EXECUTION_PROJECTION_MISSING")
|
||||
return binding
|
||||
|
||||
def execution_binding(self, binding: GovernedTaskBinding) -> GovernedExecutionBinding:
|
||||
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": binding.expected_branch_oid,
|
||||
"target_branch": binding.target_branch,
|
||||
"branch_mode": binding.branch_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 _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)
|
||||
|
||||
@@ -4,7 +4,6 @@ import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, urllib.
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from baseline_authority import BaselineAuthority, BaselineAuthorityError
|
||||
from silent_release_controller import ReleaseCandidate
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
@@ -24,23 +23,17 @@ from gitea_tag_publication import (
|
||||
TagPublisher,
|
||||
redact,
|
||||
validate_version,
|
||||
validate_gitea_api_url,
|
||||
)
|
||||
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
|
||||
@@ -90,34 +83,13 @@ SILENT_RELEASE_STATE_ROOT = Path(os.getenv(
|
||||
'AIOS_SILENT_RELEASE_STATE_ROOT',
|
||||
'/var/lib/aios-gitops/silent-release',
|
||||
))
|
||||
# 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_EXECUTION_ENABLED = str(os.getenv(
|
||||
'AIOS_SILENT_RELEASE_EXECUTION_ENABLED', 'false'
|
||||
)).lower() in {'1', 'true', 'yes', 'on'}
|
||||
SILENT_RELEASE_PUBLIC_KEYS = Path(os.getenv(
|
||||
'AIOS_SILENT_RELEASE_PUBLIC_KEYS',
|
||||
'/etc/aios-gitops/silent-release-owner-public-keys.json',
|
||||
))
|
||||
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',
|
||||
str(WS / 'config' / 'gitops' / 'silent-release-policy.json'),
|
||||
))
|
||||
monitor = ActionMonitor()
|
||||
|
||||
BASE_MODELS = [
|
||||
@@ -152,12 +124,19 @@ SILENT_RELEASE_POST_PATHS = frozenset({
|
||||
'/submitOwnerApproval',
|
||||
'/startDeployment',
|
||||
})
|
||||
BRIDGE_CONTROL_POST_PATHS = frozenset({
|
||||
'/readRepositoryFile',
|
||||
'/acquireWriterLease',
|
||||
'/commitFilesGoverned',
|
||||
})
|
||||
|
||||
def reject_non_read_http(handler, method: str, path: str) -> bool:
|
||||
if method == 'GET':
|
||||
return False
|
||||
if path in SILENT_RELEASE_POST_PATHS:
|
||||
return False
|
||||
if method == 'POST' and path in BRIDGE_CONTROL_POST_PATHS:
|
||||
return False
|
||||
if (
|
||||
method == 'POST'
|
||||
and path in ('/api/file', '/api/commit-file')
|
||||
@@ -195,86 +174,6 @@ 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:
|
||||
if not isinstance(intent, dict):
|
||||
raise TypeError('intent must be a mapping')
|
||||
version = str(intent.get('version') or 'V2R2C2')
|
||||
# A createReleaseIntent request is intentionally identity-light.
|
||||
# Candidate identity is resolved by the host builder policy, not by
|
||||
# the caller. Explicit release-worker requests may still carry it.
|
||||
candidate_id = str(intent.get('candidate_id') or '')
|
||||
source_commit = str(intent.get('source_commit') or '')
|
||||
if not version or not source_commit:
|
||||
raise KeyError('version/source_commit')
|
||||
except (KeyError, TypeError) as exc:
|
||||
raise CapabilityAuthorizationError(
|
||||
'trusted builder requires a release intent or 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():
|
||||
@@ -284,88 +183,12 @@ def _build_silent_release_service() -> SilentReleaseActionService:
|
||||
SILENT_RELEASE_STATE_ROOT / 'revocations'
|
||||
),
|
||||
)
|
||||
policy_path=TRUSTED_RELEASE_POLICY
|
||||
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,
|
||||
)
|
||||
@@ -504,8 +327,7 @@ def resolve_commit_oid(value: str | None, field: str = 'target_oid') -> str:
|
||||
raise ValueError(f'{field} does not resolve to the exact approved commit') from exc
|
||||
|
||||
def existing_tag_target(tag: str) -> str | None:
|
||||
if not re.fullmatch(r'V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$', str(tag or '')):
|
||||
raise ValueError('invalid release version')
|
||||
validate_version(tag)
|
||||
try:
|
||||
result = run_authoritative_git(
|
||||
['rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'],
|
||||
@@ -528,6 +350,7 @@ def publication_request_from_query(q: dict[str, list[str]]) -> PublicationReques
|
||||
(q['approval_plan_id'])[0],
|
||||
)
|
||||
|
||||
|
||||
def governed_release_query_payload(q: dict[str, list[str]]) -> dict[str, str]:
|
||||
request = publication_request_from_query(q)
|
||||
return {
|
||||
@@ -549,7 +372,7 @@ def required_authoritative_git_dir() -> Path:
|
||||
|
||||
def publication_publisher() -> TagPublisher:
|
||||
authoritative_repo = required_authoritative_git_dir()
|
||||
return TagPublisher(
|
||||
publisher = TagPublisher(
|
||||
WS,
|
||||
GitTagBackend(WS, authoritative_repo=authoritative_repo),
|
||||
GiteaTagClient(
|
||||
@@ -560,6 +383,10 @@ def publication_publisher() -> TagPublisher:
|
||||
),
|
||||
os.getenv('AIOS_GITOPS_GITEA_REMOTE','gitea'),
|
||||
)
|
||||
publisher.baseline_path = Path(os.getenv(
|
||||
'AIOS_GITOPS_BASELINE_AUTHORITY', str(WS / 'baseline' / 'current.json')
|
||||
))
|
||||
return publisher
|
||||
|
||||
def commit(paths, msg):
|
||||
ensure_git(); paths = [relpath(p) for p in paths] if paths else ['.']; sh(['add'] + paths, True)
|
||||
@@ -625,7 +452,10 @@ class H(BaseHTTPRequestHandler):
|
||||
request = urllib.request.Request(
|
||||
base + route,
|
||||
data=json.dumps(data, sort_keys=True, separators=(',', ':')).encode('utf-8'),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
**({k: self.headers[k] for k in ('Authorization', 'X-AIOS-Token') if self.headers.get(k)}),
|
||||
},
|
||||
method='POST',
|
||||
)
|
||||
try:
|
||||
@@ -640,6 +470,36 @@ 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_control(self, path, data):
|
||||
route = {
|
||||
'/readRepositoryFile': '/v2/source/file',
|
||||
'/acquireWriterLease': '/v2/writer-lease',
|
||||
'/commitFilesGoverned': '/v2/commit-files',
|
||||
}.get(path)
|
||||
if route is None:
|
||||
return self.sendj({'error': 'governed_control_route_invalid'}, 400)
|
||||
base = os.getenv('AIOS_BRIDGE_V2_URL', 'http://127.0.0.1:18892').rstrip('/')
|
||||
request = urllib.request.Request(
|
||||
base + route,
|
||||
data=json.dumps(data, sort_keys=True, separators=(',', ':')).encode('utf-8'),
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
**({k: self.headers[k] for k in ('Authorization', 'X-AIOS-Token') if self.headers.get(k)}),
|
||||
},
|
||||
method='POST',
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=60) 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 do_POST(self):
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
if path in {
|
||||
@@ -660,11 +520,8 @@ class H(BaseHTTPRequestHandler):
|
||||
q=urllib.parse.parse_qs(u.query)
|
||||
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',
|
||||
@@ -750,7 +607,15 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj({'error':redact(exc)},413)
|
||||
except (AuthoritativeGitError, RuntimeError) as exc:
|
||||
return self.sendj({'error':redact(exc)},503)
|
||||
return self.sendj({'from':frm,'to':to,'diff':diff})
|
||||
payload = {'from': frm, 'to': to, 'diff': diff}
|
||||
payload.update({
|
||||
key: diff[key]
|
||||
for key in (
|
||||
'from_oid', 'to_oid', 'changed_paths', 'stats',
|
||||
'total_size_bytes', 'truncated', 'patch_limit_bytes',
|
||||
)
|
||||
})
|
||||
return self.sendj(payload)
|
||||
if path in ('/api/patch','/api/patches','/api/branches','/patch'):
|
||||
snapshot = repository_snapshot(
|
||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||
@@ -835,11 +700,15 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj({'error':'invalid release version'},400)
|
||||
return self.sendj(status)
|
||||
if path == '/api/release-consistency':
|
||||
request=publication_request_from_query(q)
|
||||
return self.sendj(publication_publisher().get_release_consistency(request))
|
||||
return self.bridge_release(
|
||||
'/getReleaseConsistency',
|
||||
governed_release_query_payload(q),
|
||||
)
|
||||
if path == '/api/release-acceptance':
|
||||
request=publication_request_from_query(q)
|
||||
return self.sendj(publication_publisher().run_release_acceptance(request))
|
||||
return self.bridge_release(
|
||||
'/runReleaseAcceptance',
|
||||
governed_release_query_payload(q),
|
||||
)
|
||||
if path in (
|
||||
'/getReleaseCandidate',
|
||||
'/getReleasePlan',
|
||||
@@ -868,7 +737,8 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj(
|
||||
service.get_capability_status(candidate_id)
|
||||
)
|
||||
return self.sendj(service.get_release_saga(candidate_id))
|
||||
result = service.get_release_saga(candidate_id)
|
||||
return self.sendj(result, 404 if result.get('status') == 'NOT_FOUND' else 200)
|
||||
if path == '/getRecoveryStatus':
|
||||
return self.sendj(
|
||||
silent_release_service().get_recovery_status()
|
||||
@@ -900,18 +770,23 @@ class H(BaseHTTPRequestHandler):
|
||||
if reject_non_read_http(self, 'POST', path): return
|
||||
data=self.body()
|
||||
try:
|
||||
if path == '/readRepositoryFile':
|
||||
a=self.need('file:write')
|
||||
if not a: return
|
||||
return self.bridge_control(path, data)
|
||||
if path == '/acquireWriterLease':
|
||||
a=self.need('agent-task:control')
|
||||
if not a: return
|
||||
return self.bridge_control(path, data)
|
||||
if path == '/commitFilesGoverned':
|
||||
a=self.need('file:write')
|
||||
if not a: return
|
||||
return self.bridge_control(path, data)
|
||||
if path == '/createReleaseCandidate':
|
||||
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(
|
||||
service.create_release_candidate(data)
|
||||
silent_release_service().create_release_candidate(data)
|
||||
)
|
||||
if path == '/createReleaseIntent':
|
||||
a=self.need('release:candidate')
|
||||
@@ -951,13 +826,6 @@ 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})
|
||||
@@ -1033,7 +901,10 @@ class H(BaseHTTPRequestHandler):
|
||||
a=self.need('tag:create');
|
||||
if not a: return
|
||||
ver=data.get('version') or data.get('tag')
|
||||
if not re.match(r'^V\d+R\d+C\d+(-[A-Za-z0-9_.-]+)?$', ver or ''): return self.sendj({'error':'version must match VxRxCx'},400)
|
||||
try:
|
||||
validate_version(ver)
|
||||
except ValueError:
|
||||
return self.sendj({'error':'version must match VxRxCx'},400)
|
||||
if not data.get('target_oid') or not data.get('evidence_closure_oid') or not data.get('approval_plan_id'):
|
||||
return self.sendj({
|
||||
'error':'production_tag_evidence_required',
|
||||
@@ -1227,6 +1098,7 @@ def api_spec():
|
||||
|
||||
|
||||
def main():
|
||||
validate_gitea_api_url(os.getenv('AIOS_GITOPS_GITEA_API_URL', ''))
|
||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
||||
if __name__ == '__main__': main()
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small staging-only response overlay for the legacy listPatches route.
|
||||
|
||||
The base staging module remains the source of truth for routing, auth, and
|
||||
governed write behavior. This entrypoint only replaces the handler's JSON
|
||||
serializer for the patch-read aliases, projecting the legacy payload into a
|
||||
bounded, cursor-pageable summary while leaving every other route untouched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import urllib.parse
|
||||
from typing import Any
|
||||
|
||||
import aios_model_api_staging as base
|
||||
|
||||
|
||||
PATCH_PATHS = frozenset({"/api/patch", "/api/patches", "/api/branches", "/patch"})
|
||||
PATCH_DEFAULT_LIMIT = 20
|
||||
PATCH_MAX_LIMIT = 100
|
||||
PATCH_RESPONSE_BUDGET = 128 * 1024
|
||||
PATCH_FIELD_MAX_CHARS = 256
|
||||
PATCH_SUMMARY_MAX_CHARS = 1024
|
||||
PATCH_COMPAT_LIST_LIMIT = 20
|
||||
_TOKEN_RE = re.compile(r"(?i)(?:sk-|ghp_|bearer\s+)[A-Za-z0-9._:/+=-]{12,}")
|
||||
|
||||
|
||||
def _text(value: Any, limit: int = PATCH_FIELD_MAX_CHARS) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (dict, list)):
|
||||
value = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
value = _TOKEN_RE.sub("[REDACTED]", str(value))
|
||||
return value if len(value) <= limit else value[: max(1, limit - 1)] + "…"
|
||||
|
||||
|
||||
def _record_id(record: dict[str, Any], index: int) -> str:
|
||||
return _text(
|
||||
record.get("patch_id")
|
||||
or record.get("id")
|
||||
or record.get("patch_name")
|
||||
or record.get("branch")
|
||||
or f"patch-{index}",
|
||||
)
|
||||
|
||||
|
||||
def _record_key(record: dict[str, Any], index: int) -> tuple[bool, str, str, int]:
|
||||
timestamp = _text(
|
||||
record.get("updated_at")
|
||||
or record.get("updatedAt")
|
||||
or record.get("created_at")
|
||||
or record.get("createdAt")
|
||||
)
|
||||
return bool(timestamp), timestamp, _record_id(record, index), index
|
||||
|
||||
|
||||
def _summary(record: dict[str, Any], index: int, summary_limit: int) -> dict[str, Any]:
|
||||
patch_id = _record_id(record, index)
|
||||
raw_summary = next(
|
||||
(
|
||||
record.get(key)
|
||||
for key in ("summary", "description", "text", "intent")
|
||||
if record.get(key) not in (None, "")
|
||||
),
|
||||
"",
|
||||
)
|
||||
summary = _text(raw_summary, summary_limit)
|
||||
item = {
|
||||
"patch_id": patch_id,
|
||||
"name": _text(record.get("name") or record.get("title") or record.get("patch_name") or patch_id),
|
||||
"branch": _text(record.get("branch")) or None,
|
||||
"ref": _text(record.get("ref") or record.get("branch")) or None,
|
||||
"status": _text(record.get("status") or record.get("state") or "recorded"),
|
||||
"risk": _text(record.get("risk")) or None,
|
||||
"created_at": _text(record.get("created_at") or record.get("createdAt")) or None,
|
||||
"updated_at": _text(record.get("updated_at") or record.get("updatedAt")) or None,
|
||||
"commit_oid": _text(record.get("commit_oid") or record.get("commit") or record.get("oid"), 128) or None,
|
||||
"summary": summary,
|
||||
}
|
||||
if len(_text(raw_summary, PATCH_SUMMARY_MAX_CHARS + 1)) > summary_limit:
|
||||
item["summary_truncated"] = True
|
||||
return item
|
||||
|
||||
|
||||
def _branch_summary(record: Any, index: int) -> Any:
|
||||
if not isinstance(record, dict):
|
||||
return _text(record)
|
||||
return {
|
||||
"name": _text(record.get("name") or record.get("branch") or f"branch-{index}"),
|
||||
"ref": _text(record.get("ref") or record.get("name") or record.get("branch")) or None,
|
||||
"oid": _text(record.get("oid") or record.get("commit_oid") or record.get("commit"), 128) or None,
|
||||
"updated_at": _text(record.get("updated_at") or record.get("updatedAt")) or None,
|
||||
}
|
||||
|
||||
|
||||
def _encode_cursor(key: tuple[bool, str, str, int]) -> str:
|
||||
raw = json.dumps(
|
||||
{"has_timestamp": key[0], "timestamp": key[1], "patch_id": key[2], "index": key[3]},
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _decode_cursor(value: Any) -> tuple[bool, str, str, int]:
|
||||
if not isinstance(value, str) or not value or len(value) > 256 or re.fullmatch(r"[A-Za-z0-9_-]+", value) is None:
|
||||
raise ValueError("invalid_cursor")
|
||||
try:
|
||||
padded = value + "=" * (-len(value) % 4)
|
||||
decoded = json.loads(base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise ValueError("invalid_cursor") from exc
|
||||
if (
|
||||
not isinstance(decoded, dict)
|
||||
or set(decoded) != {"has_timestamp", "timestamp", "patch_id", "index"}
|
||||
or not isinstance(decoded["has_timestamp"], bool)
|
||||
or not isinstance(decoded["timestamp"], str)
|
||||
or not isinstance(decoded["patch_id"], str)
|
||||
or not isinstance(decoded["index"], int)
|
||||
or decoded["index"] < 0
|
||||
):
|
||||
raise ValueError("invalid_cursor")
|
||||
return decoded["has_timestamp"], decoded["timestamp"], decoded["patch_id"], decoded["index"]
|
||||
|
||||
|
||||
def _limit(query: dict[str, list[str]]) -> int:
|
||||
raw = (query.get("limit") or [str(PATCH_DEFAULT_LIMIT)])[0]
|
||||
try:
|
||||
result = int(raw)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("invalid_limit") from exc
|
||||
if not 1 <= result <= PATCH_MAX_LIMIT:
|
||||
raise ValueError("invalid_limit")
|
||||
return result
|
||||
|
||||
|
||||
def _page(records: list[Any], query: dict[str, list[str]], summary_limit: int = PATCH_SUMMARY_MAX_CHARS) -> dict[str, Any]:
|
||||
limit = _limit(query)
|
||||
rows = [
|
||||
(record, index, _record_key(record, index))
|
||||
for index, record in enumerate(records)
|
||||
if isinstance(record, dict)
|
||||
]
|
||||
rows.sort(key=lambda row: row[2], reverse=True)
|
||||
cursor = (query.get("cursor") or [None])[0]
|
||||
if cursor:
|
||||
cursor_key = _decode_cursor(cursor)
|
||||
if cursor_key not in {row[2] for row in rows}:
|
||||
raise ValueError("invalid_cursor")
|
||||
rows = [row for row in rows if row[2] < cursor_key]
|
||||
selected = rows[:limit]
|
||||
return {
|
||||
"items": [_summary(record, index, summary_limit) for record, index, _ in selected],
|
||||
"next_cursor": _encode_cursor(selected[-1][2]) if len(rows) > len(selected) and selected else None,
|
||||
"has_more": len(rows) > len(selected),
|
||||
"count": len(selected),
|
||||
"_cursor_keys": [key for _, _, key in selected],
|
||||
}
|
||||
|
||||
|
||||
def _bounded_payload(data: dict[str, Any], query: dict[str, list[str]]) -> dict[str, Any]:
|
||||
records = data.get("metadata_patches")
|
||||
if not isinstance(records, list):
|
||||
records = data.get("patches") if isinstance(data.get("patches"), list) else []
|
||||
page = _page(records, query)
|
||||
cursor_keys = page.pop("_cursor_keys")
|
||||
payload = {
|
||||
key: data[key]
|
||||
for key in ("repository_available", "query_error")
|
||||
if key in data
|
||||
}
|
||||
payload["metadata_patches"] = page["items"]
|
||||
payload["patches"] = page["items"]
|
||||
for key in ("orphan_metadata", "branches_without_metadata"):
|
||||
values = data.get(key)
|
||||
payload[key] = [
|
||||
_summary(value, index, PATCH_SUMMARY_MAX_CHARS)
|
||||
if isinstance(value, dict)
|
||||
else _text(value)
|
||||
for index, value in enumerate(values[:PATCH_COMPAT_LIST_LIMIT])
|
||||
] if isinstance(values, list) else []
|
||||
branches = data.get("authoritative_branches")
|
||||
if isinstance(branches, list):
|
||||
payload["authoritative_branches"] = [
|
||||
_branch_summary(value, index) for index, value in enumerate(branches[:PATCH_COMPAT_LIST_LIMIT])
|
||||
]
|
||||
if isinstance(data.get("branches"), list):
|
||||
payload["branches"] = [_text(value) for value in data["branches"][:PATCH_COMPAT_LIST_LIMIT]]
|
||||
payload.update(page)
|
||||
payload["list_patches_overlay"] = "bounded-summary-v1"
|
||||
|
||||
while len(json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")) > PATCH_RESPONSE_BUDGET and payload["items"]:
|
||||
payload["items"] = payload["items"][:-1]
|
||||
cursor_keys = cursor_keys[:-1]
|
||||
payload["metadata_patches"] = payload["items"]
|
||||
payload["patches"] = payload["items"]
|
||||
payload["count"] = len(payload["items"])
|
||||
payload["has_more"] = True
|
||||
payload["next_cursor"] = (
|
||||
_encode_cursor(cursor_keys[-1])
|
||||
if cursor_keys
|
||||
else None
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
class ListPatchesOverlayHandler(base.H):
|
||||
"""Delegate all behavior except bounded serialization of patch reads."""
|
||||
|
||||
def sendj(self, data: Any, code: int = 200):
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/")
|
||||
if path in PATCH_PATHS and code in (200, 503) and isinstance(data, dict):
|
||||
try:
|
||||
data = _bounded_payload(data, urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query))
|
||||
except ValueError as exc:
|
||||
return super().sendj(
|
||||
{"error": str(exc), "min_limit": 1, "max_limit": PATCH_MAX_LIMIT},
|
||||
400,
|
||||
)
|
||||
return super().sendj(data, code)
|
||||
|
||||
|
||||
base.H = ListPatchesOverlayHandler
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
base.main()
|
||||
@@ -1,528 +0,0 @@
|
||||
"""Canonical governed execution projection for Online Agent Tasks.
|
||||
|
||||
The durable governed binding is authoritative. Natural-language task fields and
|
||||
worker configuration are never allowed to invent repository, ref, generation, or
|
||||
idempotency coordinates.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
EXECUTION_SCHEMA = "aios.online-agent.execution.v2"
|
||||
PROJECTION_SCHEMA = "aios.online-agent.task-projection.v1"
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
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}$")
|
||||
KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,159}$")
|
||||
PROTECTED_PREFIXES = ("main", "master", "refs/", "tags/", "tag/", "release/", "baseline/", "production/")
|
||||
MUTATION_ACTIONS = frozenset({"deterministic"})
|
||||
READ_ONLY_ACTIONS = frozenset({"source_capture", "agent_runtime"})
|
||||
SUPPORTED_ACTIONS = frozenset({"deterministic", "source_capture", "agent_runtime"})
|
||||
AGENT_RUNTIME_ADAPTERS = frozenset({"hermes", "openclaw"})
|
||||
MAX_AGENT_RUNTIME_INPUT_BYTES = 64 * 1024
|
||||
|
||||
|
||||
class ExecutionProjectionError(RuntimeError):
|
||||
"""A canonical fail-closed projection or validation error."""
|
||||
|
||||
|
||||
class OnlineTaskProjectionClient:
|
||||
"""Official HTTP projection client using only a control credential reference."""
|
||||
|
||||
def __init__(self, api_base: str, control_token_path: Path) -> None:
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.control_token_path = Path(control_token_path)
|
||||
|
||||
def _token(self) -> str:
|
||||
path = self.control_token_path
|
||||
if path.is_symlink() or not path.is_file():
|
||||
_error("ONLINE_TASK_PROJECTION_CONTROL_UNAVAILABLE")
|
||||
value = path.read_text(encoding="utf-8").strip()
|
||||
if len(value) < 32:
|
||||
_error("ONLINE_TASK_PROJECTION_CONTROL_INVALID")
|
||||
return value
|
||||
|
||||
def create(
|
||||
self,
|
||||
projected_task: Mapping[str, Any],
|
||||
*,
|
||||
binding: "GovernedExecutionBinding",
|
||||
) -> dict[str, Any]:
|
||||
body = dict(projected_task)
|
||||
payload = _canonical(body).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
self.api_base + "/v1/tasks",
|
||||
method="POST",
|
||||
data=payload,
|
||||
headers={
|
||||
"Authorization": "Bearer " + self._token(),
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
status = response.status
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read(8192).decode("utf-8", "replace")
|
||||
try:
|
||||
error = json.loads(raw).get("error")
|
||||
except (AttributeError, json.JSONDecodeError):
|
||||
error = "HTTP_ERROR"
|
||||
raise ExecutionProjectionError(f"ONLINE_TASK_PROJECTION_FAILED:{exc.code}:{error}") from exc
|
||||
except (OSError, urllib.error.URLError, TimeoutError) as exc:
|
||||
raise ExecutionProjectionError("ONLINE_TASK_PROJECTION_UNAVAILABLE") from exc
|
||||
if status not in {200, 201} or not isinstance(result, dict):
|
||||
_error("ONLINE_TASK_PROJECTION_RESPONSE_INVALID")
|
||||
if result.get("task_id") != binding.task_id or result.get("status") not in {"QUEUED", "VERIFYING", "SUCCEEDED"}:
|
||||
_error("ONLINE_TASK_PROJECTION_RESPONSE_INVALID")
|
||||
metadata = result.get("metadata")
|
||||
if not isinstance(metadata, Mapping):
|
||||
_error("ONLINE_TASK_PROJECTION_RESPONSE_INVALID")
|
||||
returned_execution = validate_execution_envelope(
|
||||
_object(metadata.get("execution"), "ONLINE_TASK_PROJECTION_RESPONSE_INVALID"),
|
||||
binding=binding,
|
||||
)
|
||||
submitted_metadata = _object(body.get("metadata"), "ONLINE_TASK_PROJECTION_REQUEST_INVALID")
|
||||
submitted_execution = validate_execution_envelope(
|
||||
_object(submitted_metadata.get("execution"), "ONLINE_TASK_PROJECTION_REQUEST_INVALID"),
|
||||
binding=binding,
|
||||
)
|
||||
if _canonical(returned_execution) != _canonical(submitted_execution):
|
||||
_error("ONLINE_TASK_PROJECTION_RESPONSE_BINDING_INVALID")
|
||||
return result
|
||||
|
||||
|
||||
def _error(code: str) -> None:
|
||||
raise ExecutionProjectionError(code)
|
||||
|
||||
|
||||
def _object(value: object, code: str) -> dict[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
_error(code)
|
||||
return dict(value)
|
||||
|
||||
|
||||
def _string(value: object, code: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
_error(code)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _task_id(value: object) -> str:
|
||||
value = _string(value, "TASK_ID_INVALID")
|
||||
if TASK_RE.fullmatch(value) is None:
|
||||
_error("TASK_ID_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _oid(value: object, code: str) -> str:
|
||||
value = _string(value, code)
|
||||
if OID_RE.fullmatch(value) is None:
|
||||
_error(code)
|
||||
return value
|
||||
|
||||
|
||||
def _branch(value: object) -> str:
|
||||
value = _string(value, "TARGET_BRANCH_INVALID")
|
||||
if BRANCH_RE.fullmatch(value) is None or value.startswith(PROTECTED_PREFIXES):
|
||||
_error("TARGET_BRANCH_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _generation(value: object) -> int:
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
|
||||
_error("GENERATION_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _canonical(value: object) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _digest(value: object) -> str:
|
||||
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _source_signals(value: object) -> tuple[str, ...]:
|
||||
"""Normalize untrusted natural-language hints before they enter a signed envelope."""
|
||||
if value is None:
|
||||
return ()
|
||||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||
_error("SOURCE_CAPTURE_SIGNALS_INVALID")
|
||||
result: list[str] = []
|
||||
for item in value:
|
||||
if not isinstance(item, str):
|
||||
_error("SOURCE_CAPTURE_SIGNALS_INVALID")
|
||||
text = " ".join(item.split()).strip()
|
||||
if not text:
|
||||
continue
|
||||
# Projection may accept long task prose, but execution envelopes stay bounded.
|
||||
text = text[:512].rstrip()
|
||||
if text not in result:
|
||||
result.append(text)
|
||||
if len(result) == 16:
|
||||
break
|
||||
return tuple(result)
|
||||
|
||||
|
||||
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",
|
||||
"scope", "trace_id",
|
||||
}
|
||||
if set(raw) - allowed:
|
||||
_error("AGENT_RUNTIME_INPUT_FIELDS_INVALID")
|
||||
goal = _string(raw.get("goal"), "AGENT_RUNTIME_GOAL_REQUIRED")
|
||||
instructions = raw.get("instructions", "")
|
||||
if not isinstance(instructions, str):
|
||||
_error("AGENT_RUNTIME_INSTRUCTIONS_INVALID")
|
||||
criteria = raw.get("success_criteria", [])
|
||||
if not isinstance(criteria, list) or len(criteria) > 32:
|
||||
_error("AGENT_RUNTIME_SUCCESS_CRITERIA_INVALID")
|
||||
normalized_criteria: list[dict[str, str]] = []
|
||||
for item in criteria:
|
||||
if not isinstance(item, Mapping):
|
||||
_error("AGENT_RUNTIME_SUCCESS_CRITERIA_INVALID")
|
||||
criterion_id = _string(item.get("id"), "AGENT_RUNTIME_SUCCESS_CRITERIA_INVALID")
|
||||
description = _string(
|
||||
item.get("description"), "AGENT_RUNTIME_SUCCESS_CRITERIA_INVALID"
|
||||
)
|
||||
normalized_criteria.append(
|
||||
{"id": criterion_id[:120], "description": description[:1000]}
|
||||
)
|
||||
constraints = raw.get("constraints", {})
|
||||
if not isinstance(constraints, Mapping):
|
||||
_error("AGENT_RUNTIME_CONSTRAINTS_INVALID")
|
||||
sensitive_markers = ("token", "secret", "password", "credential", "authorization", "api_key", "apikey")
|
||||
for key in constraints:
|
||||
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")
|
||||
return normalized
|
||||
|
||||
|
||||
def _capabilities(value: object) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return ()
|
||||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||
_error("CAPABILITY_SCOPE_INVALID")
|
||||
result = tuple(sorted({_string(item, "CAPABILITY_SCOPE_INVALID") for item in value}))
|
||||
if any(len(item) > 120 for item in result):
|
||||
_error("CAPABILITY_SCOPE_INVALID")
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovernedExecutionBinding:
|
||||
task_id: str
|
||||
job_id: str
|
||||
repository: str
|
||||
base_oid: str
|
||||
expected_branch_oid: str
|
||||
target_branch: str
|
||||
branch_mode: str
|
||||
generation: int
|
||||
idempotency_key: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "GovernedExecutionBinding":
|
||||
data = dict(value)
|
||||
task_id = _task_id(data.get("task_id"))
|
||||
job_id = _string(data.get("job_id"), "JOB_ID_REQUIRED")
|
||||
if TASK_RE.fullmatch(job_id) is None:
|
||||
_error("JOB_ID_INVALID")
|
||||
repository = _string(data.get("repository"), "REPOSITORY_INVALID")
|
||||
if repository != "aios/aios":
|
||||
_error("WRONG_REPOSITORY_ENVELOPE_DENY")
|
||||
base_oid = _oid(data.get("base_oid"), "BASE_OID_INVALID")
|
||||
expected = _oid(data.get("expected_branch_oid"), "EXPECTED_BRANCH_OID_INVALID")
|
||||
target = _branch(data.get("target_branch"))
|
||||
mode = _string(data.get("branch_mode"), "BRANCH_MODE_MISSING")
|
||||
if mode not in {"CREATE_NEW", "CONTINUE_EXISTING"}:
|
||||
_error("BRANCH_MODE_INVALID")
|
||||
generation = _generation(data.get("generation"))
|
||||
if mode == "CREATE_NEW" and expected != base_oid:
|
||||
_error("EXPECTED_BASE_CONFLICT")
|
||||
key = _string(data.get("idempotency_key"), "IDEMPOTENCY_KEY_INVALID")
|
||||
if KEY_RE.fullmatch(key) is None:
|
||||
_error("IDEMPOTENCY_KEY_INVALID")
|
||||
return cls(task_id, job_id, repository, base_oid, expected, target, mode, generation, key)
|
||||
|
||||
def public(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def binding_from_bootstrap(value: Mapping[str, Any]) -> GovernedExecutionBinding:
|
||||
return GovernedExecutionBinding.from_mapping(value)
|
||||
|
||||
|
||||
def validate_execution_envelope(
|
||||
value: Mapping[str, Any],
|
||||
*,
|
||||
binding: GovernedExecutionBinding | None = None,
|
||||
worker_capabilities: Sequence[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
envelope = dict(value)
|
||||
if envelope.get("schema") != EXECUTION_SCHEMA:
|
||||
_error("EXECUTION_ENVELOPE_SCHEMA_VALIDATION")
|
||||
candidate = GovernedExecutionBinding.from_mapping(envelope)
|
||||
if binding is not None and candidate != binding:
|
||||
comparisons = (
|
||||
(candidate.task_id, binding.task_id, "TASK_BINDING_CONFLICT"),
|
||||
(candidate.job_id, binding.job_id, "JOB_BINDING_CONFLICT"),
|
||||
(candidate.repository, binding.repository, "WRONG_REPOSITORY_ENVELOPE_DENY"),
|
||||
(candidate.base_oid, binding.base_oid, "WRONG_BASE_ENVELOPE_DENY"),
|
||||
(candidate.expected_branch_oid, binding.expected_branch_oid, "WRONG_EXPECTED_HEAD_ENVELOPE_DENY"),
|
||||
(candidate.target_branch, binding.target_branch, "WRONG_BRANCH_ENVELOPE_DENY"),
|
||||
(candidate.generation, binding.generation, "WRONG_GENERATION_ENVELOPE_DENY"),
|
||||
(candidate.branch_mode, binding.branch_mode, "BRANCH_MODE_BINDING_CONFLICT"),
|
||||
(candidate.idempotency_key, binding.idempotency_key, "IDEMPOTENCY_BINDING_CONFLICT"),
|
||||
)
|
||||
for actual, expected, code in comparisons:
|
||||
if actual != expected:
|
||||
_error(code)
|
||||
action = _string(envelope.get("action"), "EXECUTION_ACTION_MISSING")
|
||||
if action not in SUPPORTED_ACTIONS:
|
||||
_error("EXECUTION_ACTION_UNSUPPORTED")
|
||||
capability_scope = _capabilities(envelope.get("capability_scope"))
|
||||
if worker_capabilities is not None and not set(capability_scope).issubset(set(worker_capabilities)):
|
||||
_error("CAPABILITY_MISMATCH_DENY")
|
||||
workspace = _object(envelope.get("workspace"), "WORKSPACE_POLICY_REQUIRED")
|
||||
if workspace.get("authority_git_dir") != "/data/aios-gitops/repo.git" or workspace.get("read_only_repository") is not True:
|
||||
_error("WORKSPACE_POLICY_DENY")
|
||||
governance = _object(envelope.get("governance"), "GOVERNANCE_POLICY_REQUIRED")
|
||||
governed_coordinates = (
|
||||
(governance.get("task_id"), candidate.task_id, "TASK_BINDING_CONFLICT"),
|
||||
(governance.get("job_id"), candidate.job_id, "JOB_BINDING_CONFLICT"),
|
||||
(governance.get("idempotency_key"), candidate.idempotency_key, "IDEMPOTENCY_BINDING_CONFLICT"),
|
||||
)
|
||||
for actual_coordinate, expected_coordinate, code in governed_coordinates:
|
||||
if actual_coordinate != expected_coordinate:
|
||||
_error(code)
|
||||
commit_key = _string(governance.get("commit_idempotency_key"), "COMMIT_IDEMPOTENCY_KEY_INVALID")
|
||||
if KEY_RE.fullmatch(commit_key) is None:
|
||||
_error("COMMIT_IDEMPOTENCY_KEY_INVALID")
|
||||
if governance.get("writer_lease") is not (action in MUTATION_ACTIONS):
|
||||
_error("WRITER_LEASE_POLICY_DENY")
|
||||
for field in ("main_write", "production_write", "tag_write", "baseline_write", "force_push", "protected_ref_bypass"):
|
||||
if governance.get(field, False) is not False:
|
||||
_error("POLICY_DENY")
|
||||
changes = envelope.get("change_set")
|
||||
verification = envelope.get("verification")
|
||||
if not isinstance(changes, list) or not isinstance(verification, list):
|
||||
_error("EXECUTION_OPERATIONS_INVALID")
|
||||
if action == "source_capture":
|
||||
if changes or verification:
|
||||
_error("SOURCE_CAPTURE_MUTATION_DENY")
|
||||
signals = list(_source_signals(envelope.get("source_signals") or []))
|
||||
if envelope.get("source_signals") != signals:
|
||||
_error("SOURCE_CAPTURE_SIGNALS_INVALID")
|
||||
if envelope.get("runtime_input") not in (None, {}):
|
||||
_error("SOURCE_CAPTURE_RUNTIME_INPUT_DENY")
|
||||
elif action == "agent_runtime":
|
||||
if changes or verification:
|
||||
_error("AGENT_RUNTIME_MUTATION_DENY")
|
||||
adapter = str(envelope.get("adapter") or "")
|
||||
if adapter not in AGENT_RUNTIME_ADAPTERS:
|
||||
_error("AGENT_RUNTIME_ADAPTER_INVALID")
|
||||
runtime_input = _agent_runtime_input(envelope.get("runtime_input"))
|
||||
if envelope.get("runtime_input") != runtime_input:
|
||||
_error("AGENT_RUNTIME_INPUT_NOT_CANONICAL")
|
||||
required_runtime_capability = f"agent.runtime.{adapter}"
|
||||
if required_runtime_capability not in capability_scope:
|
||||
_error("AGENT_RUNTIME_CAPABILITY_REQUIRED")
|
||||
if envelope.get("source_signals") not in (None, []):
|
||||
_error("AGENT_RUNTIME_SOURCE_SIGNALS_DENY")
|
||||
elif not changes:
|
||||
_error("CHANGE_SET_INVALID")
|
||||
declared = str(envelope.get("execution_digest") or "")
|
||||
material = {key: item for key, item in envelope.items() if key != "execution_digest"}
|
||||
actual = _digest(material)
|
||||
if declared != actual:
|
||||
_error("EXECUTION_DIGEST_INVALID")
|
||||
return {**material, "execution_digest": actual}
|
||||
|
||||
|
||||
def project_execution_envelope(
|
||||
binding: GovernedExecutionBinding,
|
||||
execution_request: Mapping[str, Any] | None = None,
|
||||
*,
|
||||
required_capabilities: Sequence[str] = (),
|
||||
) -> dict[str, Any]:
|
||||
request = dict(execution_request or {})
|
||||
action = str(request.get("action") or "source_capture")
|
||||
if action not in SUPPORTED_ACTIONS:
|
||||
_error("EXECUTION_ACTION_UNSUPPORTED")
|
||||
capabilities = _capabilities(request.get("capability_scope", required_capabilities))
|
||||
governance = {
|
||||
"task_id": binding.task_id,
|
||||
"job_id": binding.job_id,
|
||||
"idempotency_key": binding.idempotency_key,
|
||||
"commit_idempotency_key": str(request.get("commit_idempotency_key") or f"{binding.task_id}-commit-g{binding.generation}"),
|
||||
"writer_lease": action in MUTATION_ACTIONS,
|
||||
"main_write": False,
|
||||
"production_write": False,
|
||||
"tag_write": False,
|
||||
"baseline_write": False,
|
||||
"force_push": False,
|
||||
"protected_ref_bypass": False,
|
||||
}
|
||||
supplied_governance = request.get("governance")
|
||||
if supplied_governance is not None:
|
||||
governance.update(_object(supplied_governance, "GOVERNANCE_POLICY_REQUIRED"))
|
||||
material: dict[str, Any] = {
|
||||
"schema": EXECUTION_SCHEMA,
|
||||
**binding.public(),
|
||||
"action": action,
|
||||
"adapter": str(
|
||||
request.get("adapter")
|
||||
or ("deterministic" if action == "deterministic" else "source_capture")
|
||||
),
|
||||
"capability_scope": list(capabilities),
|
||||
"change_set": list(request.get("change_set") or []),
|
||||
"verification": list(request.get("verification") or []),
|
||||
"source_signals": list(_source_signals(request.get("source_signals") or [])) if action == "source_capture" else [],
|
||||
"runtime_input": _agent_runtime_input(request.get("runtime_input")) if action == "agent_runtime" else {},
|
||||
"governance": governance,
|
||||
"workspace": {
|
||||
"authority_git_dir": "/data/aios-gitops/repo.git",
|
||||
"read_only_repository": True,
|
||||
"cleanup_policy": "always",
|
||||
},
|
||||
}
|
||||
envelope = {**material, "execution_digest": _digest(material)}
|
||||
return validate_execution_envelope(envelope, binding=binding)
|
||||
|
||||
|
||||
def project_online_task(
|
||||
binding: GovernedExecutionBinding,
|
||||
task_request: Mapping[str, Any],
|
||||
execution_request: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
request = dict(task_request)
|
||||
required = _capabilities(request.get("required_capabilities"))
|
||||
execution_value = dict(execution_request or {})
|
||||
if str(execution_value.get("action") or "source_capture") == "agent_runtime":
|
||||
adapter = str(execution_value.get("adapter") or "")
|
||||
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"),
|
||||
"instructions": request.get("instructions") or "",
|
||||
"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,
|
||||
execution_value,
|
||||
required_capabilities=required,
|
||||
)
|
||||
metadata = dict(request.get("metadata") or {})
|
||||
if "execution" in metadata:
|
||||
validate_execution_envelope(_object(metadata["execution"], "EXECUTION_ENVELOPE_INVALID"), binding=binding)
|
||||
if metadata["execution"] != execution:
|
||||
_error("EXECUTION_PROJECTION_CONFLICT")
|
||||
metadata["execution"] = execution
|
||||
metadata["execution_projection"] = {
|
||||
"schema": PROJECTION_SCHEMA,
|
||||
"task_id": binding.task_id,
|
||||
"job_id": binding.job_id,
|
||||
"execution_digest": execution["execution_digest"],
|
||||
}
|
||||
return {
|
||||
**request,
|
||||
"task_id": binding.task_id,
|
||||
"metadata": metadata,
|
||||
"required_capabilities": list(required),
|
||||
"idempotency_key": str(request.get("idempotency_key") or f"online-task:{binding.idempotency_key}"),
|
||||
}
|
||||
|
||||
|
||||
def resolve_task_execution(
|
||||
task: Mapping[str, Any],
|
||||
binding: GovernedExecutionBinding,
|
||||
*,
|
||||
worker_capabilities: Sequence[str] | None = None,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
metadata = task.get("metadata")
|
||||
if isinstance(metadata, Mapping) and "execution" in metadata:
|
||||
value = validate_execution_envelope(
|
||||
_object(metadata["execution"], "EXECUTION_ENVELOPE_INVALID"),
|
||||
binding=binding,
|
||||
worker_capabilities=worker_capabilities,
|
||||
)
|
||||
return value, "persisted"
|
||||
projected = project_execution_envelope(
|
||||
binding,
|
||||
{"action": "source_capture", "capability_scope": task.get("required_capabilities") or []},
|
||||
required_capabilities=task.get("required_capabilities") or [],
|
||||
)
|
||||
validate_execution_envelope(projected, binding=binding, worker_capabilities=worker_capabilities)
|
||||
return projected, "recovered_projection"
|
||||
@@ -1,148 +0,0 @@
|
||||
"""Fail-closed signed deterministic execution amendment for R7."""
|
||||
from datetime import datetime, timezone
|
||||
import hashlib, json, sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
from aios_task_terminal_recovery import DeterministicPlan, RecoveryError, canonical_json
|
||||
|
||||
AMENDMENT_SCHEMA="aios.deterministic-execution-amendment.v1"
|
||||
APPLY_SCHEMA="aios.deterministic-execution-amendment-apply.v1"
|
||||
NAMESPACE="aios-owner-approval-v1"
|
||||
EVENT="TASK_RECOVERY_EXECUTION_AMENDMENT_APPLIED"
|
||||
APPLY_STATES=frozenset({"FAILED", "QUEUED"})
|
||||
|
||||
|
||||
def _err(code): raise RecoveryError(code)
|
||||
def _sha(v): return hashlib.sha256(canonical_json(v).encode()).hexdigest()
|
||||
def amendment_body(x):
|
||||
return {k:v for k,v in x.items() if k not in {"signature","amendment_id"}}
|
||||
def amendment_id(x): return "sha256:"+_sha(amendment_body(x))
|
||||
def signed_payload(x):
|
||||
return canonical_json({"amendment_id":x["amendment_id"],**amendment_body(x)}).encode()
|
||||
|
||||
|
||||
def parse_amendment(raw):
|
||||
x=dict(raw)
|
||||
required={"schema","amendment_id","nonce","task_id","job_id","generation",
|
||||
"repository","branch","expected_branch_oid","recovery_epoch","decision_id",
|
||||
"plan_id","plan_sha256","expected_apply_status","expected_apply_revision",
|
||||
"expected_apply_attempt","expected_current_capabilities","envelope",
|
||||
"owner_identity","key_id","issued_at","not_before","expires_at",
|
||||
"one_time_use","signature"}
|
||||
if set(x)<required or x.get("schema")!=AMENDMENT_SCHEMA: _err("AMENDMENT_SCHEMA_INVALID")
|
||||
if x["expected_apply_status"] not in APPLY_STATES: _err("AMENDMENT_APPLY_STATUS_INVALID")
|
||||
if x.get("one_time_use") is not True: _err("AMENDMENT_ONE_TIME_REQUIRED")
|
||||
if not isinstance(x["envelope"],Mapping): _err("AMENDMENT_ENVELOPE_INVALID")
|
||||
if not isinstance(x["expected_current_capabilities"],list): _err("AMENDMENT_CAPABILITIES_INVALID")
|
||||
for k in ("generation","expected_apply_revision","expected_apply_attempt"):
|
||||
if isinstance(x[k],bool) or not isinstance(x[k],int) or x[k]<0: _err("AMENDMENT_CAS_INVALID")
|
||||
for k in ("issued_at","not_before","expires_at"):
|
||||
try:d=datetime.fromisoformat(str(x[k]).replace("Z","+00:00"))
|
||||
except ValueError as e: raise RecoveryError("AMENDMENT_TIME_INVALID") from e
|
||||
if d.tzinfo is None:_err("AMENDMENT_TIME_INVALID")
|
||||
x[k]=d.astimezone(timezone.utc).isoformat().replace("+00:00","Z")
|
||||
x["expected_current_capabilities"]=sorted(set(x["expected_current_capabilities"]))
|
||||
x["envelope"]=dict(x["envelope"])
|
||||
return x
|
||||
|
||||
|
||||
def _validate_envelope(env,plan,x):
|
||||
try:
|
||||
from aios_online_agent_execution import GovernedExecutionBinding,validate_execution_envelope
|
||||
binding=GovernedExecutionBinding.from_mapping(env)
|
||||
e=dict(validate_execution_envelope(env,binding=binding,worker_capabilities=plan.required_capabilities))
|
||||
except Exception as z: raise RecoveryError("AMENDMENT_EXECUTION_ENVELOPE_INVALID") from z
|
||||
if e.get("action")!="deterministic" or e.get("adapter")!="deterministic":_err("AMENDMENT_EXECUTION_ACTION_INVALID")
|
||||
if tuple(sorted(e.get("capability_scope") or ()))!=plan.required_capabilities:_err("AMENDMENT_EXECUTION_CAPABILITIES_MISMATCH")
|
||||
if not e.get("change_set") or not e.get("verification"):_err("AMENDMENT_EXECUTION_OPERATIONS_REQUIRED")
|
||||
if e.get("runtime_input") not in (None,{}) or e.get("source_signals") not in (None,[]):_err("AMENDMENT_STALE_RUNTIME_FIELDS_FORBIDDEN")
|
||||
for k,w in (("task_id",x["task_id"]),("job_id",x["job_id"]),("repository",x["repository"]),
|
||||
("generation",x["generation"]),("target_branch",x["branch"]),
|
||||
("expected_branch_oid",x["expected_branch_oid"]),("base_oid",x["expected_branch_oid"]),
|
||||
("plan_id",x["plan_id"]),("plan_sha256",x["plan_sha256"]),("recovery_epoch",x["recovery_epoch"])):
|
||||
if e.get(k)!=w:_err("AMENDMENT_EXECUTION_BINDING_MISMATCH")
|
||||
g=e.get("governance") or {}
|
||||
if g.get("writer_lease") is not True:_err("AMENDMENT_WRITER_LEASE_REQUIRED")
|
||||
if any(g.get(k,False) for k in ("main_write","production_write","tag_write","baseline_write","force_push","protected_ref_bypass")):_err("AMENDMENT_PROTECTED_WRITE_FORBIDDEN")
|
||||
return e
|
||||
|
||||
|
||||
class DeterministicExecutionAmendmentController:
|
||||
def __init__(self,db_path,verifier,*,clock=None,envelope_validator=None):
|
||||
self.db_path=Path(db_path); self.verifier=verifier
|
||||
self.clock=clock or (lambda:datetime.now(timezone.utc)); self._init()
|
||||
|
||||
def _db(self):
|
||||
c=sqlite3.connect(self.db_path,timeout=10,isolation_level=None); c.row_factory=sqlite3.Row
|
||||
c.execute("PRAGMA busy_timeout=10000"); return c
|
||||
|
||||
def _now(self): return self.clock().astimezone(timezone.utc).isoformat().replace("+00:00","Z")
|
||||
|
||||
def _init(self):
|
||||
with self._db() as c:
|
||||
c.execute("DROP TRIGGER IF EXISTS aios_r7_recovery_capability_projection")
|
||||
c.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS deterministic_execution_amendments(
|
||||
amendment_id TEXT PRIMARY KEY,nonce TEXT UNIQUE NOT NULL,task_id TEXT NOT NULL,
|
||||
decision_id TEXT NOT NULL,plan_id TEXT NOT NULL,plan_sha256 TEXT NOT NULL,
|
||||
envelope_digest TEXT NOT NULL,envelope_json TEXT NOT NULL,payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,UNIQUE(task_id,decision_id));
|
||||
DROP TRIGGER IF EXISTS aios_r7_recovery_requires_executable_amendment;
|
||||
CREATE TRIGGER aios_r7_recovery_requires_executable_amendment
|
||||
BEFORE UPDATE OF status ON tasks
|
||||
WHEN OLD.status='FAILED' AND NEW.status='QUEUED' BEGIN
|
||||
SELECT CASE WHEN NOT EXISTS(
|
||||
SELECT 1 FROM deterministic_execution_amendments a
|
||||
WHERE a.task_id=OLD.task_id
|
||||
AND a.decision_id=json_extract(NEW.metadata_json,'$.recovery.decision_id')
|
||||
AND a.amendment_id=json_extract(OLD.metadata_json,'$.recovery.execution_amendment_id')
|
||||
AND a.envelope_digest=json_extract(OLD.metadata_json,'$.execution.execution_digest')
|
||||
) THEN RAISE(ABORT,'SIGNED_EXECUTION_AMENDMENT_REQUIRED') END;
|
||||
END;""")
|
||||
|
||||
def _plan(self,c,x):
|
||||
r=c.execute("SELECT * FROM deterministic_plans WHERE plan_id=?",(x["plan_id"],)).fetchone()
|
||||
if r is None or r["plan_sha256"]!=x["plan_sha256"]:_err("AMENDMENT_PLAN_NOT_FOUND_OR_MISMATCH")
|
||||
p=DeterministicPlan.from_dict(json.loads(r["payload_json"]))
|
||||
if (p.task_id,p.job_id,p.generation,p.repository,p.branch,p.expected_branch_oid,p.recovery_epoch)!=(x["task_id"],x["job_id"],x["generation"],x["repository"],x["branch"],x["expected_branch_oid"],x["recovery_epoch"]):_err("AMENDMENT_PLAN_BINDING_MISMATCH")
|
||||
return p
|
||||
|
||||
def persist(self,raw):
|
||||
x=parse_amendment(raw)
|
||||
if x["amendment_id"]!=amendment_id(x):_err("AMENDMENT_ID_DIGEST_MISMATCH")
|
||||
now=self._now()
|
||||
if now<x["not_before"] or now>=x["expires_at"]:_err("AMENDMENT_EXPIRED_OR_NOT_YET_VALID")
|
||||
if not self.verifier.verify(namespace=NAMESPACE,payload=signed_payload(x),signature=x["signature"],key_id=x["key_id"]):_err("AMENDMENT_SIGNATURE_INVALID")
|
||||
with self._db() as c:
|
||||
c.execute("BEGIN IMMEDIATE"); e=_validate_envelope(x["envelope"],self._plan(c,x),x)
|
||||
ej=canonical_json(e); ed=e["execution_digest"]; stored=dict(x); stored["signature"]="<present>"
|
||||
old=c.execute("SELECT * FROM deterministic_execution_amendments WHERE amendment_id=?",(x["amendment_id"],)).fetchone()
|
||||
if old:
|
||||
if old["envelope_json"]!=ej:_err("AMENDMENT_ID_REUSE_MISMATCH")
|
||||
c.commit(); return {"status":"PRESENT","amendment_id":x["amendment_id"],"task_id":x["task_id"],"decision_id":x["decision_id"],"plan_id":x["plan_id"],"execution_digest":ed}
|
||||
if c.execute("SELECT 1 FROM deterministic_execution_amendments WHERE nonce=? OR (task_id=? AND decision_id=?)",(x["nonce"],x["task_id"],x["decision_id"])).fetchone():_err("AMENDMENT_REPLAY_OR_DECISION_REUSE")
|
||||
c.execute("INSERT INTO deterministic_execution_amendments VALUES(?,?,?,?,?,?,?,?,?,?)",(x["amendment_id"],x["nonce"],x["task_id"],x["decision_id"],x["plan_id"],x["plan_sha256"],ed,ej,canonical_json(stored),now)); c.commit()
|
||||
return {"status":"PERSISTED","amendment_id":x["amendment_id"],"task_id":x["task_id"],"decision_id":x["decision_id"],"plan_id":x["plan_id"],"execution_digest":ed}
|
||||
|
||||
def apply(self,raw):
|
||||
if raw.get("schema")!=APPLY_SCHEMA:_err("AMENDMENT_APPLY_SCHEMA_INVALID")
|
||||
aid=raw.get("amendment_id"); tid=raw.get("task_id")
|
||||
with self._db() as c:
|
||||
c.execute("BEGIN IMMEDIATE"); a=c.execute("SELECT * FROM deterministic_execution_amendments WHERE amendment_id=? AND task_id=?",(aid,tid)).fetchone()
|
||||
if a is None:_err("AMENDMENT_NOT_FOUND")
|
||||
x=json.loads(a["payload_json"]); d=json.loads(a["envelope_json"])
|
||||
t=c.execute("SELECT * FROM tasks WHERE task_id=?",(tid,)).fetchone()
|
||||
if t is None:_err("TASK_NOT_FOUND")
|
||||
m=json.loads(t["metadata_json"] or "{}"); cp=tuple(sorted(json.loads(t["required_capabilities_json"] or "[]"))); dc=tuple(sorted(d.get("capability_scope") or ()))
|
||||
rec=m.get("recovery"); rec=dict(rec) if isinstance(rec,Mapping) else {}
|
||||
old=m.get("execution")
|
||||
if isinstance(old,Mapping) and old.get("execution_digest")==a["envelope_digest"] and cp==dc and rec.get("execution_amendment_id")==aid:
|
||||
c.commit(); return {"status":"PRESENT","task_id":tid,"amendment_id":aid,"execution_digest":a["envelope_digest"],"revision":int(t["revision"]),"attempt":int(t["attempt"])}
|
||||
if t["status"]!=x["expected_apply_status"] or int(t["revision"])!=x["expected_apply_revision"] or int(t["attempt"])!=x["expected_apply_attempt"] or cp!=tuple(x["expected_current_capabilities"]) or t["claimed_by"] is not None or t["lease_token_hash"] is not None:_err("AMENDMENT_APPLY_TASK_CAS_MISMATCH")
|
||||
if t["status"]=="QUEUED" and rec.get("decision_id")!=x["decision_id"]:_err("AMENDMENT_DECISION_BINDING_MISMATCH")
|
||||
m["execution"]=d; m["execution_projection"]={"schema":"aios.online-agent.task-projection.v1","task_id":tid,"job_id":d.get("job_id"),"execution_digest":a["envelope_digest"]}
|
||||
rec["execution_amendment_id"]=aid; m["recovery"]=rec; now=self._now()
|
||||
q=c.execute("""UPDATE tasks SET required_capabilities_json=?,metadata_json=?,revision=revision+1,updated_at=? WHERE task_id=? AND status=? AND revision=? AND attempt=? AND claimed_by IS NULL AND lease_token_hash IS NULL""",(canonical_json(list(dc)),canonical_json(m),now,tid,x["expected_apply_status"],x["expected_apply_revision"],x["expected_apply_attempt"]))
|
||||
if q.rowcount!=1:_err("AMENDMENT_APPLY_TASK_CAS_FAILED")
|
||||
c.execute("INSERT INTO events(task_id,agent_id,event_type,payload_json,created_at) VALUES(?,?,?,?,?)",(tid,None,EVENT,canonical_json({"amendment_id":aid,"decision_id":x["decision_id"],"plan_id":x["plan_id"],"execution_digest":a["envelope_digest"],"required_capabilities":list(dc)}),now)); c.commit()
|
||||
return {"status":"APPLIED","task_id":tid,"amendment_id":aid,"decision_id":x["decision_id"],"plan_id":x["plan_id"],"execution_digest":a["envelope_digest"],"required_capabilities":list(dc),"revision":x["expected_apply_revision"]+1,"attempt":x["expected_apply_attempt"]}
|
||||
@@ -1,798 +0,0 @@
|
||||
#!/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 | None, source_commit: str) -> Mapping[str, str]:
|
||||
recipe = self.fixed_recipe(version)
|
||||
# Intent-only callers do not choose a candidate identity. The
|
||||
# policy-owned recipe supplies it; explicit worker requests are
|
||||
# still checked against the same immutable recipe.
|
||||
effective_candidate_id = str(candidate_id or recipe.get("candidate_id") or "")
|
||||
if {"version": recipe.get("version"), "candidate_id": recipe.get("candidate_id"), "source_commit": recipe.get("source_commit")} != {"version": version, "candidate_id": effective_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": "AIOS_TRUSTED_RELEASE_BUILDER",
|
||||
"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": "AIOS_TRUSTED_RELEASE_BUILDER",
|
||||
"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("AIOS_TRUSTED_RELEASE_BUILDER", 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 | None, 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 | None, 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="AIOS_TRUSTED_RELEASE_BUILDER",
|
||||
)
|
||||
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()
|
||||
@@ -31,8 +31,6 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from trusted_release_execution_plan import PlanError, TrustedReleasePlan
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
@@ -75,24 +73,6 @@ class Binding:
|
||||
production_port: int
|
||||
repository_identity: str = "aios/aios"
|
||||
|
||||
candidate_id: str | None = None
|
||||
release_plan_sha256: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_trusted_plan(cls, plan: TrustedReleasePlan) -> "Binding":
|
||||
value = plan.value
|
||||
adapter = value["adapter_policy"]
|
||||
binding = cls(
|
||||
version=str(value["version"]), target_oid=str(value["source_commit"]),
|
||||
evidence_closure_oid=str(value["evidence_closure_oid"]), artifact_sha256=str(value["artifact_sha256"]),
|
||||
approval_plan_id=str(value["approval_plan_id"]), artifact_path=str(value["artifact"]["path"]),
|
||||
artifact_size=int(value["artifact"]["size"]), production_service=str(adapter["service"]),
|
||||
production_port=int(adapter["port"]), candidate_id=str(value["candidate_id"]),
|
||||
release_plan_sha256=plan.release_plan_sha256,
|
||||
)
|
||||
plan.assert_binding(dataclasses.asdict(binding))
|
||||
return binding
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.version or not OID_RE.fullmatch(self.target_oid):
|
||||
raise ValueError("invalid release target binding")
|
||||
@@ -127,8 +107,6 @@ class Binding:
|
||||
"production_service": self.production_service,
|
||||
"production_port": self.production_port,
|
||||
"repository_identity": self.repository_identity,
|
||||
"candidate_id": self.candidate_id,
|
||||
"release_plan_sha256": self.release_plan_sha256,
|
||||
}
|
||||
return hashlib.sha256(canonical_json(payload)).hexdigest()
|
||||
|
||||
@@ -148,12 +126,9 @@ class Binding:
|
||||
"production_service": self.production_service,
|
||||
"production_port": self.production_port,
|
||||
"repository_identity": self.repository_identity,
|
||||
"candidate_id": self.candidate_id,
|
||||
"release_plan_sha256": self.release_plan_sha256,
|
||||
}
|
||||
|
||||
|
||||
|
||||
def canonical_json(value: object) -> bytes:
|
||||
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
||||
|
||||
@@ -307,12 +282,6 @@ def verify_authorization(receipt: Mapping[str, Any], binding: Binding) -> dict[s
|
||||
"artifact_sha256": binding.artifact_sha256,
|
||||
"approval_plan_id": binding.approval_plan_id,
|
||||
}
|
||||
if binding.candidate_id is not None:
|
||||
expected["candidate_id"] = binding.candidate_id
|
||||
if binding.release_plan_sha256 is not None:
|
||||
expected["release_plan_sha256"] = binding.release_plan_sha256
|
||||
if receipt.get("candidate_binding_sha256") is None:
|
||||
raise ManualOwnerReviewRequired("Owner authorization missing candidate binding")
|
||||
for key, value in expected.items():
|
||||
if receipt.get(key) != value:
|
||||
raise ManualOwnerReviewRequired(f"Owner authorization mismatch: {key}")
|
||||
@@ -529,14 +498,7 @@ def execute(
|
||||
deploy_command: Sequence[str],
|
||||
registry_command: Sequence[str],
|
||||
baseline_command: Sequence[str],
|
||||
release_plan_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
plan = TrustedReleasePlan.load(release_plan_path) if release_plan_path is not None else None
|
||||
if plan is not None:
|
||||
try:
|
||||
plan.assert_binding(dataclasses.asdict(binding))
|
||||
except PlanError as exc:
|
||||
raise ReleaseBlocked(f"trusted release plan mismatch: {exc}") from exc
|
||||
authorization = verify_authorization(secure_json(authorization_path), binding)
|
||||
lineage = verify_git_lineage(repository, binding)
|
||||
artifact = verify_artifact(binding)
|
||||
@@ -629,18 +591,17 @@ def execute(
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--authorization", required=True, type=Path)
|
||||
p.add_argument("--release-plan", type=Path)
|
||||
p.add_argument("--repository", required=True, type=Path)
|
||||
p.add_argument("--state-root", required=True, type=Path)
|
||||
p.add_argument("--artifact", type=Path)
|
||||
p.add_argument("--artifact-size", type=int)
|
||||
p.add_argument("--version")
|
||||
p.add_argument("--target-oid")
|
||||
p.add_argument("--evidence-closure-oid")
|
||||
p.add_argument("--artifact-sha256")
|
||||
p.add_argument("--approval-plan-id")
|
||||
p.add_argument("--production-service")
|
||||
p.add_argument("--production-port", type=int)
|
||||
p.add_argument("--artifact", required=True, type=Path)
|
||||
p.add_argument("--artifact-size", required=True, type=int)
|
||||
p.add_argument("--version", required=True)
|
||||
p.add_argument("--target-oid", required=True)
|
||||
p.add_argument("--evidence-closure-oid", required=True)
|
||||
p.add_argument("--artifact-sha256", required=True)
|
||||
p.add_argument("--approval-plan-id", required=True)
|
||||
p.add_argument("--production-service", required=True)
|
||||
p.add_argument("--production-port", required=True, type=int)
|
||||
p.add_argument("--repository-identity", default="aios/aios")
|
||||
p.add_argument("--tag-adapter", required=True, nargs="+")
|
||||
p.add_argument("--deploy-adapter", required=True, nargs="+")
|
||||
@@ -651,13 +612,18 @@ def parser() -> argparse.ArgumentParser:
|
||||
|
||||
def main() -> int:
|
||||
args = parser().parse_args()
|
||||
plan = TrustedReleasePlan.load(args.release_plan) if args.release_plan else None
|
||||
if plan is not None:
|
||||
binding = Binding.from_trusted_plan(plan)
|
||||
else:
|
||||
legacy=(args.artifact,args.artifact_size,args.version,args.target_oid,args.evidence_closure_oid,args.artifact_sha256,args.approval_plan_id,args.production_service,args.production_port)
|
||||
if any(value is None for value in legacy): raise SystemExit("legacy binding is incomplete")
|
||||
binding = Binding(version=args.version,target_oid=args.target_oid,evidence_closure_oid=args.evidence_closure_oid,artifact_sha256=args.artifact_sha256,approval_plan_id=args.approval_plan_id,artifact_path=str(args.artifact),artifact_size=args.artifact_size,production_service=args.production_service,production_port=args.production_port,repository_identity=args.repository_identity)
|
||||
binding = Binding(
|
||||
version=args.version,
|
||||
target_oid=args.target_oid,
|
||||
evidence_closure_oid=args.evidence_closure_oid,
|
||||
artifact_sha256=args.artifact_sha256,
|
||||
approval_plan_id=args.approval_plan_id,
|
||||
artifact_path=str(args.artifact),
|
||||
artifact_size=args.artifact_size,
|
||||
production_service=args.production_service,
|
||||
production_port=args.production_port,
|
||||
repository_identity=args.repository_identity,
|
||||
)
|
||||
try:
|
||||
result = execute(
|
||||
binding=binding,
|
||||
@@ -668,7 +634,6 @@ def main() -> int:
|
||||
deploy_command=parse_command(args.deploy_adapter),
|
||||
registry_command=parse_command(args.registry_adapter),
|
||||
baseline_command=parse_command(args.baseline_adapter),
|
||||
release_plan_path=args.release_plan,
|
||||
)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
|
||||
@@ -12,7 +12,6 @@ import tempfile
|
||||
from typing import Any, Mapping
|
||||
|
||||
from release_campaign_authorization import CampaignAuthorization, CampaignError
|
||||
from trusted_release_execution_plan import PlanError, TrustedReleasePlan
|
||||
|
||||
|
||||
class AcceptanceError(RuntimeError):
|
||||
@@ -62,7 +61,6 @@ def evaluate(
|
||||
staging: Mapping[str, Any],
|
||||
main: Mapping[str, Any],
|
||||
tag: Mapping[str, Any],
|
||||
release_plan: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
required_binding = {
|
||||
"version",
|
||||
@@ -100,27 +98,8 @@ def evaluate(
|
||||
raise AcceptanceError("rollback_readiness_failed")
|
||||
if tag.get("conflict") is not False:
|
||||
raise AcceptanceError("tag_conflict")
|
||||
trusted_plan: TrustedReleasePlan | None = None
|
||||
if release_plan is not None:
|
||||
try:
|
||||
trusted_plan = TrustedReleasePlan.from_mapping(release_plan)
|
||||
trusted_plan.assert_binding(binding)
|
||||
except PlanError as exc:
|
||||
raise AcceptanceError(f"trusted_release_plan_invalid:{exc}") from exc
|
||||
if trusted_plan.value["campaign_authorization_sha256"] != campaign.digest:
|
||||
raise AcceptanceError("campaign_authorization_digest_mismatch")
|
||||
if int(trusted_plan.value["campaign_generation"]) != int(campaign.data["generation"]):
|
||||
raise AcceptanceError("campaign_generation_mismatch")
|
||||
if main.get("tree_oid") not in (None, trusted_plan.value["tree_oid"]):
|
||||
raise AcceptanceError("tree_oid_mismatch")
|
||||
if evidence.get("release_plan_sha256") not in (None, trusted_plan.release_plan_sha256):
|
||||
raise AcceptanceError("evidence_release_plan_mismatch")
|
||||
if artifact.get("manifest_sha256") not in (None, trusted_plan.value["manifest_sha256"]):
|
||||
raise AcceptanceError("manifest_digest_mismatch")
|
||||
if artifact.get("package_closure_sha256") not in (None, trusted_plan.value["package_closure_sha256"]):
|
||||
raise AcceptanceError("package_closure_digest_mismatch")
|
||||
campaign.assert_operation("derive_campaign_acceptance")
|
||||
result = {
|
||||
return {
|
||||
"schema": "aios.campaign-release-acceptance.v1",
|
||||
"accepted": True,
|
||||
"binding_replaced": False,
|
||||
@@ -129,22 +108,6 @@ def evaluate(
|
||||
**dict(binding),
|
||||
"owner_interaction_required": False,
|
||||
}
|
||||
if trusted_plan is not None:
|
||||
result.update({
|
||||
"candidate_id": trusted_plan.value["candidate_id"],
|
||||
"release_plan_sha256": trusted_plan.release_plan_sha256,
|
||||
"candidate_binding_sha256": trusted_plan.candidate_binding_sha256,
|
||||
"campaign_authorization_sha256": trusted_plan.value["campaign_authorization_sha256"],
|
||||
"campaign_generation": trusted_plan.value["campaign_generation"],
|
||||
"tree_oid": trusted_plan.value["tree_oid"],
|
||||
"manifest_sha256": trusted_plan.value["manifest_sha256"],
|
||||
"package_closure_sha256": trusted_plan.value["package_closure_sha256"],
|
||||
"deployment_plan_sha256": trusted_plan.value["deployment_plan_sha256"],
|
||||
"policy_sha256": trusted_plan.value["policy_sha256"],
|
||||
"rollback": trusted_plan.value["rollback"],
|
||||
"baseline": trusted_plan.value["baseline"],
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -157,7 +120,6 @@ def main() -> int:
|
||||
p.add_argument("--staging", type=Path, required=True)
|
||||
p.add_argument("--main-facts", type=Path, required=True)
|
||||
p.add_argument("--tag-facts", type=Path, required=True)
|
||||
p.add_argument("--release-plan", type=Path)
|
||||
p.add_argument("--output", type=Path, required=True)
|
||||
a = p.parse_args()
|
||||
|
||||
@@ -174,7 +136,6 @@ def main() -> int:
|
||||
secure_json(a.staging),
|
||||
secure_json(a.main_facts),
|
||||
secure_json(a.tag_facts),
|
||||
secure_json(a.release_plan) if a.release_plan else None,
|
||||
)
|
||||
atomic_new(a.output, receipt)
|
||||
print(json.dumps(receipt, sort_keys=True))
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
"""Governed release orchestration with a trusted-plan V2R2C2 path.
|
||||
|
||||
The legacy V2R2C1 implementation remains available only for compatibility;
|
||||
new release requests are dispatched to the plan-driven executor below.
|
||||
"""Governed V2R2C1 production release orchestration.
|
||||
|
||||
This module is deliberately scoped to the Owner-approved V2R2C1 binding. It
|
||||
uses the existing TagPublisher and metadata adapters; it does not introduce a
|
||||
@@ -23,8 +20,6 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from trusted_release_execution_plan import PlanError, TrustedReleasePlan
|
||||
|
||||
from gitea_tag_publication import (
|
||||
GiteaTagClient,
|
||||
GitTagBackend,
|
||||
@@ -524,7 +519,11 @@ class GovernedProductionReleaseExecutor:
|
||||
|
||||
def acceptance(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
result = self.consistency(request)
|
||||
result["accepted"] = result.get("status") == "PASS" and result.get("production_health", {}).get("status") == "ACTIVE_VERIFIED"
|
||||
result["accepted"] = (
|
||||
result.get("status") == "PASS"
|
||||
and result.get("production_health", {}).get("verification_status")
|
||||
== "ACTIVE_VERIFIED"
|
||||
)
|
||||
self.state.record("ACCEPTED" if result["accepted"] else "RECONCILIATION_REQUIRED", "acceptance", result)
|
||||
return result
|
||||
|
||||
@@ -546,7 +545,11 @@ def _health(url: str, binding: ReleaseBinding, artifact: Mapping[str, Any]) -> d
|
||||
body = json.loads(response.read().decode("utf-8"))
|
||||
status = response.status
|
||||
except Exception as exc:
|
||||
return {"status": "UNAVAILABLE", "error": type(exc).__name__}
|
||||
return {
|
||||
"status": "UNAVAILABLE",
|
||||
"verification_status": "UNAVAILABLE",
|
||||
"error": type(exc).__name__,
|
||||
}
|
||||
expected_artifact = artifact.get("artifact_sha256")
|
||||
exact = (
|
||||
status == 200
|
||||
@@ -556,142 +559,14 @@ def _health(url: str, binding: ReleaseBinding, artifact: Mapping[str, Any]) -> d
|
||||
and body.get("deployment_state") == "ACTIVE"
|
||||
and body.get("production_deployed") is True
|
||||
)
|
||||
return {"status": "ACTIVE_VERIFIED" if exact else "CONFLICT", "http_status": status, **body}
|
||||
return {
|
||||
**body,
|
||||
"http_status": status,
|
||||
"verification_status": "ACTIVE_VERIFIED" if exact else "CONFLICT",
|
||||
}
|
||||
|
||||
|
||||
|
||||
class TrustedPlanReleaseExecutor:
|
||||
"""Release handler whose identity comes only from an immutable trusted plan.
|
||||
|
||||
The bridge may prepare/read evidence and request tag publication, but it does
|
||||
not perform production side effects. The privileged approved-release
|
||||
service consumes the same plan and exact Owner approval envelope.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Any):
|
||||
self.config = config
|
||||
configured = getattr(config, "release_plan_path", None)
|
||||
self.plan_path = Path(configured) if configured else None
|
||||
self.state_root = Path(config.state_root) / "trusted-release"
|
||||
self.state_root.mkdir(parents=True, exist_ok=True)
|
||||
self._approval: dict[str, Any] | None = None
|
||||
self._tag: dict[str, Any] | None = None
|
||||
|
||||
def _plan(self) -> TrustedReleasePlan:
|
||||
path = self.plan_path
|
||||
if path is None:
|
||||
env_path = os.environ.get("AIOS_RELEASE_PLAN_PATH", "")
|
||||
path = Path(env_path) if env_path else None
|
||||
if path is None:
|
||||
raise ReleaseBlocked("TRUSTED_RELEASE_PLAN_REQUIRED")
|
||||
try:
|
||||
return TrustedReleasePlan.load(path)
|
||||
except PlanError as exc:
|
||||
raise ReleaseBlocked(f"TRUSTED_RELEASE_PLAN_INVALID:{exc}") from exc
|
||||
|
||||
@staticmethod
|
||||
def _binding(plan: TrustedReleasePlan) -> dict[str, Any]:
|
||||
value = plan.value
|
||||
return {
|
||||
"version": value["version"],
|
||||
"target_oid": value["source_commit"],
|
||||
"evidence_closure_oid": value["evidence_closure_oid"],
|
||||
"artifact_sha256": value["artifact_sha256"],
|
||||
"artifact_size": value["artifact"]["size"],
|
||||
"approval_plan_id": value["approval_plan_id"],
|
||||
"candidate_id": value["candidate_id"],
|
||||
"release_plan_sha256": plan.release_plan_sha256,
|
||||
}
|
||||
|
||||
def _request(self, request: Mapping[str, Any]) -> tuple[TrustedReleasePlan, dict[str, Any]]:
|
||||
plan = self._plan()
|
||||
binding = self._binding(plan)
|
||||
for key, expected in binding.items():
|
||||
if key in request and request[key] != expected:
|
||||
raise ManualOwnerReviewRequired("trusted release request binding mismatch:" + key)
|
||||
return plan, binding
|
||||
|
||||
def approval_request(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if request.get("execute") is not False:
|
||||
raise ReleaseBlocked("TAG_APPROVAL_REQUEST_MUST_BE_DRY_RUN")
|
||||
plan, binding = self._request(request)
|
||||
approval = request.get("owner_approval")
|
||||
if not isinstance(approval, Mapping) or approval.get("approved") is not True:
|
||||
raise ReleaseBlocked("OWNER_RELEASE_APPROVAL_REQUIRED")
|
||||
for key in ("candidate_id", "release_plan_sha256", "approval_plan_id"):
|
||||
if approval.get(key) != binding[key]:
|
||||
raise ManualOwnerReviewRequired("OWNER_APPROVAL_BINDING_MISMATCH:" + key)
|
||||
record = {"plan": plan.as_dict(), "owner_approval": dict(approval)}
|
||||
digest = hashlib.sha256(canonical(record)).hexdigest()
|
||||
if self._approval is not None and self._approval != record:
|
||||
raise ManualOwnerReviewRequired("OWNER_APPROVAL_REPLAY_CONFLICT")
|
||||
self._approval = record
|
||||
return {"status": "RECORDED", "execute": False, "release_plan_sha256": plan.release_plan_sha256, "approval_digest_sha256": digest}
|
||||
|
||||
def _require_approval(self, plan: TrustedReleasePlan) -> None:
|
||||
if self._approval is None or self._approval.get("plan") != plan.as_dict():
|
||||
raise ReleaseBlocked("OWNER_RELEASE_APPROVAL_NOT_RECORDED")
|
||||
|
||||
def build_artifact(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
plan, _ = self._request(request)
|
||||
artifact = dict(plan.value["artifact"])
|
||||
path = Path(str(artifact["path"]))
|
||||
if path.is_symlink() or not path.is_file() or path.stat().st_size != artifact["size"]:
|
||||
raise ReleaseBlocked("TRUSTED_ARTIFACT_NOT_PRESENT")
|
||||
actual = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
if actual != plan.value["artifact_sha256"]:
|
||||
raise ReleaseBlocked("TRUSTED_ARTIFACT_DIGEST_MISMATCH")
|
||||
return {"status": "VERIFIED", "version": plan.value["version"], "source_commit": plan.value["source_commit"], "tree_oid": plan.value["tree_oid"], "artifact": str(path), "artifact_size": artifact["size"], "artifact_sha256": actual, "manifest_sha256": plan.value["manifest_sha256"], "package_closure_sha256": plan.value["package_closure_sha256"], "release_plan_sha256": plan.release_plan_sha256}
|
||||
|
||||
def _publisher(self, plan: TrustedReleasePlan) -> TagPublisher:
|
||||
root = self.state_root / plan.value["candidate_id"]
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
publisher = TagPublisher(root / "publication-workspace", GitTagBackend(root / "publication-workspace", authoritative_repo=Path(self.config.authoritative_git_dir)), GiteaTagClient(self.config.api_base, "aios/aios", Path(self.config.token_path)), self.config.remote_name, state_root=root / "tag-publication")
|
||||
publisher.baseline_path = Path(os.environ.get("AIOS_GITOPS_BASELINE_AUTHORITY", str(self.config.baseline_path)))
|
||||
publisher.versions_root = Path(__file__).resolve().parents[1] / "versions"
|
||||
return publisher
|
||||
|
||||
def publish_tag(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if request.get("execute") is not True:
|
||||
raise ReleaseBlocked("TAG_PUBLICATION_REQUIRES_EXECUTE_TRUE")
|
||||
plan, _ = self._request(request)
|
||||
self._require_approval(plan)
|
||||
publication = PublicationRequest(plan.value["version"], plan.value["source_commit"], plan.value["evidence_closure_oid"], plan.value["approval_plan_id"])
|
||||
try:
|
||||
result = self._publisher(plan)._verify_publication(publication, allow_push=True, previously_deployed=False)
|
||||
except (PublicationManualOwnerReviewRequired, PublicationPending, PublicationReconciliationRequired) as exc:
|
||||
raise ReconciliationRequired(str(exc)) from exc
|
||||
self._tag = dict(result)
|
||||
return {"status": "REMOTE_VERIFIED", **result, "release_plan_sha256": plan.release_plan_sha256}
|
||||
|
||||
def tag_status(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
plan, _ = self._request(request)
|
||||
return self._publisher(plan).get_tag_publication_status(plan.value["version"])
|
||||
|
||||
def execute_request(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
plan, _ = self._request(request)
|
||||
self._require_approval(plan)
|
||||
raise ReleaseBlocked("PRODUCTION_EXECUTION_REQUIRES_APPROVED_RELEASE_EXECUTOR")
|
||||
|
||||
def consistency(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
plan, binding = self._request(request)
|
||||
result = {"status": "PASS", "release_binding": binding, "trusted_plan": plan.as_dict(), "production_mutation": False}
|
||||
result["baseline"] = _read_json(Path(self.config.baseline_path))
|
||||
result["registry"] = _read_json(Path(self.config.registry_root) / f"{plan.value['version']}.json")
|
||||
return result
|
||||
|
||||
def acceptance(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
result = self.consistency(request)
|
||||
result["accepted"] = result["status"] == "PASS"
|
||||
return result
|
||||
|
||||
|
||||
def build_executor_config(config: Any) -> Any:
|
||||
"""Select legacy compatibility only when no trusted new-release plan exists."""
|
||||
plan_path = getattr(config, "release_plan_path", None) or os.environ.get("AIOS_RELEASE_PLAN_PATH", "")
|
||||
target_version = getattr(config, "target_version", "")
|
||||
if plan_path or target_version != VERSION:
|
||||
return TrustedPlanReleaseExecutor(config)
|
||||
def build_executor_config(config: Any) -> GovernedProductionReleaseExecutor:
|
||||
return GovernedProductionReleaseExecutor(config)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Durable, generation-scoped leases for the staging governed writer."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _iso(value: datetime) -> str:
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
def _expired(value: str) -> bool:
|
||||
return datetime.fromisoformat(value).astimezone(timezone.utc) <= _now()
|
||||
|
||||
|
||||
class ScopedLeaseError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ScopedLeaseStore:
|
||||
"""A small SQLite CAS boundary; no repository or filesystem writes."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
if not path.is_absolute() or path.is_symlink():
|
||||
raise ScopedLeaseError("LEASE_STORE_INVALID")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path = path
|
||||
with self._connect() as db:
|
||||
db.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS scoped_leases (
|
||||
lease_key TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
target_branch TEXT NOT NULL,
|
||||
expected_branch_oid TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL,
|
||||
binding_json TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
idempotency_key TEXT,
|
||||
result_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS scoped_leases_binding
|
||||
ON scoped_leases(task_id, repository, target_branch, generation);
|
||||
"""
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
db = sqlite3.connect(self.path, timeout=30.0)
|
||||
db.row_factory = sqlite3.Row
|
||||
db.execute("PRAGMA journal_mode=WAL")
|
||||
db.execute("PRAGMA synchronous=FULL")
|
||||
db.execute("PRAGMA busy_timeout=30000")
|
||||
return db
|
||||
|
||||
@staticmethod
|
||||
def key(request: Mapping[str, Any]) -> str:
|
||||
binding = {
|
||||
field: request[field]
|
||||
for field in (
|
||||
"task_id", "repository", "branch_mode", "base_oid",
|
||||
"expected_branch_oid", "target_branch", "generation",
|
||||
"lease_seconds",
|
||||
)
|
||||
}
|
||||
return hashlib.sha256(
|
||||
json.dumps(binding, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _row(row: sqlite3.Row) -> dict[str, Any]:
|
||||
value = dict(row)
|
||||
value["binding"] = json.loads(value.pop("binding_json"))
|
||||
if value.get("result_json"):
|
||||
value["result"] = json.loads(value.pop("result_json"))
|
||||
else:
|
||||
value.pop("result_json", None)
|
||||
return value
|
||||
|
||||
def acquire(self, request: Mapping[str, Any]) -> tuple[dict[str, Any], bool]:
|
||||
key = self.key(request)
|
||||
now = _now()
|
||||
expires = now.timestamp() + int(request["lease_seconds"])
|
||||
expires_at = _iso(datetime.fromtimestamp(expires, timezone.utc))
|
||||
binding_json = json.dumps(dict(request), sort_keys=True, separators=(",", ":"))
|
||||
try:
|
||||
with self._connect() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
exact = db.execute(
|
||||
"SELECT * FROM scoped_leases WHERE lease_key=?", (key,)
|
||||
).fetchone()
|
||||
if exact is not None:
|
||||
if exact["state"] == "ACTIVE" and _expired(exact["expires_at"]):
|
||||
db.execute(
|
||||
"UPDATE scoped_leases SET state='EXPIRED',updated_at=? WHERE lease_key=?",
|
||||
(_iso(now), key),
|
||||
)
|
||||
exact = db.execute(
|
||||
"SELECT * FROM scoped_leases WHERE lease_key=?", (key,)
|
||||
).fetchone()
|
||||
db.commit()
|
||||
return self._row(exact), False
|
||||
active = db.execute(
|
||||
"SELECT * FROM scoped_leases WHERE task_id=? AND repository=? AND target_branch=? AND state='ACTIVE'",
|
||||
(str(request["task_id"]), str(request["repository"]), str(request["target_branch"])),
|
||||
).fetchall()
|
||||
for row in active:
|
||||
if _expired(row["expires_at"]):
|
||||
db.execute(
|
||||
"UPDATE scoped_leases SET state='EXPIRED',updated_at=? WHERE lease_key=?",
|
||||
(_iso(now), row["lease_key"]),
|
||||
)
|
||||
else:
|
||||
raise ScopedLeaseError("LEASE_BINDING_CONFLICT")
|
||||
db.execute(
|
||||
"INSERT INTO scoped_leases(lease_key,task_id,repository,target_branch,expected_branch_oid,generation,binding_json,expires_at,state,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
key, request["task_id"], request["repository"],
|
||||
request["target_branch"], request["expected_branch_oid"],
|
||||
int(request["generation"]), binding_json, expires_at,
|
||||
"ACTIVE", _iso(now), _iso(now),
|
||||
),
|
||||
)
|
||||
row = db.execute(
|
||||
"SELECT * FROM scoped_leases WHERE lease_key=?", (key,)
|
||||
).fetchone()
|
||||
db.commit()
|
||||
return self._row(row), True
|
||||
except ScopedLeaseError:
|
||||
raise
|
||||
except sqlite3.Error as exc:
|
||||
raise ScopedLeaseError("LEASE_STORE_UNAVAILABLE") from exc
|
||||
|
||||
def for_commit(self, request: Mapping[str, Any]) -> dict[str, Any] | None:
|
||||
try:
|
||||
with self._connect() as db:
|
||||
row = db.execute(
|
||||
"SELECT * FROM scoped_leases WHERE task_id=? AND repository=? AND target_branch=? AND expected_branch_oid=? AND generation=? ORDER BY created_at DESC LIMIT 1",
|
||||
(
|
||||
request["task_id"], request["repository"],
|
||||
request["target_branch"], request["expected_branch_oid"],
|
||||
int(request["generation"]),
|
||||
),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
if row["state"] == "ACTIVE" and _expired(row["expires_at"]):
|
||||
db.execute(
|
||||
"UPDATE scoped_leases SET state='EXPIRED',updated_at=? WHERE lease_key=?",
|
||||
(_iso(_now()), row["lease_key"]),
|
||||
)
|
||||
row = db.execute(
|
||||
"SELECT * FROM scoped_leases WHERE lease_key=?", (row["lease_key"],)
|
||||
).fetchone()
|
||||
return self._row(row)
|
||||
except sqlite3.Error as exc:
|
||||
raise ScopedLeaseError("LEASE_STORE_UNAVAILABLE") from exc
|
||||
|
||||
def mark_used(self, lease_key: str, idempotency_key: str, result: Mapping[str, Any]) -> None:
|
||||
with self._connect() as db:
|
||||
updated = db.execute(
|
||||
"UPDATE scoped_leases SET state='USED',idempotency_key=?,result_json=?,updated_at=? WHERE lease_key=? AND state='ACTIVE'",
|
||||
(
|
||||
idempotency_key, json.dumps(dict(result), sort_keys=True),
|
||||
_iso(_now()), lease_key,
|
||||
),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
raise ScopedLeaseError("LEASE_ALREADY_USED")
|
||||
|
||||
def delete(self, lease_key: str) -> None:
|
||||
with self._connect() as db:
|
||||
db.execute("DELETE FROM scoped_leases WHERE lease_key=? AND state='ACTIVE'", (lease_key,))
|
||||
@@ -9,7 +9,6 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -41,8 +40,6 @@ 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 = "AIOS_TRUSTED_RELEASE_BUILDER"
|
||||
ALLOWED_CAPABILITIES = frozenset(
|
||||
{
|
||||
"runtime_switch",
|
||||
@@ -65,10 +62,6 @@ 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."""
|
||||
|
||||
@@ -229,10 +222,6 @@ 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)
|
||||
@@ -726,14 +715,6 @@ 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(
|
||||
@@ -750,35 +731,7 @@ 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]:
|
||||
@@ -801,290 +754,20 @@ class BuilderAttestation:
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls,
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
require_fingerprint: bool = False,
|
||||
) -> "BuilderAttestation":
|
||||
def from_dict(cls, payload: Mapping[str, Any]) -> "BuilderAttestation":
|
||||
values = dict(payload)
|
||||
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
|
||||
values.pop("fingerprint", None)
|
||||
return cls(**values)
|
||||
|
||||
|
||||
class BuilderAttestationVerifier:
|
||||
"""Public-key-only verifier; no builder private material is accepted."""
|
||||
|
||||
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 __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 verify(
|
||||
self,
|
||||
@@ -1110,54 +793,22 @@ 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
|
||||
)
|
||||
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,
|
||||
Ed25519PublicKey.from_public_bytes(key).verify(
|
||||
signature, attestation.canonical_bytes()
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@@ -3530,12 +3181,9 @@ 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)
|
||||
@@ -3546,28 +3194,11 @@ 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"
|
||||
)
|
||||
@@ -3604,26 +3235,10 @@ 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", "version"}
|
||||
allowed = {"event_id", "source_commit"}
|
||||
unknown = set(payload).difference(allowed)
|
||||
if unknown:
|
||||
raise CapabilityAuthorizationError(
|
||||
@@ -3635,17 +3250,12 @@ 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,
|
||||
@@ -3655,153 +3265,42 @@ class SilentReleaseActionService:
|
||||
else "BUILDER_UNAVAILABLE"
|
||||
),
|
||||
}
|
||||
if version:
|
||||
record["version"] = version
|
||||
if was_existing:
|
||||
if path.exists():
|
||||
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"
|
||||
)
|
||||
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(),
|
||||
return {
|
||||
**_sanitize(existing),
|
||||
"idempotent": True,
|
||||
"execution_enabled": self.execution_enabled,
|
||||
"shadow_mode": not self.execution_enabled,
|
||||
"side_effects_executed": False,
|
||||
}
|
||||
)
|
||||
_durable_json(path, record)
|
||||
try:
|
||||
result = dict(record)
|
||||
if self.candidate_builder is not None:
|
||||
candidate = self.create_release_candidate(
|
||||
{
|
||||
"event_id": event_id,
|
||||
"intent_id": intent_id,
|
||||
"source_commit": source_commit,
|
||||
**({"version": version} if version else {}),
|
||||
}
|
||||
)
|
||||
except TrustedCandidateBuilderUnavailable:
|
||||
record.update(
|
||||
result.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"
|
||||
),
|
||||
"candidate_id": candidate["candidate_id"],
|
||||
"status": candidate["saga_state"],
|
||||
}
|
||||
)
|
||||
_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]:
|
||||
_durable_json(path, result)
|
||||
return {
|
||||
**_sanitize(dict(record)),
|
||||
"idempotent": idempotent,
|
||||
**_sanitize(result),
|
||||
"idempotent": False,
|
||||
"execution_enabled": self.execution_enabled,
|
||||
"shadow_mode": not self.execution_enabled,
|
||||
"side_effects_executed": False,
|
||||
@@ -3822,7 +3321,6 @@ 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",
|
||||
@@ -3835,69 +3333,13 @@ class SilentReleaseActionService:
|
||||
"tests",
|
||||
"tree_oid",
|
||||
}
|
||||
if forbidden_evidence.intersection(values):
|
||||
if forbidden_evidence.intersection(payload):
|
||||
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"
|
||||
|
||||
@@ -1,905 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Root-only, purpose-bound trusted-builder signer provisioner.
|
||||
|
||||
The provisioner consumes one exact Owner-signed request after the normal
|
||||
engineering review has prepared it. It creates the Ed25519 private material
|
||||
and builder HMAC on the host, encrypts both for systemd, publishes only the
|
||||
public verification key, and appends one replay-protected receipt. It never
|
||||
accepts or writes main, tag, baseline, production, or Owner-approval keys.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import datetime as dt
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover - Windows only affects local payload tooling.
|
||||
class _NoopFcntl:
|
||||
LOCK_EX = 0
|
||||
|
||||
@staticmethod
|
||||
def flock(_file, _mode):
|
||||
return None
|
||||
|
||||
fcntl = _NoopFcntl()
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
PURPOSE = "AIOS_TRUSTED_RELEASE_BUILDER"
|
||||
TRUST_DOMAIN = "aios.trusted-builder.attestation.v1"
|
||||
OWNER_NAMESPACE = "aios-owner-approval-v1"
|
||||
OWNER_PRINCIPAL = "aios-task-grant-signer"
|
||||
SCHEMA = "aios.trusted-builder-signer-provisioning.v2"
|
||||
ALLOWED_CAPABILITIES = frozenset(
|
||||
{
|
||||
"builder_receipt_signing",
|
||||
"artifact_provenance_signing",
|
||||
"manifest_provenance_signing",
|
||||
"builder_owned_release_evidence_signing",
|
||||
}
|
||||
)
|
||||
DENIED_CAPABILITIES = frozenset(
|
||||
{
|
||||
"owner_release_approval",
|
||||
"protected_main_merge",
|
||||
"arbitrary_ref_write",
|
||||
"tag_publication",
|
||||
"baseline_finalize",
|
||||
"production_runtime_switch",
|
||||
}
|
||||
)
|
||||
OID_FIELDS = {
|
||||
"gitea_main_oid",
|
||||
"authority_main_oid_at_capture",
|
||||
"governed_main_oid",
|
||||
"gitea_main_tree_oid",
|
||||
"pr_head_oid",
|
||||
"protected_merge_commit",
|
||||
}
|
||||
SHA_FIELDS = {"source_evidence_sha256"}
|
||||
PACKAGE_MANIFEST_PATH = "deploy/trusted-builder/aios-trusted-builder-package-manifest.json"
|
||||
PACKAGE_MANIFEST_SCHEMA = "aios.trusted-builder-package.v1"
|
||||
SOURCE_EVIDENCE_PATHS = (
|
||||
"active-runtime/app/trusted_builder_signer_provisioning.py",
|
||||
"active-runtime/app/aios_trusted_candidate_builder.py",
|
||||
"config/gitops/trusted-candidate-builder-policy.json",
|
||||
"deploy/systemd/aios-trusted-candidate-builder.service",
|
||||
"deploy/systemd/aios-trusted-candidate-builder.socket",
|
||||
"deploy/systemd/aios-gitops-model-api-trusted-builder.conf.example",
|
||||
"deploy/libexec/aios-trusted-builder-signer-provisioner",
|
||||
PACKAGE_MANIFEST_PATH,
|
||||
)
|
||||
SOURCE_EVIDENCE_MODES = {path: frozenset({0o600, 0o640, 0o644, 0o664}) for path in SOURCE_EVIDENCE_PATHS}
|
||||
|
||||
|
||||
def package_contract_document() -> dict[str, Any]:
|
||||
"""The non-secret package/activation contract for a fresh B2 payload."""
|
||||
files = []
|
||||
for path in SOURCE_EVIDENCE_PATHS:
|
||||
install_mode = "0755" if path == "deploy/libexec/aios-trusted-builder-signer-provisioner" else "0644"
|
||||
files.append({"source_path": path, "source_mode": "0644", "install_mode": install_mode})
|
||||
return {
|
||||
"schema": PACKAGE_MANIFEST_SCHEMA,
|
||||
"version": 1,
|
||||
"package_name": "aios-trusted-candidate-builder",
|
||||
"source_root": "/opt/aios/trusted-candidate-builder/current",
|
||||
"files": files,
|
||||
"activation": {
|
||||
"daemon_reload": True,
|
||||
"enable_units": ["aios-trusted-candidate-builder.socket"],
|
||||
"service_start": "socket-activation-only",
|
||||
"credential_installation": False,
|
||||
"signer_invocation": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _source_evidence(source_root: Path, source_files: list[str] | tuple[str, ...]) -> dict[str, str]:
|
||||
if set(source_files) != set(SOURCE_EVIDENCE_PATHS) or len(source_files) != len(SOURCE_EVIDENCE_PATHS):
|
||||
raise ProvisioningError("SOURCE_EVIDENCE_SET_INVALID")
|
||||
evidence: dict[str, str] = {}
|
||||
for path in SOURCE_EVIDENCE_PATHS:
|
||||
candidate = source_root / path
|
||||
if candidate.is_symlink() or not candidate.is_file():
|
||||
raise ProvisioningError("SOURCE_EVIDENCE_MISSING")
|
||||
if stat.S_IMODE(candidate.stat().st_mode) not in SOURCE_EVIDENCE_MODES[path]:
|
||||
raise ProvisioningError("SOURCE_EVIDENCE_MODE_INVALID")
|
||||
evidence[path] = _digest_file(candidate)
|
||||
return evidence
|
||||
|
||||
|
||||
def verify_package_contract(source_root: Path) -> None:
|
||||
manifest = source_root / PACKAGE_MANIFEST_PATH
|
||||
raw, value = _read_json(manifest)
|
||||
if value != package_contract_document() or raw != pretty_json(package_contract_document()):
|
||||
raise ProvisioningError("PACKAGE_CONTRACT_INVALID")
|
||||
_source_evidence(source_root, list(SOURCE_EVIDENCE_PATHS))
|
||||
|
||||
|
||||
class ProvisioningError(RuntimeError):
|
||||
"""Stable fail-closed error code; never includes secret material."""
|
||||
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> bytes:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def pretty_json(value: Any) -> bytes:
|
||||
return (json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def sha256_bytes(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def utc_now() -> dt.datetime:
|
||||
return dt.datetime.now(dt.timezone.utc)
|
||||
|
||||
|
||||
def iso(value: dt.datetime) -> str:
|
||||
return value.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _require_oid(value: Any, code: str) -> str:
|
||||
if not isinstance(value, str) or len(value) != 40 or any(c not in "0123456789abcdef" for c in value.lower()):
|
||||
raise ProvisioningError(code)
|
||||
return value.lower()
|
||||
|
||||
|
||||
def _require_sha(value: Any, code: str) -> str:
|
||||
if not isinstance(value, str) or len(value) != 64 or any(c not in "0123456789abcdef" for c in value.lower()):
|
||||
raise ProvisioningError(code)
|
||||
return value.lower()
|
||||
|
||||
|
||||
def _read_json(path: Path) -> tuple[bytes, dict[str, Any]]:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ProvisioningError("PROVISIONING_INPUT_MISSING")
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ProvisioningError("PROVISIONING_INPUT_INVALID") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ProvisioningError("PROVISIONING_OBJECT_REQUIRED")
|
||||
return raw, value
|
||||
|
||||
|
||||
def _digest_file(path: Path) -> str:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ProvisioningError("SOURCE_EVIDENCE_MISSING")
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _validate_payload(payload: Mapping[str, Any]) -> None:
|
||||
expected = {
|
||||
"schema", "status", "operation", "repository", "source",
|
||||
"signing_identity", "allowed_capabilities", "denied_capabilities",
|
||||
"runtime_binding", "provisioning_constraints", "required_preconditions",
|
||||
"provisioner_contract", "issued_at", "expires_in_seconds", "nonce",
|
||||
}
|
||||
if set(payload) != expected or payload.get("schema") != SCHEMA or payload.get("status") != "OWNER_SIGNED_PROVISIONING_REQUEST":
|
||||
raise ProvisioningError("PROVISIONING_SCHEMA_INVALID")
|
||||
if payload.get("operation") != "PROVISION_PURPOSE_BOUND_TRUSTED_RELEASE_BUILDER_SIGNER" or payload.get("repository") != "aios/aios":
|
||||
raise ProvisioningError("PROVISIONING_SCOPE_INVALID")
|
||||
source = payload.get("source")
|
||||
identity = payload.get("signing_identity")
|
||||
binding = payload.get("runtime_binding")
|
||||
constraints = payload.get("provisioning_constraints")
|
||||
contract = payload.get("provisioner_contract")
|
||||
if not all(isinstance(item, Mapping) for item in (source, identity, binding, constraints, contract)):
|
||||
raise ProvisioningError("PROVISIONING_BINDING_INVALID")
|
||||
required_source = OID_FIELDS | {"authority_reconciliation_completed", "pr_number", "merge_parent_oids", "source_repository_identity", "source_evidence_sha256"}
|
||||
if set(source) != required_source:
|
||||
raise ProvisioningError("SOURCE_BINDING_INVALID")
|
||||
for name in OID_FIELDS:
|
||||
_require_oid(source.get(name), "SOURCE_OID_INVALID")
|
||||
if not (
|
||||
source["gitea_main_oid"] == source["authority_main_oid_at_capture"] == source["governed_main_oid"] == source["protected_merge_commit"]
|
||||
) or source.get("authority_reconciliation_completed") is not True:
|
||||
raise ProvisioningError("SOURCE_MAIN_NOT_CONVERGED")
|
||||
if not isinstance(source.get("pr_number"), int) or isinstance(source["pr_number"], bool) or source["pr_number"] < 1 or source.get("source_repository_identity") != "aios/aios":
|
||||
raise ProvisioningError("SOURCE_PR_BINDING_INVALID")
|
||||
parents = source.get("merge_parent_oids")
|
||||
if not isinstance(parents, list) or len(parents) != 2:
|
||||
raise ProvisioningError("SOURCE_ANCESTRY_INVALID")
|
||||
for item in parents:
|
||||
_require_oid(item, "SOURCE_ANCESTRY_INVALID")
|
||||
if parents[1] != source["pr_head_oid"] or parents[0] == parents[1]:
|
||||
raise ProvisioningError("SOURCE_PR_HEAD_SECOND_PARENT_INVALID")
|
||||
evidence = source.get("source_evidence_sha256")
|
||||
if not isinstance(evidence, Mapping) or set(evidence) != set(SOURCE_EVIDENCE_PATHS):
|
||||
raise ProvisioningError("SOURCE_EVIDENCE_SET_INVALID")
|
||||
for path, digest in evidence.items():
|
||||
if path not in SOURCE_EVIDENCE_MODES:
|
||||
raise ProvisioningError("SOURCE_EVIDENCE_PATH_INVALID")
|
||||
_require_sha(digest, "SOURCE_EVIDENCE_DIGEST_INVALID")
|
||||
required_identity = {
|
||||
"key_id", "key_generation", "algorithm", "purpose", "trust_domain",
|
||||
"private_key_export", "private_key_delivery", "public_key_registration",
|
||||
"service_principal_bound", "service_principal",
|
||||
}
|
||||
if set(identity) != required_identity or identity.get("algorithm") != "Ed25519" or identity.get("purpose") != PURPOSE or identity.get("trust_domain") != TRUST_DOMAIN:
|
||||
raise ProvisioningError("SIGNING_IDENTITY_INVALID")
|
||||
if identity.get("private_key_export") is not False or identity.get("service_principal_bound") is not True:
|
||||
raise ProvisioningError("SIGNING_CAPABILITY_INVALID")
|
||||
if not isinstance(identity.get("key_id"), str) or not identity["key_id"] or not isinstance(identity.get("key_generation"), int) or isinstance(identity["key_generation"], bool) or identity["key_generation"] < 1:
|
||||
raise ProvisioningError("SIGNING_IDENTITY_INVALID")
|
||||
if not isinstance(identity.get("service_principal"), str) or identity["service_principal"] != "aios-trusted-candidate-builder.service":
|
||||
raise ProvisioningError("SERVICE_PRINCIPAL_INVALID")
|
||||
if set(payload["allowed_capabilities"]) != ALLOWED_CAPABILITIES or set(payload["denied_capabilities"]) != DENIED_CAPABILITIES:
|
||||
raise ProvisioningError("CAPABILITY_SCOPE_INVALID")
|
||||
required_binding = {
|
||||
"builder_service", "builder_socket", "credential_directory", "builder_policy",
|
||||
"builder_public_keyring", "builder_hmac_credential", "signing_private_key_credential",
|
||||
"hmac_credential_required", "peer_authentication_required", "execution_user",
|
||||
}
|
||||
if set(binding) != required_binding or binding.get("builder_service") != identity["service_principal"] or binding.get("hmac_credential_required") is not True or binding.get("peer_authentication_required") is not True or binding.get("execution_user") != "root":
|
||||
raise ProvisioningError("RUNTIME_BINDING_INVALID")
|
||||
required_constraints = {
|
||||
"one_shot", "replay_protected", "append_only_receipt", "owner_approval_scope",
|
||||
"production_mutation", "protected_ref_mutation", "tag_mutation", "baseline_mutation", "credential_material_in_payload",
|
||||
}
|
||||
if set(constraints) != required_constraints or any(constraints.get(name) is not False for name in ("production_mutation", "protected_ref_mutation", "tag_mutation", "baseline_mutation", "credential_material_in_payload")) or constraints.get("one_shot") is not True or constraints.get("replay_protected") is not True or constraints.get("append_only_receipt") is not True or constraints.get("owner_approval_scope") != "trusted-builder-signer-provisioning-only":
|
||||
raise ProvisioningError("PROVISIONING_CONSTRAINTS_INVALID")
|
||||
if not isinstance(contract.get("command"), str) or not contract["command"].startswith("/") or contract.get("credential_store") != "/etc/credstore.encrypted/aios-trusted-candidate-builder" or contract.get("receipt_log") != "/var/lib/aios-gitops/trusted-builder/provisioning-receipts.jsonl":
|
||||
raise ProvisioningError("PROVISIONER_CONTRACT_INVALID")
|
||||
if not isinstance(payload.get("required_preconditions"), list) or not payload["required_preconditions"]:
|
||||
raise ProvisioningError("PROVISIONING_PRECONDITIONS_INVALID")
|
||||
try:
|
||||
issued = dt.datetime.fromisoformat(str(payload["issued_at"]).replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ProvisioningError("PROVISIONING_TIME_INVALID") from exc
|
||||
if issued.tzinfo is None or not isinstance(payload.get("expires_in_seconds"), int) or isinstance(payload["expires_in_seconds"], bool) or not 60 <= payload["expires_in_seconds"] <= 900:
|
||||
raise ProvisioningError("PROVISIONING_TIME_INVALID")
|
||||
if not isinstance(payload.get("nonce"), str) or len(payload["nonce"]) < 16:
|
||||
raise ProvisioningError("PROVISIONING_NONCE_INVALID")
|
||||
|
||||
|
||||
def build_payload(
|
||||
source_root: Path,
|
||||
output: Path,
|
||||
*,
|
||||
main_oid: str,
|
||||
tree_oid: str,
|
||||
pr_number: int,
|
||||
pr_head_oid: str,
|
||||
merge_parent_oids: list[str],
|
||||
source_files: list[str],
|
||||
nonce: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
main_oid = _require_oid(main_oid, "MAIN_OID_INVALID")
|
||||
tree_oid = _require_oid(tree_oid, "TREE_OID_INVALID")
|
||||
pr_head_oid = _require_oid(pr_head_oid, "PR_HEAD_OID_INVALID")
|
||||
if not isinstance(pr_number, int) or isinstance(pr_number, bool) or pr_number < 1:
|
||||
raise ProvisioningError("PR_NUMBER_INVALID")
|
||||
if len(merge_parent_oids) != 2:
|
||||
raise ProvisioningError("MERGE_PARENTS_INVALID")
|
||||
parents = [_require_oid(value, "MERGE_PARENT_INVALID") for value in merge_parent_oids]
|
||||
if parents[1] != pr_head_oid or parents[0] == parents[1]:
|
||||
raise ProvisioningError("MERGE_PARENT_ORDER_INVALID")
|
||||
evidence = _source_evidence(source_root, source_files)
|
||||
payload: dict[str, Any] = {
|
||||
"schema": SCHEMA,
|
||||
"status": "OWNER_SIGNED_PROVISIONING_REQUEST",
|
||||
"operation": "PROVISION_PURPOSE_BOUND_TRUSTED_RELEASE_BUILDER_SIGNER",
|
||||
"repository": "aios/aios",
|
||||
"source": {
|
||||
"gitea_main_oid": main_oid,
|
||||
"gitea_main_tree_oid": tree_oid,
|
||||
"authority_main_oid_at_capture": main_oid,
|
||||
"governed_main_oid": main_oid,
|
||||
"authority_reconciliation_completed": True,
|
||||
"pr_number": pr_number,
|
||||
"pr_head_oid": pr_head_oid,
|
||||
"protected_merge_commit": main_oid,
|
||||
"merge_parent_oids": parents,
|
||||
"source_repository_identity": "aios/aios",
|
||||
"source_evidence_sha256": evidence,
|
||||
},
|
||||
"signing_identity": {
|
||||
"key_id": "builder-attestation-ed25519-g1",
|
||||
"key_generation": 1,
|
||||
"algorithm": "Ed25519",
|
||||
"purpose": PURPOSE,
|
||||
"trust_domain": TRUST_DOMAIN,
|
||||
"private_key_export": False,
|
||||
"private_key_delivery": "server_side_systemd_credential_only",
|
||||
"public_key_registration": "/etc/aios-gitops/trusted-builder/public-keyring.json",
|
||||
"service_principal_bound": True,
|
||||
"service_principal": "aios-trusted-candidate-builder.service",
|
||||
},
|
||||
"allowed_capabilities": sorted(ALLOWED_CAPABILITIES),
|
||||
"denied_capabilities": sorted(DENIED_CAPABILITIES),
|
||||
"runtime_binding": {
|
||||
"builder_service": "aios-trusted-candidate-builder.service",
|
||||
"builder_socket": "/run/aios-trusted-candidate-builder.sock",
|
||||
"credential_directory": "/run/credentials/aios-trusted-candidate-builder.service",
|
||||
"builder_policy": "config/gitops/trusted-candidate-builder-policy.json",
|
||||
"builder_public_keyring": "/etc/aios-gitops/trusted-builder/public-keyring.json",
|
||||
"builder_hmac_credential": "builder_hmac",
|
||||
"signing_private_key_credential": "signing_private_key",
|
||||
"hmac_credential_required": True,
|
||||
"peer_authentication_required": True,
|
||||
"execution_user": "root",
|
||||
},
|
||||
"provisioning_constraints": {
|
||||
"one_shot": True,
|
||||
"replay_protected": True,
|
||||
"append_only_receipt": True,
|
||||
"owner_approval_scope": "trusted-builder-signer-provisioning-only",
|
||||
"production_mutation": False,
|
||||
"protected_ref_mutation": False,
|
||||
"tag_mutation": False,
|
||||
"baseline_mutation": False,
|
||||
"credential_material_in_payload": False,
|
||||
},
|
||||
"required_preconditions": [
|
||||
"FINAL_COMMON_MAIN_PROTECTED_MERGE_VERIFIED",
|
||||
"AUTHORITY_MAIN_RECONCILIATION_COMPLETED",
|
||||
"FINAL_MAIN_MERGE_PARENT_ORDER_VERIFIED",
|
||||
"PR_HEAD_SECOND_PARENT_VERIFIED",
|
||||
"TRUSTED_BUILDER_IMMUTABLE_PACKAGE_CONTRACT_VERIFIED",
|
||||
"PROVISIONER_GENERATES_PRIVATE_KEY_SERVER_SIDE",
|
||||
"PUBLIC_KEYRING_PURPOSE_AND_KEY_ID_MATCH",
|
||||
"BUILDER_HMAC_PROVISIONED_SERVER_SIDE",
|
||||
"BUILDER_SERVICE_PEER_POLICY_VERIFIED",
|
||||
"APPROVED_SIGNER_RECEIPT_APPEND_ONLY",
|
||||
],
|
||||
"provisioner_contract": {
|
||||
"command": "/usr/local/libexec/aios-trusted-builder-signer-provisioner",
|
||||
"credential_store": "/etc/credstore.encrypted/aios-trusted-candidate-builder",
|
||||
"receipt_log": "/var/lib/aios-gitops/trusted-builder/provisioning-receipts.jsonl",
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_principal": OWNER_PRINCIPAL,
|
||||
"private_key_export": False,
|
||||
},
|
||||
"issued_at": iso(utc_now()),
|
||||
"expires_in_seconds": 900,
|
||||
"nonce": nonce or "trusted-builder-provisioning-" + secrets.token_hex(16),
|
||||
}
|
||||
_validate_payload(payload)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if output.exists():
|
||||
raise ProvisioningError("PROVISIONING_OUTPUT_EXISTS")
|
||||
output.write_bytes(pretty_json(payload))
|
||||
os.chmod(output, 0o600)
|
||||
return payload
|
||||
|
||||
|
||||
class ProvisionerConfig:
|
||||
def __init__(self) -> None:
|
||||
self.credential_store = Path(os.getenv("AIOS_TRUSTED_BUILDER_CREDENTIAL_STORE", "/etc/credstore.encrypted/aios-trusted-candidate-builder"))
|
||||
self.public_keyring = Path(os.getenv("AIOS_TRUSTED_BUILDER_PUBLIC_KEYRING", "/etc/aios-gitops/trusted-builder/public-keyring.json"))
|
||||
self.binding_file = Path(os.getenv("AIOS_TRUSTED_BUILDER_BINDING_FILE", "/etc/aios-gitops/trusted-builder/service-principal-binding.json"))
|
||||
self.receipt_log = Path(os.getenv("AIOS_TRUSTED_BUILDER_RECEIPT_LOG", "/var/lib/aios-gitops/trusted-builder/provisioning-receipts.jsonl"))
|
||||
self.allowed_signers = Path(os.getenv("AIOS_TRUSTED_BUILDER_ALLOWED_SIGNERS", "/etc/aios/trust/aios-task-grant-allowed-signers"))
|
||||
self.source_root = Path(os.getenv("AIOS_TRUSTED_BUILDER_SOURCE_ROOT", "/opt/aios/trusted-candidate-builder/current"))
|
||||
self.authority_git_dir = Path(os.getenv("AIOS_TRUSTED_BUILDER_AUTHORITY_GIT_DIR", "/data/aios-gitops/repo.git"))
|
||||
self.service_unit = Path(os.getenv("AIOS_TRUSTED_BUILDER_SERVICE_UNIT", "/etc/systemd/system/aios-trusted-candidate-builder.service"))
|
||||
self.systemd_creds = Path(os.getenv("AIOS_SYSTEMD_CREDS", "/usr/bin/systemd-creds"))
|
||||
self.ssh_keygen = Path(os.getenv("AIOS_SSH_KEYGEN", "/usr/bin/ssh-keygen"))
|
||||
|
||||
|
||||
def _safe_parent(path: Path) -> None:
|
||||
if not path.is_absolute() or path.is_symlink():
|
||||
raise ProvisioningError("PROVISIONING_PATH_INVALID")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.parent.is_symlink():
|
||||
raise ProvisioningError("PROVISIONING_PATH_SYMLINK")
|
||||
|
||||
|
||||
def _atomic_write(path: Path, data: bytes, mode: int = 0o600) -> None:
|
||||
_safe_parent(path)
|
||||
fd, temporary = tempfile.mkstemp(prefix=".trusted-builder-", dir=path.parent)
|
||||
try:
|
||||
os.fchmod(fd, mode)
|
||||
with os.fdopen(fd, "wb", closefd=True) as stream:
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
os.chmod(path, mode)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def _append(path: Path, row: Mapping[str, Any]) -> None:
|
||||
_safe_parent(path)
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||
os.write(fd, canonical_json(dict(row)) + b"\n")
|
||||
os.fsync(fd)
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(fd)
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
def _receipts(path: Path) -> list[dict[str, Any]]:
|
||||
if not path.exists() and not path.is_symlink():
|
||||
return []
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ProvisioningError("RECEIPT_LOG_INVALID")
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
raise ProvisioningError("RECEIPT_LOG_INVALID") from exc
|
||||
if not text:
|
||||
return []
|
||||
|
||||
# New receipts are canonical JSONL: exactly one object per physical line.
|
||||
# If that framing does not parse, accept exactly one complete legacy object
|
||||
# so the original pretty-printed receipt remains immutable and replayable.
|
||||
lines = text.splitlines()
|
||||
result: list[dict[str, Any]] = []
|
||||
jsonl_valid = bool(lines)
|
||||
for line in lines:
|
||||
if not line:
|
||||
jsonl_valid = False
|
||||
break
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
jsonl_valid = False
|
||||
break
|
||||
if not isinstance(value, dict):
|
||||
jsonl_valid = False
|
||||
break
|
||||
result.append(value)
|
||||
if jsonl_valid:
|
||||
return result
|
||||
try:
|
||||
legacy = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ProvisioningError("RECEIPT_LOG_INVALID") from exc
|
||||
if not isinstance(legacy, dict):
|
||||
raise ProvisioningError("RECEIPT_LOG_INVALID")
|
||||
return [legacy]
|
||||
|
||||
|
||||
def _run_signature_verify(config: ProvisionerConfig, payload: bytes, signature: Path) -> None:
|
||||
for path in (config.allowed_signers, signature, config.ssh_keygen):
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ProvisioningError("OWNER_SIGNATURE_INPUT_INVALID")
|
||||
result = subprocess.run(
|
||||
[str(config.ssh_keygen), "-Y", "verify", "-f", str(config.allowed_signers), "-I", OWNER_PRINCIPAL, "-n", OWNER_NAMESPACE, "-s", str(signature)],
|
||||
input=payload,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise ProvisioningError("OWNER_SIGNATURE_VERIFY_FAILED")
|
||||
|
||||
|
||||
def _verify_service_policy(config: ProvisionerConfig) -> None:
|
||||
if config.service_unit.is_symlink() or not config.service_unit.is_file():
|
||||
raise ProvisioningError("BUILDER_SERVICE_UNIT_MISSING")
|
||||
text = config.service_unit.read_text(encoding="utf-8")
|
||||
required = (
|
||||
"User=root", "LoadCredentialEncrypted=signing_private_key:",
|
||||
"LoadCredentialEncrypted=builder_hmac:", "NoNewPrivileges=true",
|
||||
"PrivateNetwork=true", "RestrictAddressFamilies=AF_UNIX",
|
||||
)
|
||||
if any(item not in text for item in required):
|
||||
raise ProvisioningError("BUILDER_SERVICE_POLICY_INVALID")
|
||||
|
||||
|
||||
def _git(config: ProvisionerConfig, *args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
["git", "--git-dir", str(config.authority_git_dir), *args],
|
||||
capture_output=True, check=False, timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def _verify_source(config: ProvisionerConfig, source: Mapping[str, Any]) -> None:
|
||||
live_main = subprocess.run(
|
||||
["git", "--git-dir", str(config.authority_git_dir), "rev-parse", "refs/heads/main"],
|
||||
capture_output=True, text=True, check=False, timeout=30,
|
||||
)
|
||||
live_tree = subprocess.run(
|
||||
["git", "--git-dir", str(config.authority_git_dir), "rev-parse", f"{source['protected_merge_commit']}^{{tree}}"],
|
||||
capture_output=True, text=True, check=False, timeout=30,
|
||||
)
|
||||
parents = subprocess.run(
|
||||
["git", "--git-dir", str(config.authority_git_dir), "rev-list", "--parents", "-n", "1", source["protected_merge_commit"]],
|
||||
capture_output=True, text=True, check=False, timeout=30,
|
||||
)
|
||||
ancestry = subprocess.run(
|
||||
["git", "--git-dir", str(config.authority_git_dir), "merge-base", "--is-ancestor", source["pr_head_oid"], source["protected_merge_commit"]],
|
||||
capture_output=True, text=True, check=False, timeout=30,
|
||||
)
|
||||
expected_parents = [source["protected_merge_commit"], *source["merge_parent_oids"]]
|
||||
if (live_main.returncode != 0 or live_main.stdout.strip().lower() != source["gitea_main_oid"]
|
||||
or live_tree.returncode != 0 or live_tree.stdout.strip().lower() != source["gitea_main_tree_oid"]
|
||||
or parents.returncode != 0 or parents.stdout.strip().split() != expected_parents
|
||||
or ancestry.returncode != 0):
|
||||
raise ProvisioningError("AUTHORITY_MAIN_SOURCE_DRIFT")
|
||||
if source["merge_parent_oids"][1] != source["pr_head_oid"]:
|
||||
raise ProvisioningError("SOURCE_PR_HEAD_SECOND_PARENT_INVALID")
|
||||
verify_package_contract(config.source_root)
|
||||
for relative, expected in source["source_evidence_sha256"].items():
|
||||
actual = _digest_file(config.source_root / relative)
|
||||
if actual != expected:
|
||||
raise ProvisioningError("SOURCE_EVIDENCE_DIGEST_MISMATCH")
|
||||
_verify_service_policy(config)
|
||||
|
||||
|
||||
def _verify_replay_source(config: ProvisionerConfig, source: Mapping[str, Any]) -> None:
|
||||
"""Verify the historical f8-bound source after authority main advances."""
|
||||
commit = source["protected_merge_commit"]
|
||||
live_main = _git(config, "rev-parse", "refs/heads/main")
|
||||
tree = _git(config, "rev-parse", f"{commit}^{{tree}}")
|
||||
parents = _git(config, "rev-list", "--parents", "-n", "1", commit)
|
||||
source_ancestry = _git(config, "merge-base", "--is-ancestor", commit, live_main.stdout.strip().decode("ascii", "ignore"))
|
||||
pr_ancestry = _git(config, "merge-base", "--is-ancestor", source["pr_head_oid"], commit)
|
||||
expected_parents = [commit, *source["merge_parent_oids"]]
|
||||
if (
|
||||
live_main.returncode != 0
|
||||
or tree.returncode != 0
|
||||
or tree.stdout.strip().decode("ascii", "ignore").lower() != source["gitea_main_tree_oid"]
|
||||
or parents.returncode != 0
|
||||
or parents.stdout.decode("utf-8", "replace").strip().split() != expected_parents
|
||||
or source_ancestry.returncode != 0
|
||||
or pr_ancestry.returncode != 0
|
||||
):
|
||||
raise ProvisioningError("AUTHORITY_HISTORICAL_SOURCE_DRIFT")
|
||||
if source["merge_parent_oids"][1] != source["pr_head_oid"]:
|
||||
raise ProvisioningError("SOURCE_PR_HEAD_SECOND_PARENT_INVALID")
|
||||
for relative, expected in source["source_evidence_sha256"].items():
|
||||
historical = _git(config, "show", f"{commit}:{relative}")
|
||||
if historical.returncode != 0 or sha256_bytes(historical.stdout) != expected:
|
||||
raise ProvisioningError("SOURCE_EVIDENCE_DIGEST_MISMATCH")
|
||||
# The installed repair package must still satisfy the governed package and
|
||||
# service policy, but its bytes are expected to differ from historical f8.
|
||||
verify_package_contract(config.source_root)
|
||||
_verify_service_policy(config)
|
||||
|
||||
|
||||
def _keyring(identity: Mapping[str, Any], public: bytes, now: dt.datetime) -> dict[str, Any]:
|
||||
record = {
|
||||
"algorithm": "Ed25519",
|
||||
"purpose": PURPOSE,
|
||||
"key_id": identity["key_id"],
|
||||
"generation": identity["key_generation"],
|
||||
"not_before": iso(now - dt.timedelta(minutes=1)),
|
||||
"expires_at": iso(now + dt.timedelta(days=365)),
|
||||
"status": "ACTIVE",
|
||||
"public_key": base64.b64encode(public).decode("ascii"),
|
||||
"public_key_sha256": sha256_bytes(public),
|
||||
}
|
||||
body = {"algorithm": "Ed25519", "purpose": PURPOSE, "keys": [record]}
|
||||
return {**body, "keyring_sha256": sha256_bytes(canonical_json(body))}
|
||||
|
||||
|
||||
def _encrypt(config: ProvisionerConfig, plain: Path, target: Path, name: str) -> None:
|
||||
if config.systemd_creds.is_symlink() or not config.systemd_creds.is_file():
|
||||
raise ProvisioningError("SYSTEMD_CREDS_UNAVAILABLE")
|
||||
result = subprocess.run(
|
||||
[str(config.systemd_creds), "--with-key=host", "--name=" + name, "encrypt", str(plain), str(target)],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=60,
|
||||
)
|
||||
if result.returncode != 0 or target.is_symlink() or not target.is_file() or target.stat().st_size <= 0:
|
||||
raise ProvisioningError("CREDENTIAL_ENCRYPTION_FAILED")
|
||||
os.chmod(target, 0o600)
|
||||
|
||||
|
||||
def _require_private_regular_file(path: Path, code: str) -> None:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ProvisioningError(code)
|
||||
metadata = path.stat()
|
||||
if stat.S_IMODE(metadata.st_mode) != 0o600 or metadata.st_size <= 0:
|
||||
raise ProvisioningError(code)
|
||||
|
||||
|
||||
def _read_state_json(path: Path, code: str) -> dict[str, Any]:
|
||||
_require_private_regular_file(path, code)
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ProvisioningError(code) from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ProvisioningError(code)
|
||||
return value
|
||||
|
||||
|
||||
def _parse_active_window(record: Mapping[str, Any]) -> None:
|
||||
try:
|
||||
not_before = dt.datetime.fromisoformat(str(record["not_before"]).replace("Z", "+00:00"))
|
||||
expires_at = dt.datetime.fromisoformat(str(record["expires_at"]).replace("Z", "+00:00"))
|
||||
except (KeyError, ValueError) as exc:
|
||||
raise ProvisioningError("KEYRING_BINDING_INVALID") from exc
|
||||
now = utc_now()
|
||||
if not_before.tzinfo is None or expires_at.tzinfo is None or not (not_before <= now < expires_at):
|
||||
raise ProvisioningError("KEYRING_BINDING_INVALID")
|
||||
|
||||
|
||||
def _verify_existing_state(
|
||||
config: ProvisionerConfig,
|
||||
payload: Mapping[str, Any],
|
||||
raw: bytes,
|
||||
signature_bytes: bytes,
|
||||
row: Mapping[str, Any],
|
||||
) -> None:
|
||||
receipt_keys = {
|
||||
"schema", "status", "created_at", "operation_key", "payload_sha256",
|
||||
"signature_sha256", "source_commit", "source_tree", "key_id",
|
||||
"generation", "algorithm", "purpose", "service_principal",
|
||||
"service_principal_bound", "public_key_sha256",
|
||||
"private_key_exportable", "credential_projection",
|
||||
"negative_capability_tests", "production_mutation",
|
||||
"protected_ref_mutation", "tag_mutation", "baseline_mutation",
|
||||
}
|
||||
if set(row) != receipt_keys:
|
||||
raise ProvisioningError("RECEIPT_CONFLICT")
|
||||
try:
|
||||
created_at = dt.datetime.fromisoformat(str(row["created_at"]).replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ProvisioningError("RECEIPT_CONFLICT") from exc
|
||||
identity = payload["signing_identity"]
|
||||
source = payload["source"]
|
||||
expected = {
|
||||
"schema": "aios.trusted-builder-signer-provisioning-receipt.v1",
|
||||
"status": "COMPLETED",
|
||||
"operation_key": "payload:" + sha256_bytes(raw) + ":signature:" + sha256_bytes(signature_bytes),
|
||||
"payload_sha256": sha256_bytes(raw),
|
||||
"signature_sha256": sha256_bytes(signature_bytes),
|
||||
"source_commit": source["gitea_main_oid"],
|
||||
"source_tree": source["gitea_main_tree_oid"],
|
||||
"key_id": identity["key_id"],
|
||||
"generation": identity["key_generation"],
|
||||
"algorithm": "Ed25519",
|
||||
"purpose": PURPOSE,
|
||||
"service_principal": identity["service_principal"],
|
||||
"service_principal_bound": True,
|
||||
"private_key_exportable": False,
|
||||
"credential_projection": "systemd-encrypted-host-credential",
|
||||
"negative_capability_tests": {name: True for name in sorted(DENIED_CAPABILITIES)},
|
||||
"production_mutation": False,
|
||||
"protected_ref_mutation": False,
|
||||
"tag_mutation": False,
|
||||
"baseline_mutation": False,
|
||||
}
|
||||
if created_at.tzinfo is None or any(row.get(key) != value for key, value in expected.items()):
|
||||
raise ProvisioningError("RECEIPT_CONFLICT")
|
||||
|
||||
public_keyring = _read_state_json(config.public_keyring, "KEYRING_BINDING_INVALID")
|
||||
if set(public_keyring) != {"algorithm", "purpose", "keys", "keyring_sha256"}:
|
||||
raise ProvisioningError("KEYRING_BINDING_INVALID")
|
||||
keys = public_keyring.get("keys")
|
||||
if not isinstance(keys, list) or len(keys) != 1 or not isinstance(keys[0], Mapping):
|
||||
raise ProvisioningError("KEYRING_BINDING_INVALID")
|
||||
record = dict(keys[0])
|
||||
if set(record) != {
|
||||
"algorithm", "purpose", "key_id", "generation", "not_before",
|
||||
"expires_at", "status", "public_key", "public_key_sha256",
|
||||
}:
|
||||
raise ProvisioningError("KEYRING_BINDING_INVALID")
|
||||
try:
|
||||
public_key = base64.b64decode(str(record["public_key"]), validate=True)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ProvisioningError("KEYRING_BINDING_INVALID") from exc
|
||||
public_sha = sha256_bytes(public_key)
|
||||
keyring_body = {key: public_keyring[key] for key in ("algorithm", "purpose", "keys")}
|
||||
if (
|
||||
len(public_key) != 32
|
||||
or public_keyring["algorithm"] != "Ed25519"
|
||||
or public_keyring["purpose"] != PURPOSE
|
||||
or record.get("algorithm") != "Ed25519"
|
||||
or record.get("purpose") != PURPOSE
|
||||
or record.get("key_id") != identity["key_id"]
|
||||
or record.get("generation") != identity["key_generation"]
|
||||
or record.get("status") != "ACTIVE"
|
||||
or record.get("public_key_sha256") != public_sha
|
||||
or row.get("public_key_sha256") != public_sha
|
||||
or public_keyring.get("keyring_sha256") != sha256_bytes(canonical_json(keyring_body))
|
||||
):
|
||||
raise ProvisioningError("KEYRING_BINDING_INVALID")
|
||||
_parse_active_window(record)
|
||||
|
||||
binding = _read_state_json(config.binding_file, "SERVICE_PRINCIPAL_BINDING_INVALID")
|
||||
expected_binding = {
|
||||
"schema": "aios.trusted-builder.service-principal-binding.v1",
|
||||
"purpose": PURPOSE,
|
||||
"service_principal": identity["service_principal"],
|
||||
"key_id": identity["key_id"],
|
||||
"generation": identity["key_generation"],
|
||||
"algorithm": "Ed25519",
|
||||
"private_key_export": False,
|
||||
"public_key_sha256": public_sha,
|
||||
"source_commit": source["gitea_main_oid"],
|
||||
"source_tree": source["gitea_main_tree_oid"],
|
||||
"payload_sha256": sha256_bytes(raw),
|
||||
}
|
||||
if binding != expected_binding:
|
||||
raise ProvisioningError("SERVICE_PRINCIPAL_BINDING_INVALID")
|
||||
|
||||
if (
|
||||
config.credential_store.is_symlink()
|
||||
or not config.credential_store.is_dir()
|
||||
or stat.S_IMODE(config.credential_store.stat().st_mode) != 0o700
|
||||
):
|
||||
raise ProvisioningError("CREDENTIAL_STORE_INVALID")
|
||||
_require_private_regular_file(
|
||||
config.credential_store / "signing_private_key", "RECEIPT_OUTPUT_MISSING"
|
||||
)
|
||||
_require_private_regular_file(
|
||||
config.credential_store / "builder_hmac", "RECEIPT_OUTPUT_MISSING"
|
||||
)
|
||||
|
||||
|
||||
def provision(config: ProvisionerConfig, payload_path: Path, signature_path: Path) -> dict[str, Any]:
|
||||
if os.name != "nt" and os.geteuid() != 0:
|
||||
raise ProvisioningError("ROOT_REQUIRED")
|
||||
raw, payload = _read_json(payload_path)
|
||||
_validate_payload(payload)
|
||||
_run_signature_verify(config, raw, signature_path)
|
||||
signature_bytes = signature_path.read_bytes()
|
||||
operation_key = "payload:" + sha256_bytes(raw) + ":signature:" + sha256_bytes(signature_bytes)
|
||||
lock_path = config.receipt_log.with_suffix(".lock")
|
||||
_safe_parent(lock_path)
|
||||
with lock_path.open("a+b") as lock:
|
||||
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
||||
rows = _receipts(config.receipt_log)
|
||||
previous = [row for row in rows if row.get("operation_key") == operation_key]
|
||||
if previous:
|
||||
row = previous[-1]
|
||||
if any(canonical_json(item) != canonical_json(row) for item in previous):
|
||||
raise ProvisioningError("RECEIPT_CONFLICT")
|
||||
_verify_replay_source(config, payload["source"])
|
||||
_verify_existing_state(config, payload, raw, signature_bytes, row)
|
||||
return {
|
||||
"status": "IDEMPOTENT_REPLAY_PASS",
|
||||
"idempotent": True,
|
||||
"mutation_executed": False,
|
||||
"existing_signer_state_adopted": True,
|
||||
**{key: row[key] for key in (
|
||||
"operation_key", "payload_sha256", "signature_sha256",
|
||||
"source_commit", "source_tree", "key_id", "generation",
|
||||
"public_key_sha256", "purpose", "service_principal_bound",
|
||||
"private_key_exportable",
|
||||
)},
|
||||
"negative_capability_tests": row["negative_capability_tests"],
|
||||
}
|
||||
_verify_source(config, payload["source"])
|
||||
outputs = [config.public_keyring, config.binding_file, config.credential_store / "signing_private_key", config.credential_store / "builder_hmac"]
|
||||
if any(path.exists() or path.is_symlink() for path in outputs):
|
||||
raise ProvisioningError("PREEXISTING_STATE_CONFLICT")
|
||||
config.credential_store.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
os.chmod(config.credential_store, 0o700)
|
||||
temp_root = Path(tempfile.mkdtemp(prefix="trusted-builder-provision-"))
|
||||
try:
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
now = utc_now()
|
||||
private = Ed25519PrivateKey.generate()
|
||||
public = private.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
||||
private_path = temp_root / "signing_private_key.pem"
|
||||
private_path.write_bytes(private.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
|
||||
os.chmod(private_path, 0o600)
|
||||
hmac_path = temp_root / "builder_hmac"
|
||||
hmac_path.write_bytes(secrets.token_bytes(32))
|
||||
os.chmod(hmac_path, 0o600)
|
||||
enc_private = temp_root / "signing_private_key.cred"
|
||||
enc_hmac = temp_root / "builder_hmac.cred"
|
||||
_encrypt(config, private_path, enc_private, "signing_private_key")
|
||||
_encrypt(config, hmac_path, enc_hmac, "builder_hmac")
|
||||
keyring = _keyring(payload["signing_identity"], public, now)
|
||||
binding = {
|
||||
"schema": "aios.trusted-builder.service-principal-binding.v1",
|
||||
"purpose": PURPOSE,
|
||||
"service_principal": payload["signing_identity"]["service_principal"],
|
||||
"key_id": payload["signing_identity"]["key_id"],
|
||||
"generation": payload["signing_identity"]["key_generation"],
|
||||
"algorithm": "Ed25519",
|
||||
"private_key_export": False,
|
||||
"public_key_sha256": sha256_bytes(public),
|
||||
"source_commit": payload["source"]["gitea_main_oid"],
|
||||
"source_tree": payload["source"]["gitea_main_tree_oid"],
|
||||
"payload_sha256": sha256_bytes(raw),
|
||||
}
|
||||
_atomic_write(temp_root / "public-keyring.json", pretty_json(keyring))
|
||||
_atomic_write(temp_root / "service-principal-binding.json", pretty_json(binding))
|
||||
config.public_keyring.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
config.binding_file.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
os.replace(temp_root / "signing_private_key.cred", config.credential_store / "signing_private_key")
|
||||
os.replace(temp_root / "builder_hmac.cred", config.credential_store / "builder_hmac")
|
||||
os.replace(temp_root / "public-keyring.json", config.public_keyring)
|
||||
os.replace(temp_root / "service-principal-binding.json", config.binding_file)
|
||||
for path in outputs:
|
||||
os.chmod(path, 0o600)
|
||||
receipt = {
|
||||
"schema": "aios.trusted-builder-signer-provisioning-receipt.v1",
|
||||
"status": "COMPLETED",
|
||||
"created_at": iso(now),
|
||||
"operation_key": operation_key,
|
||||
"payload_sha256": sha256_bytes(raw),
|
||||
"signature_sha256": sha256_bytes(signature_bytes),
|
||||
"source_commit": payload["source"]["gitea_main_oid"],
|
||||
"source_tree": payload["source"]["gitea_main_tree_oid"],
|
||||
"key_id": payload["signing_identity"]["key_id"],
|
||||
"generation": payload["signing_identity"]["key_generation"],
|
||||
"algorithm": "Ed25519",
|
||||
"purpose": PURPOSE,
|
||||
"service_principal": payload["signing_identity"]["service_principal"],
|
||||
"service_principal_bound": True,
|
||||
"public_key_sha256": sha256_bytes(public),
|
||||
"private_key_exportable": False,
|
||||
"credential_projection": "systemd-encrypted-host-credential",
|
||||
"negative_capability_tests": {name: True for name in sorted(DENIED_CAPABILITIES)},
|
||||
"production_mutation": False,
|
||||
"protected_ref_mutation": False,
|
||||
"tag_mutation": False,
|
||||
"baseline_mutation": False,
|
||||
}
|
||||
_append(config.receipt_log, receipt)
|
||||
return {"status": "PROVISIONED", "idempotent": False, "mutation_executed": True, "existing_signer_state_adopted": False, **{key: receipt[key] for key in ("operation_key", "payload_sha256", "signature_sha256", "source_commit", "source_tree", "key_id", "generation", "purpose", "service_principal_bound", "public_key_sha256", "private_key_exportable")}, "negative_capability_tests": receipt["negative_capability_tests"]}
|
||||
finally:
|
||||
shutil.rmtree(temp_root, ignore_errors=True)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Provision the purpose-bound trusted-builder signer")
|
||||
parser.add_argument("--payload", type=Path)
|
||||
parser.add_argument("--signature", type=Path)
|
||||
parser.add_argument("--result-json", type=Path)
|
||||
parser.add_argument("--self-test", action="store_true")
|
||||
parser.add_argument("--verify-package", action="store_true")
|
||||
parser.add_argument("--source-root", type=Path)
|
||||
args = parser.parse_args(argv)
|
||||
if args.self_test:
|
||||
print("PASS")
|
||||
return 0
|
||||
if args.verify_package:
|
||||
if args.source_root is None:
|
||||
parser.error("--verify-package requires --source-root")
|
||||
try:
|
||||
verify_package_contract(args.source_root)
|
||||
print("PACKAGE_CONTRACT_VALID=PASS")
|
||||
return 0
|
||||
except ProvisioningError as exc:
|
||||
print(json.dumps({"status": "BLOCKED", "error_code": exc.code}, sort_keys=True))
|
||||
return 42
|
||||
if not args.payload or not args.signature or not args.result_json:
|
||||
parser.error("--payload, --signature and --result-json are required")
|
||||
try:
|
||||
result = provision(ProvisionerConfig(), args.payload, args.signature)
|
||||
_atomic_write(args.result_json, pretty_json(result))
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
except ProvisioningError as exc:
|
||||
result = {"schema": "aios.trusted-builder-signer-provisioning-result.v1", "status": "BLOCKED", "error_code": exc.code}
|
||||
try:
|
||||
_atomic_write(args.result_json, pretty_json(result))
|
||||
except Exception:
|
||||
pass
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 42
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,117 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Plan-bound production adapter for releases newer than the legacy baseline.
|
||||
|
||||
All identity comes from the already verified executor request. The adapter has
|
||||
no Git/ref/tag authority and refuses a missing candidate/release-plan binding.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import hashlib, json, os, shutil, subprocess, sys, tempfile, urllib.error, urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
class AdapterBlocked(RuntimeError): pass
|
||||
|
||||
ALLOWED_SERVICE_PORTS = {"aios-v2r2c2-production.service": 18928}
|
||||
|
||||
def _safe(value: str) -> str:
|
||||
if not value or any(ch not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-" for ch in value):
|
||||
raise AdapterBlocked("invalid_release_identity")
|
||||
return value
|
||||
|
||||
def _run(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(args, check=False, capture_output=True, text=True, timeout=60, env={"PATH": os.environ.get("PATH", "")})
|
||||
if check and result.returncode != 0: raise AdapterBlocked(result.stderr.strip() or "system_command_failed")
|
||||
return result
|
||||
|
||||
def _sha(path: Path) -> str:
|
||||
if path.is_symlink() or not path.is_file(): raise AdapterBlocked("artifact_must_be_regular_file")
|
||||
digest=hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for block in iter(lambda: stream.read(1024*1024), b""): digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
def _binding(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
required=("version","target_oid","artifact_sha256","artifact_path","artifact_size","production_service","production_port","candidate_id","release_plan_sha256")
|
||||
if any(key not in value for key in required): raise AdapterBlocked("trusted_binding_incomplete")
|
||||
if str(value["version"]).startswith("V2R1") or not value["candidate_id"] or not value["release_plan_sha256"]: raise AdapterBlocked("legacy_or_unbound_release_rejected")
|
||||
service = str(value["production_service"])
|
||||
port = int(value["production_port"])
|
||||
if ALLOWED_SERVICE_PORTS.get(service) != port:
|
||||
raise AdapterBlocked("production_adapter_policy_rejected")
|
||||
return {key:value[key] for key in required}
|
||||
|
||||
def _paths(binding: Mapping[str, Any]) -> dict[str, Path]:
|
||||
version=_safe(str(binding["version"])); service=_safe(str(binding["production_service"]))
|
||||
root=Path(os.environ.get("AIOS_RELEASE_ROOT", "/opt/aios/releases"))
|
||||
slug=version.lower()
|
||||
return {"release_dir":root/(version+"-"+str(binding["target_oid"])[:12]+"-"+str(binding["artifact_sha256"])[:12]),"current_link":Path("/opt/aios")/slug,"env_file":Path("/etc/aios")/(slug+".env"),"state_file":Path("/var/lib/aios")/slug/"deployment-state.json","unit_file":Path("/etc/systemd/system")/(service+".service" if not service.endswith(".service") else service),"health_url":f"http://127.0.0.1:{int(binding['production_port'])}/health"}
|
||||
|
||||
def _health(url: str, binding: Mapping[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=5) as response: body=json.loads(response.read().decode()); status=response.status
|
||||
except (OSError, ValueError, urllib.error.URLError): return {"status":"UNAVAILABLE"}
|
||||
exact=(status==200 and body.get("version")==binding["version"] and body.get("commit_oid")==binding["target_oid"] and body.get("artifact_sha256")==binding["artifact_sha256"] and body.get("deployment_state")=="ACTIVE" and body.get("production_deployed") is True)
|
||||
return {"status":"ACTIVE_VERIFIED" if exact else "CONFLICT", "http_status":status, **body}
|
||||
|
||||
def _extract(artifact: Path, destination: Path) -> None:
|
||||
import tarfile
|
||||
with tarfile.open(artifact, "r:*") as archive:
|
||||
for member in archive.getmembers():
|
||||
name=Path(member.name)
|
||||
if name.is_absolute() or ".." in name.parts or member.issym() or member.islnk() or not member.isfile() or not member.name.startswith("payload/"):
|
||||
raise AdapterBlocked("unsafe_artifact_member")
|
||||
rel=name.relative_to("payload"); target=destination/rel; target.parent.mkdir(parents=True,exist_ok=True)
|
||||
source=archive.extractfile(member)
|
||||
if source is None: raise AdapterBlocked("artifact_member_unreadable")
|
||||
target.write_bytes(source.read()); os.chmod(target,0o755 if rel.parts and rel.parts[0]=="scripts" else 0o644)
|
||||
|
||||
def probe(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
binding=_binding(value); p=_paths(binding); active=_run("systemctl","is-active","--quiet",str(binding["production_service"]),check=False).returncode==0
|
||||
return _health(str(p["health_url"]),binding) if active else {"status":"NOT_APPLIED","version":binding["version"],"target_oid":binding["target_oid"],"artifact_sha256":binding["artifact_sha256"],"production_deployed":False,"deployment_state":"ABSENT"}
|
||||
|
||||
def apply(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
binding=_binding(value); p=_paths(binding); p["release_dir"].parent.mkdir(parents=True, exist_ok=True); artifact=Path(str(binding["artifact_path"]))
|
||||
if artifact.stat().st_size != int(binding["artifact_size"]) or _sha(artifact)!=binding["artifact_sha256"]: raise AdapterBlocked("artifact_binding_mismatch")
|
||||
marker={"schema":"aios.trusted-runtime-release-binding.v1",**binding}
|
||||
marker_path=p["release_dir"]/"DEPLOYMENT-BINDING.json"
|
||||
if p["release_dir"].exists():
|
||||
if not marker_path.is_file() or json.loads(marker_path.read_text())!=marker: raise AdapterBlocked("existing_release_binding_conflict")
|
||||
else:
|
||||
temp=Path(tempfile.mkdtemp(prefix=p["release_dir"].name+".",dir=str(p["release_dir"].parent)))
|
||||
try:
|
||||
_extract(artifact,temp); (temp/"DEPLOYMENT-BINDING.json").write_text(json.dumps(marker,sort_keys=True,separators=(",",":"))+"\n"); os.rename(temp,p["release_dir"])
|
||||
except Exception:
|
||||
shutil.rmtree(temp,ignore_errors=True); raise
|
||||
version_file=p["release_dir"] / "VERSION"
|
||||
if not version_file.is_file() or version_file.read_text().strip()!=binding["version"]: raise AdapterBlocked("release_version_mismatch")
|
||||
unit_source=p["release_dir"] / "deploy" / "aios-v2" / "systemd" / str(binding["production_service"])
|
||||
if not unit_source.is_file(): raise AdapterBlocked("release_unit_missing")
|
||||
p["unit_file"].parent.mkdir(parents=True,exist_ok=True); shutil.copyfile(unit_source,p["unit_file"])
|
||||
p["current_link"].parent.mkdir(parents=True,exist_ok=True)
|
||||
if p["current_link"].exists() and not p["current_link"].is_symlink(): raise AdapterBlocked("current_release_path_conflict")
|
||||
if not p["current_link"].is_symlink(): p["current_link"].symlink_to(p["release_dir"])
|
||||
p["state_file"].parent.mkdir(parents=True,exist_ok=True); p["state_file"].write_text(json.dumps({"version":binding["version"],"commit_oid":binding["target_oid"],"artifact_sha256":binding["artifact_sha256"],"candidate_id":binding["candidate_id"],"release_plan_sha256":binding["release_plan_sha256"],"deployment_state":"ACTIVE","production_deployed":True},sort_keys=True)+"\n")
|
||||
env=(f"RELEASE_VERSION={binding['version']}\n" f"TARGET_OID={binding['target_oid']}\n"
|
||||
f"ARTIFACT_SHA256={binding['artifact_sha256']}\n" f"CANDIDATE_ID={binding['candidate_id']}\n"
|
||||
f"RELEASE_PLAN_SHA256={binding['release_plan_sha256']}\n" f"DEPLOYMENT_STATE=ACTIVE\n"
|
||||
f"DEPLOYMENT_STATE_FILE={p['state_file']}\n" f"AIOS_V2R2C2_PORT={binding['production_port']}\n"
|
||||
"AIOS_GITOPS_WRITES_ENABLED=false\n")
|
||||
p["env_file"].parent.mkdir(parents=True,exist_ok=True); p["env_file"].write_text(env)
|
||||
_run("systemctl","daemon-reload"); _run("systemctl","enable",str(binding["production_service"])); _run("systemctl","restart",str(binding["production_service"]))
|
||||
result=_health(str(p["health_url"]),binding)
|
||||
if result["status"]!="ACTIVE_VERIFIED": raise AdapterBlocked("production_health_binding_mismatch")
|
||||
return result
|
||||
|
||||
def rollback(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
binding=_binding(value); _run("systemctl","stop",str(binding["production_service"]),check=False); _run("systemctl","disable",str(binding["production_service"]),check=False)
|
||||
p=_paths(binding); p["state_file"].parent.mkdir(parents=True,exist_ok=True); p["state_file"].write_text(json.dumps({"version":binding["version"],"commit_oid":binding["target_oid"],"artifact_sha256":binding["artifact_sha256"],"deployment_state":"ROLLED_BACK","production_deployed":False},sort_keys=True)+"\n")
|
||||
return {"status":"ROLLED_BACK","version":binding["version"],"target_oid":binding["target_oid"],"artifact_sha256":binding["artifact_sha256"],"production_deployed":False,"deployment_state":"ROLLED_BACK"}
|
||||
|
||||
def main() -> int:
|
||||
value=json.loads(sys.stdin.read()); operation=value.get("operation")
|
||||
result=probe(value) if operation=="probe" else apply(value) if operation=="apply" else rollback(value) if operation=="rollback" else (_ for _ in ()).throw(AdapterBlocked("unsupported_operation"))
|
||||
print(json.dumps(result,sort_keys=True)); return 0
|
||||
|
||||
if __name__=="__main__":
|
||||
try: raise SystemExit(main())
|
||||
except Exception as exc: print(json.dumps({"status":"FAIL_CLOSED","error":type(exc).__name__,"reason":str(exc)},sort_keys=True)); raise SystemExit(2)
|
||||
@@ -1,101 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Canonical, fail-closed projection for trusted release execution plans.
|
||||
|
||||
A release plan is the only authority for candidate, source, artifact, policy,
|
||||
rollback, and release identity. Callers may not provide executable paths or
|
||||
replace individual fields after the plan has been accepted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import hashlib, json, re, stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
PLAN_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{2,255}$")
|
||||
TOP_LEVEL_KEYS = frozenset({"schema","version","candidate_id","source_commit","tree_oid","artifact_sha256","manifest_sha256","package_closure_sha256","deployment_plan_sha256","policy_sha256","release_plan_id","approval_plan_id","evidence_closure_oid","campaign_authorization_sha256","campaign_generation","artifact","rollback","baseline","adapter_policy","release_plan_sha256"})
|
||||
FORBIDDEN_KEYS = frozenset({"command","commands","executable","exec","shell","argv","adapter_command","tag_command","deploy_command","registry_command","baseline_command","sudo","force_push","direct_main_push"})
|
||||
class PlanError(ValueError):
|
||||
"""A release plan is malformed, inconsistent, or unsafe."""
|
||||
def canonical_json(value: object) -> bytes:
|
||||
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
||||
def _sha(value: Any, label: str) -> str:
|
||||
if not isinstance(value, str) or not SHA_RE.fullmatch(value): raise PlanError(f"{label}_invalid")
|
||||
return value
|
||||
def _oid(value: Any, label: str) -> str:
|
||||
if not isinstance(value, str) or not OID_RE.fullmatch(value): raise PlanError(f"{label}_invalid")
|
||||
return value
|
||||
def _walk_forbidden(value: Any) -> None:
|
||||
if isinstance(value, Mapping):
|
||||
for key, item in value.items():
|
||||
if str(key).lower() in FORBIDDEN_KEYS: raise PlanError(f"forbidden_plan_key:{key}")
|
||||
_walk_forbidden(item)
|
||||
elif isinstance(value, list):
|
||||
for item in value: _walk_forbidden(item)
|
||||
def _exact_mapping(value: Any, required: set[str], label: str) -> Mapping[str, Any]:
|
||||
if not isinstance(value, Mapping) or set(value) != required: raise PlanError(f"{label}_fields_invalid")
|
||||
return value
|
||||
def _secure_json(path: Path) -> dict[str, Any]:
|
||||
try: st = path.lstat()
|
||||
except OSError as exc: raise PlanError(f"plan_unreadable:{path}") from exc
|
||||
if path.is_symlink() or not stat.S_ISREG(st.st_mode): raise PlanError("plan_must_be_regular_file")
|
||||
if st.st_uid != 0 or st.st_mode & 0o022: raise PlanError("plan_ownership_or_mode_invalid")
|
||||
try: value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc: raise PlanError("plan_json_invalid") from exc
|
||||
if not isinstance(value, dict): raise PlanError("plan_object_required")
|
||||
return value
|
||||
@dataclass(frozen=True)
|
||||
class TrustedReleasePlan:
|
||||
value: Mapping[str, Any]
|
||||
release_plan_sha256: str
|
||||
@classmethod
|
||||
def from_mapping(cls, raw: Mapping[str, Any]) -> "TrustedReleasePlan":
|
||||
if not isinstance(raw, Mapping) or set(raw) - TOP_LEVEL_KEYS: raise PlanError("release_plan_fields_invalid")
|
||||
required = TOP_LEVEL_KEYS - {"release_plan_sha256"}
|
||||
if set(raw) not in (required, TOP_LEVEL_KEYS): raise PlanError("release_plan_fields_incomplete")
|
||||
_walk_forbidden(raw)
|
||||
if raw.get("schema") != "aios.trusted-release-execution-plan.v1": raise PlanError("release_plan_schema_invalid")
|
||||
version = raw.get("version")
|
||||
if not isinstance(version, str) or not re.fullmatch(r"V[0-9]+R[0-9]+C[0-9]+", version): raise PlanError("version_invalid")
|
||||
if not isinstance(raw.get("candidate_id"), str) or not IDENTIFIER_RE.fullmatch(raw["candidate_id"]): raise PlanError("candidate_id_invalid")
|
||||
for key in ("source_commit","tree_oid","evidence_closure_oid"): _oid(raw.get(key), key)
|
||||
for key in ("artifact_sha256","manifest_sha256","package_closure_sha256","deployment_plan_sha256","policy_sha256","campaign_authorization_sha256"): _sha(raw.get(key), key)
|
||||
for key in ("release_plan_id","approval_plan_id"):
|
||||
if not isinstance(raw.get(key), str) or not PLAN_ID_RE.fullmatch(raw[key]): raise PlanError(f"{key}_invalid")
|
||||
if not isinstance(raw.get("campaign_generation"), int) or raw["campaign_generation"] < 1: raise PlanError("campaign_generation_invalid")
|
||||
artifact = _exact_mapping(raw.get("artifact"), {"path","size"}, "artifact")
|
||||
if not isinstance(artifact["path"], str) or not artifact["path"].startswith("/") or "\\" in artifact["path"]: raise PlanError("artifact_path_invalid")
|
||||
if not isinstance(artifact["size"], int) or artifact["size"] <= 0: raise PlanError("artifact_size_invalid")
|
||||
rollback = _exact_mapping(raw.get("rollback"), {"version","target_oid","artifact_sha256"}, "rollback")
|
||||
if not isinstance(rollback["version"], str) or not rollback["version"] or rollback["version"] == version: raise PlanError("rollback_version_invalid")
|
||||
_oid(rollback["target_oid"], "rollback_target_oid"); _sha(rollback["artifact_sha256"], "rollback_artifact_sha256")
|
||||
baseline = _exact_mapping(raw.get("baseline"), {"version","target_oid"}, "baseline")
|
||||
if baseline["version"] != rollback["version"]: raise PlanError("baseline_rollback_version_mismatch")
|
||||
_oid(baseline["target_oid"], "baseline_target_oid")
|
||||
adapter = _exact_mapping(raw.get("adapter_policy"), {"policy_id","service","port"}, "adapter_policy")
|
||||
if adapter["policy_id"] != "aios.production-adapter.allowlisted.v1": raise PlanError("adapter_policy_invalid")
|
||||
if not isinstance(adapter["service"], str) or not IDENTIFIER_RE.fullmatch(adapter["service"]): raise PlanError("adapter_service_invalid")
|
||||
if not isinstance(adapter["port"], int) or not 1024 <= adapter["port"] <= 65535: raise PlanError("adapter_port_invalid")
|
||||
payload = {key: raw[key] for key in TOP_LEVEL_KEYS if key != "release_plan_sha256"}
|
||||
digest = hashlib.sha256(canonical_json(payload)).hexdigest()
|
||||
if raw.get("release_plan_sha256") is not None and raw["release_plan_sha256"] != digest: raise PlanError("release_plan_sha256_mismatch")
|
||||
return cls(value=dict(payload), release_plan_sha256=digest)
|
||||
@classmethod
|
||||
def load(cls, path: Path) -> "TrustedReleasePlan": return cls.from_mapping(_secure_json(path))
|
||||
@property
|
||||
def candidate_binding_sha256(self) -> str:
|
||||
value = {key: self.value[key] for key in ("candidate_id","source_commit","tree_oid","artifact_sha256","manifest_sha256")}
|
||||
return hashlib.sha256(canonical_json(value)).hexdigest()
|
||||
def as_dict(self) -> dict[str, Any]: return {**dict(self.value),"release_plan_sha256":self.release_plan_sha256,"candidate_binding_sha256":self.candidate_binding_sha256}
|
||||
def assert_binding(self, binding: Mapping[str, Any]) -> None:
|
||||
expected={"version":self.value["version"],"target_oid":self.value["source_commit"],"evidence_closure_oid":self.value["evidence_closure_oid"],"artifact_sha256":self.value["artifact_sha256"],"artifact_size":self.value["artifact"]["size"],"approval_plan_id":self.value["approval_plan_id"]}
|
||||
for key,value in expected.items():
|
||||
if binding.get(key) != value: raise PlanError(f"binding_plan_mismatch:{key}")
|
||||
if binding.get("candidate_id") is not None and binding.get("candidate_id") != self.value["candidate_id"]:
|
||||
raise PlanError("binding_plan_mismatch:candidate_id")
|
||||
def assert_artifact(self, artifact: Mapping[str, Any]) -> None:
|
||||
expected=self.value["artifact"]
|
||||
if artifact.get("sha256") != self.value["artifact_sha256"] or artifact.get("size") != expected["size"]: raise PlanError("artifact_plan_mismatch")
|
||||
if artifact.get("manifest_sha256") != self.value["manifest_sha256"]: raise PlanError("manifest_plan_mismatch")
|
||||
if artifact.get("package_closure_sha256") != self.value["package_closure_sha256"]: raise PlanError("package_closure_plan_mismatch")
|
||||
@@ -1,5 +0,0 @@
|
||||
"""V2R2C2 production health entry point; all identity is plan supplied."""
|
||||
from v2r1c6_privacy_tools import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"allowed_peer_cgroups": [
|
||||
"/system.slice/aios-gitops-model-api-v2r2c1-staging.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",
|
||||
"allowed_peer_uid": 1000,
|
||||
"evidence_layout": {
|
||||
"artifact": "{version}/artifact.tar.gz",
|
||||
"canary": "{version}/canary.json",
|
||||
"gitea": "{version}/gitea.json",
|
||||
"manifest": "{version}/manifest.json",
|
||||
"package_closure": "{version}/checksums.sha256",
|
||||
"policy": "{version}/trusted-release-policy.json",
|
||||
"rollback": "{version}/rollback.json",
|
||||
"secret_scan": "{version}/secret-scan.json",
|
||||
"tests": "{version}/tests.json"
|
||||
},
|
||||
"governed_recipes": {},
|
||||
"maximum_request_age_seconds": 60,
|
||||
"operation": "BuildTrustedCandidateV1",
|
||||
"readiness_operation": "ProbeTrustedCandidateBuilderV1",
|
||||
"recipes": {
|
||||
"V2R2C2": {
|
||||
"base_oid": "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6",
|
||||
"candidate_id": "rc-v2r2c2-d2b5a8339a34",
|
||||
"mode": "fixed",
|
||||
"purpose": "AIOS_TRUSTED_RELEASE_BUILDER",
|
||||
"source_commit": "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6",
|
||||
"task_id": "v12-durable-trusted-release-controller-activation-20260829",
|
||||
"version": "V2R2C2"
|
||||
},
|
||||
"V2R2C3": {
|
||||
"derives_from": "V2R2C2",
|
||||
"mode": "governed-derived-only",
|
||||
"purpose": "AIOS_TRUSTED_RELEASE_BUILDER",
|
||||
"version": "V2R2C3"
|
||||
}
|
||||
},
|
||||
"repository": "aios/aios",
|
||||
"request_schema": "aios.trusted-candidate-builder.request.v1",
|
||||
"request_timeout_seconds": 30,
|
||||
"required_checks": [
|
||||
"authoritative_git",
|
||||
"artifact",
|
||||
"canary",
|
||||
"gitea",
|
||||
"manifest",
|
||||
"package_closure",
|
||||
"rollback",
|
||||
"secret_scan",
|
||||
"tests"
|
||||
],
|
||||
"response_schema": "aios.trusted-candidate-builder.response.v1",
|
||||
"schema": "aios.trusted-candidate-builder-policy.v1"
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"authoritative_git_dirs": [
|
||||
"/data/aios-gitops/repo.git"
|
||||
],
|
||||
"cutover_links": [
|
||||
"/opt/aios/v2r2c2"
|
||||
],
|
||||
"dropin_paths": [
|
||||
"/var/lib/aios-gitops/release-only/v2r2c2-d2b5a8339a34/payload/deploy/aios-v2/systemd/aios-v2r2c2-production.service"
|
||||
],
|
||||
"release_roots": [
|
||||
"/var/lib/aios-gitops/release-only"
|
||||
],
|
||||
"repository_identities": [
|
||||
"aios/aios"
|
||||
],
|
||||
"services": [
|
||||
"aios-v2r2c2-production.service"
|
||||
]
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
[Unit]
|
||||
Description=AIOS V2R2C2 governed trusted release health service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios-v2r1c7
|
||||
Group=aios-v2r1c7
|
||||
WorkingDirectory=/opt/aios/v2r2c2
|
||||
EnvironmentFile=/etc/aios/v2r2c2.env
|
||||
ExecStart=/usr/bin/python3 /opt/aios/v2r2c2/active-runtime/app/v2r2c2_health_service.py --serve --host 127.0.0.1 --port ${AIOS_V2R2C2_PORT}
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ProtectKernelLogs=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectControlGroups=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=localhost
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
"""Installed entry point for the governed trusted-builder provisioner."""
|
||||
from __future__ import annotations
|
||||
|
||||
import runpy
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/opt/aios/trusted-candidate-builder/current/app")
|
||||
runpy.run_module("trusted_builder_signer_provisioning", run_name="__main__")
|
||||
@@ -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
|
||||
@@ -15,9 +15,17 @@ ExecStart=/usr/bin/python3 /usr/libexec/aios-approved-release-executor/current/a
|
||||
--authorization ${AIOS_RELEASE_AUTHORIZATION} \
|
||||
--repository ${AIOS_GITOPS_AUTHORITATIVE_GIT_DIR} \
|
||||
--state-root ${AIOS_RELEASE_EXECUTION_STATE_ROOT} \
|
||||
--release-plan ${AIOS_RELEASE_PLAN} \
|
||||
--artifact ${AIOS_RELEASE_ARTIFACT} \
|
||||
--artifact-size ${AIOS_RELEASE_ARTIFACT_SIZE} \
|
||||
--version ${AIOS_RELEASE_VERSION} \
|
||||
--target-oid ${AIOS_RELEASE_TARGET_OID} \
|
||||
--evidence-closure-oid ${AIOS_RELEASE_EVIDENCE_CLOSURE_OID} \
|
||||
--artifact-sha256 ${AIOS_RELEASE_ARTIFACT_SHA256} \
|
||||
--approval-plan-id ${AIOS_RELEASE_APPROVAL_PLAN_ID} \
|
||||
--production-service aios-v2r1c7-production.service \
|
||||
--production-port 18927 \
|
||||
--tag-adapter /usr/bin/python3 /usr/libexec/aios-approved-release-executor/current/active-runtime/app/governed_tag_adapter.py \
|
||||
--deploy-adapter /usr/bin/python3 /usr/libexec/aios-approved-release-executor/current/active-runtime/app/trusted_production_adapter.py \
|
||||
--deploy-adapter /usr/bin/python3 /usr/libexec/aios-approved-release-executor/current/active-runtime/app/v2r1c7_production_adapter.py \
|
||||
--registry-adapter /usr/bin/env AIOS_RELEASE_METADATA_MODE=registry /usr/bin/python3 /usr/libexec/aios-approved-release-executor/current/active-runtime/app/release_metadata_adapter.py \
|
||||
--baseline-adapter /usr/bin/env AIOS_RELEASE_METADATA_MODE=baseline /usr/bin/python3 /usr/libexec/aios-approved-release-executor/current/active-runtime/app/release_metadata_adapter.py
|
||||
NoNewPrivileges=true
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[Service]
|
||||
User=aiman
|
||||
Group=gitops
|
||||
WorkingDirectory=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime/app/aios_chatgpt_bridge_v2_server.py
|
||||
Environment=AIOS_CONTROL_PLANE_TARGET_OID=@SOURCE_OID@
|
||||
Environment=AIOS_CONTROL_PLANE_TARGET_VERSION=V2R2C1
|
||||
Environment=AIOS_CONTROL_PLANE_RUNTIME_PATH=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime
|
||||
Environment=AIOS_CONTROL_PLANE_ARTIFACT=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime/manifest.json
|
||||
Environment=AIOS_CONTROL_PLANE_ARTIFACT_SHA256=@ARTIFACT_SHA256@
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED=true
|
||||
Environment=AIOS_GITOPS_TOKEN_FILE=%d/action_tokens
|
||||
Environment=AIOS_GITEA_API_BASE=https://g.cnnetmkt.top/git/api/v1
|
||||
Environment=AIOS_BRIDGE_V2_PORT=18892
|
||||
Environment=PYTHONPATH=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime:/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime/app:/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime/runtime-agent-os
|
||||
LoadCredential=action_tokens:/data/aios-gitops/runtime/secrets/gitops.tokens
|
||||
ReadOnlyPaths=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime /data/aios-gitops/repo.git
|
||||
ReadWritePaths=/var/lib/aios-gitops/bridge-v2-staging
|
||||
@@ -29,7 +29,7 @@ NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/aios-gitops/bridge-v2-staging /data/aios-gitops/repo.git /data/aios-gitops/repo.git/worktrees
|
||||
ReadWritePaths=/var/lib/aios-gitops/bridge-v2-staging /data/aios-gitops/repo.git
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# Non-secret activation template for a verified immutable model-api package.
|
||||
# Install only through the governed runtime activation procedure.
|
||||
[Service]
|
||||
Environment=AIOS_GITOPS_WORKSPACE=/opt/aios/gitops-model-api/<VERIFIED_PACKAGE_OID>/runtime
|
||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_ENABLED=true
|
||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_SOCKET=/run/aios-trusted-candidate-builder.sock
|
||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_POLICY=/opt/aios/gitops-model-api/<VERIFIED_PACKAGE_OID>/runtime/config/gitops/trusted-candidate-builder-policy.json
|
||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_PUBLIC_KEYRING=/etc/aios-gitops/trusted-builder/public-keyring.json
|
||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_CREDENTIALS_DIRECTORY=%d
|
||||
Environment=AIOS_SILENT_RELEASE_TRUSTED_POLICY=/opt/aios/gitops-model-api/<VERIFIED_PACKAGE_OID>/runtime/config/gitops/trusted-release-policy.json
|
||||
LoadCredential=builder_hmac:/etc/aios-gitops/credential-references/trusted-candidate-builder-hmac
|
||||
# The credential source above is a reference only; no secret value belongs here.
|
||||
# Keep execution shadow-disabled and do not add Owner/deploy/tag/baseline switches.
|
||||
Environment=AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
[Service]
|
||||
User=aiman
|
||||
Group=gitops
|
||||
WorkingDirectory=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime/app/aios_model_api_staging_list_patches_overlay.py
|
||||
Environment=AIOS_GITOPS_WORKSPACE=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime
|
||||
Environment=AIOS_GITOPS_RUNTIME_OID=@SOURCE_OID@
|
||||
Environment=AIOS_GITOPS_ARTIFACT=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime/manifest.json
|
||||
Environment=AIOS_GITOPS_ARTIFACT_SHA256=@ARTIFACT_SHA256@
|
||||
Environment=AIOS_GITOPS_DEPLOYMENT_STATE=STAGING
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED=true
|
||||
Environment=AIOS_GITOPS_JOB_CONTROLLER_ENABLED=false
|
||||
Environment=AIOS_GITOPS_TOKEN_FILE=%d/action_tokens
|
||||
Environment=AIOS_GITOPS_GITEA_API_URL=https://g.cnnetmkt.top/git/api/v1
|
||||
Environment=AIOS_BRIDGE_V2_URL=http://127.0.0.1:18892
|
||||
Environment=AIOS_GITOPS_PORT=18893
|
||||
Environment=PYTHONPATH=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime:/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime/app:/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime/runtime-agent-os
|
||||
LoadCredential=action_tokens:/data/aios-gitops/runtime/secrets/gitops.tokens
|
||||
ReadOnlyPaths=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime /data/aios-gitops/repo.git
|
||||
ReadWritePaths=/var/lib/aios-gitops/model-api-staging
|
||||
@@ -7,6 +7,11 @@ Wants=network-online.target
|
||||
Type=simple
|
||||
User=aiman
|
||||
Group=gitops
|
||||
PermissionsStartOnly=true
|
||||
ExecStartPre=/usr/bin/install -d -o aiman -g gitops -m 0750 /var/lib/aios-gitops/model-api-staging
|
||||
ExecStartPre=/usr/bin/install -d -o aiman -g gitops -m 0750 /var/lib/aios-gitops/model-api-staging/silent-release
|
||||
ExecStartPre=/usr/bin/install -d -o root -g gitops -m 0750 /var/lib/aios-gitops/model-api-staging/baseline
|
||||
ExecStartPre=/usr/bin/setfacl -m u:aiman:--x /usr/local/libexec
|
||||
WorkingDirectory=@RUNTIME_ROOT@
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
@@ -16,7 +21,7 @@ Environment=AIOS_GITOPS_REPO=/data/aios-gitops/repo.git
|
||||
Environment=AIOS_GITOPS_BARE_REPO=/data/aios-gitops/repo.git
|
||||
Environment=AIOS_GITOPS_AUTHORITATIVE_GIT_DIR=/data/aios-gitops/repo.git
|
||||
Environment=AIOS_GITOPS_REPOSITORY_IDENTITY=aios/aios
|
||||
Environment=AIOS_GITOPS_BASELINE_AUTHORITY=/var/lib/aios-gitops-runtime-anchor/baseline/current.json
|
||||
Environment=AIOS_GITOPS_BASELINE_AUTHORITY=/var/lib/aios-gitops/model-api-staging/baseline/current.json
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED=false
|
||||
Environment=AIOS_GITOPS_JOB_CONTROLLER_ENABLED=false
|
||||
@@ -27,10 +32,21 @@ Environment=AIOS_GITOPS_DEPLOYMENT_STATE=STAGING
|
||||
Environment=AIOS_GITOPS_MODEL_API_SERVICE=aios-gitops-model-api-v2r2c1-staging.service
|
||||
Environment=AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
||||
Environment=AIOS_SILENT_RELEASE_STATE_ROOT=/var/lib/aios-gitops/model-api-staging/silent-release
|
||||
Environment=AIOS_GITOPS_GITEA_API_URL=https://g.cnnetmkt.top/git/api/v1
|
||||
Environment=AIOS_GITOPS_GITEA_REPOSITORY=aios/aios
|
||||
Environment=AIOS_GITOPS_GITEA_REMOTE=gitea-http
|
||||
Environment=AIOS_GITOPS_GITEA_TOKEN_FILE=/run/credentials/aios-gitops-model-api-v2r2c1-staging.service/gitea_token
|
||||
Environment=GIT_ASKPASS=/var/lib/aios-gitops/bridge-v2-staging/bridge-v2-askpass.sh
|
||||
Environment=GIT_ASKPASS_REQUIRE=force
|
||||
Environment=GIT_TERMINAL_PROMPT=0
|
||||
Environment=GITEA_MACHINE_ACTOR=aios-autonomy-agent
|
||||
Environment=CREDENTIAL_FILE=/run/credentials/aios-gitops-model-api-v2r2c1-staging.service/gitea_token
|
||||
Environment=AIOS_GITOPS_GITEA_TOKEN_LINE=1
|
||||
LoadCredential=gitea_token:/run/credentials/aios-governance-executor.service/gitea-governance-executor-token
|
||||
Environment=AIOS_BRIDGE_V2_URL=http://127.0.0.1:18892
|
||||
Environment=AIOS_GITOPS_HOST=127.0.0.1
|
||||
Environment=AIOS_GITOPS_PORT=18893
|
||||
Environment=PYTHONPATH=@RUNTIME_ROOT@/app:@RUNTIME_ROOT@/runtime-agent-os
|
||||
Environment=PYTHONPATH=@RUNTIME_ROOT@:@RUNTIME_ROOT@/app:@RUNTIME_ROOT@/runtime-agent-os
|
||||
ExecStart=/usr/bin/python3 @RUNTIME_ROOT@/app/aios_model_api_staging.py
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
[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
|
||||
@@ -1,13 +0,0 @@
|
||||
[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,57 +0,0 @@
|
||||
{
|
||||
"activation": {
|
||||
"credential_installation": false,
|
||||
"daemon_reload": true,
|
||||
"enable_units": [
|
||||
"aios-trusted-candidate-builder.socket"
|
||||
],
|
||||
"service_start": "socket-activation-only",
|
||||
"signer_invocation": false
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"install_mode": "0644",
|
||||
"source_mode": "0644",
|
||||
"source_path": "active-runtime/app/trusted_builder_signer_provisioning.py"
|
||||
},
|
||||
{
|
||||
"install_mode": "0644",
|
||||
"source_mode": "0644",
|
||||
"source_path": "active-runtime/app/aios_trusted_candidate_builder.py"
|
||||
},
|
||||
{
|
||||
"install_mode": "0644",
|
||||
"source_mode": "0644",
|
||||
"source_path": "config/gitops/trusted-candidate-builder-policy.json"
|
||||
},
|
||||
{
|
||||
"install_mode": "0644",
|
||||
"source_mode": "0644",
|
||||
"source_path": "deploy/systemd/aios-trusted-candidate-builder.service"
|
||||
},
|
||||
{
|
||||
"install_mode": "0644",
|
||||
"source_mode": "0644",
|
||||
"source_path": "deploy/systemd/aios-trusted-candidate-builder.socket"
|
||||
},
|
||||
{
|
||||
"install_mode": "0644",
|
||||
"source_mode": "0644",
|
||||
"source_path": "deploy/systemd/aios-gitops-model-api-trusted-builder.conf.example"
|
||||
},
|
||||
{
|
||||
"install_mode": "0755",
|
||||
"source_mode": "0644",
|
||||
"source_path": "deploy/libexec/aios-trusted-builder-signer-provisioner"
|
||||
},
|
||||
{
|
||||
"install_mode": "0644",
|
||||
"source_mode": "0644",
|
||||
"source_path": "deploy/trusted-builder/aios-trusted-builder-package-manifest.json"
|
||||
}
|
||||
],
|
||||
"package_name": "aios-trusted-candidate-builder",
|
||||
"schema": "aios.trusted-builder-package.v1",
|
||||
"source_root": "/opt/aios/trusted-candidate-builder/current",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
AIOS governed Action loop smoke
|
||||
task=fix-action-contracts-20260825-1407
|
||||
generation=5
|
||||
expected_parent=f104e1244fcbc36d1debf1aa0c6720f734ea04cf
|
||||
purpose=verify read-lease-commit-idempotency-cas
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: AIOS GitOps Controlled Access API
|
||||
version: V1R1C0-chatgpt-actions
|
||||
version: V2R2C1-chatgpt-actions-28ops
|
||||
description: Controlled ChatGPT Actions API for AIOS online GitOps baseline. Read endpoints are public. Write endpoints require Bearer token and create Git commits on non-main branches.
|
||||
servers:
|
||||
- url: https://cnnetmkt.top
|
||||
@@ -241,10 +241,62 @@ components:
|
||||
properties:
|
||||
candidate_id: {type: string}
|
||||
paths:
|
||||
/readRepositoryFile:
|
||||
post:
|
||||
operationId: readRepositoryFile
|
||||
summary: Read one bounded file from an approved repository ref
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [repository, ref, path, max_bytes]
|
||||
properties:
|
||||
repository: {type: string, enum: [aios/aios]}
|
||||
ref: {type: string, description: agent branch, main, V2R2C1, or full commit OID}
|
||||
path: {type: string, minLength: 1, maxLength: 512}
|
||||
max_bytes: {type: integer, minimum: 1, maximum: 262144}
|
||||
responses:
|
||||
'200': {description: Bounded read-only source content}
|
||||
'400': {description: Invalid repository, ref, path, or size}
|
||||
'404': {description: Ref or file not found}
|
||||
x-openai-isConsequential: false
|
||||
/acquireWriterLease:
|
||||
post:
|
||||
operationId: acquireWriterLease
|
||||
summary: Acquire a short-lived generation-scoped writer lease
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [schema, task_id, repository, branch_mode, base_oid, expected_branch_oid, target_branch, generation, lease_seconds]
|
||||
properties:
|
||||
schema: {type: string, enum: [aios.task-writer-lease.v2]}
|
||||
task_id: {type: string, minLength: 1, maxLength: 200}
|
||||
repository: {type: string, enum: [aios/aios]}
|
||||
branch_mode: {type: string, enum: [CONTINUE_EXISTING]}
|
||||
base_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9._/-]+$'}
|
||||
generation: {type: integer, minimum: 1}
|
||||
lease_seconds: {type: integer, minimum: 1, maximum: 3600}
|
||||
responses:
|
||||
'200': {description: Acquired or idempotently replayed scoped lease}
|
||||
'409': {description: Binding, stale, expired, or already-used lease}
|
||||
'423': {description: Governed writes disabled}
|
||||
x-openai-isConsequential: true
|
||||
/v2/commit-files:
|
||||
post:
|
||||
operationId: commitFilesGoverned
|
||||
summary: Submit one atomic governed commit containing multiple files
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -263,16 +315,15 @@ paths:
|
||||
- message
|
||||
properties:
|
||||
task_id: {type: string}
|
||||
job_id: {type: string, description: External durable Job identity for exact completed-replay reconciliation}
|
||||
source_lease_epoch: {type: integer, minimum: 1}
|
||||
repository: {type: string, example: aios/aios}
|
||||
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9._/-]+$'}
|
||||
generation: {type: integer, minimum: 1}
|
||||
idempotency_key: {type: string}
|
||||
idempotency_key: {type: string, minLength: 8, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$'}
|
||||
files:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 50
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
@@ -285,6 +336,7 @@ paths:
|
||||
'200': {description: Governed commit job and verified branch result}
|
||||
'400': {description: Contract or policy rejection}
|
||||
'409': {description: Branch CAS or idempotency conflict}
|
||||
x-openai-isConsequential: true
|
||||
/createReleaseIntent:
|
||||
post:
|
||||
operationId: createReleaseIntent
|
||||
@@ -364,20 +416,6 @@ paths:
|
||||
'403': {description: Signature, key, expiry, or revocation failure}
|
||||
'409': {description: Conflicting release plan}
|
||||
x-openai-isConsequential: true
|
||||
/submitSignedApproval:
|
||||
post:
|
||||
operationId: submitSignedApproval
|
||||
summary: Alias for immutable signed owner approval submission
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/SignedReleasePlanApproval'}
|
||||
responses:
|
||||
'200': {description: Signature verified and approval stored}
|
||||
'403': {description: Production approval backend unavailable or signature invalid}
|
||||
x-openai-isConsequential: true
|
||||
/startDeployment:
|
||||
post:
|
||||
operationId: startDeployment
|
||||
@@ -441,17 +479,6 @@ paths:
|
||||
operationId: listPatches
|
||||
summary: List patch records
|
||||
responses: {'200': {description: OK}}
|
||||
post:
|
||||
operationId: createPatch
|
||||
summary: Create a patch branch and commit metadata
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/PatchRequest'}
|
||||
responses: {'200': {description: Patch branch committed}, '401': {description: Unauthorized}, '403': {description: Forbidden}}
|
||||
x-openai-isConsequential: true
|
||||
/api/model:
|
||||
get:
|
||||
operationId: readModelRegistry
|
||||
@@ -473,61 +500,6 @@ paths:
|
||||
operationId: getRuntimeStatus
|
||||
summary: Read runtime status
|
||||
responses: {'200': {description: OK}}
|
||||
/api/branch:
|
||||
post:
|
||||
operationId: createBranch
|
||||
summary: Create a non-main branch and commit branch metadata
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/BranchRequest'}
|
||||
responses: {'200': {description: Branch created}, '401': {description: Unauthorized}, '403': {description: Forbidden}}
|
||||
x-openai-isConsequential: true
|
||||
/api/file:
|
||||
post:
|
||||
operationId: commitFile
|
||||
summary: Write a file and create a Git commit on a non-main branch
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/FileCommitRequest'}
|
||||
responses: {'200': {description: File committed}, '400': {description: Secret rejected or invalid path}, '401': {description: Unauthorized}}
|
||||
x-openai-isConsequential: true
|
||||
/api/commit-file:
|
||||
post:
|
||||
operationId: commitFileAtomic
|
||||
summary: Atomically write one allowlisted file, commit, push, and verify using Token Policy V2
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/AtomicFileCommitRequest'}
|
||||
responses:
|
||||
'200': {description: Completed or idempotently replayed}
|
||||
'400': {description: Invalid canonical request}
|
||||
'401': {description: Missing, invalid, expired, or revoked Token Policy V2 grant}
|
||||
'403': {description: Audience, source, branch, operation, path, or policy denied}
|
||||
'409': {description: Idempotency conflict or manual review required}
|
||||
'413': {description: Request too large}
|
||||
'503': {description: Push or remote verification unavailable; recoverable}
|
||||
x-openai-isConsequential: true
|
||||
/api/commit:
|
||||
post:
|
||||
operationId: commitChanges
|
||||
summary: Commit existing workspace changes on a non-main branch
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/CommitRequest'}
|
||||
responses: {'200': {description: Commit result}, '401': {description: Unauthorized}}
|
||||
x-openai-isConsequential: true
|
||||
/api/tag-publication-status:
|
||||
get:
|
||||
operationId: getTagPublicationStatus
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# Trusted-builder signer provisioning
|
||||
|
||||
`trusted_builder_signer_provisioning.py` provisions the purpose-bound
|
||||
`AIOS_TRUSTED_RELEASE_BUILDER` signer only from an exact
|
||||
`aios.trusted-builder-signer-provisioning.v2` payload and detached SSH signature
|
||||
for `aios-task-grant-signer` in namespace `aios-owner-approval-v1`.
|
||||
|
||||
A fresh payload is bound to the final common Gitea, authority, and governed
|
||||
`main` OID; the protected merge tree; the ordered two parents; and the selected
|
||||
PR head as the merge's second parent. The provisioner rechecks those facts in
|
||||
the authority repository and rejects ancestry or source drift. It does not
|
||||
reuse PR69, PR70, or a release-candidate recipe binding.
|
||||
|
||||
The payload evidence is an exact allowlist: provisioner, candidate builder,
|
||||
policy, service, socket, model-API config template, launcher, and the versioned
|
||||
package manifest. Missing, extra, modified, or mode-incompatible evidence is
|
||||
rejected. The package manifest is non-secret and declares daemon reload plus
|
||||
socket activation, with no credential installation or signer invocation. It
|
||||
requires the installed launcher target mode to be `0755`; source checkout mode
|
||||
is treated separately from installed package mode. A controlled deterministic
|
||||
worktree may hold ordinary source evidence at `0600`, `0640`, `0644`, or `0664`; the
|
||||
installed package still enforces its declared target modes.
|
||||
|
||||
The provisioner generates the Ed25519 key and builder HMAC only on the host.
|
||||
Plaintext exists only in its private temporary directory and is encrypted with
|
||||
`systemd-creds --with-key=host`; the service receives encrypted credentials via
|
||||
`LoadCredentialEncrypted`. The public keyring and service-principal binding
|
||||
contain no private key.
|
||||
|
||||
Receipts append at
|
||||
`/var/lib/aios-gitops/trusted-builder/provisioning-receipts.jsonl`. An exact
|
||||
replay returns `IDEMPOTENT_REPLAY_PASS`; mismatched or unreceipted state fails
|
||||
closed. Provisioning is configuration only: activation, restart persistence,
|
||||
public-key verification and negative-capability tests are separate gates before
|
||||
`TRUSTED_BUILDER_SIGNER_READINESS=PASS`.
|
||||
@@ -20,9 +20,34 @@ import sys
|
||||
|
||||
source, output = map(Path, sys.argv[1:])
|
||||
text = source.read_text(encoding="utf-8")
|
||||
bridge_marker = " # AIOS external Action Bridge v2 staging route"
|
||||
bridge_block = r''' # AIOS external Action Bridge v2 staging route
|
||||
# The published Action schema uses the site-root /v2 paths and must reach
|
||||
# the non-production Bridge v2 listener, never the production 18920 unit.
|
||||
location ^~ /v2/ {
|
||||
proxy_pass http://127.0.0.1:18892;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header X-AIOS-Token $http_x_aios_token;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 1100k;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
'''
|
||||
if bridge_marker not in text:
|
||||
needle = " # BEGIN AIOS CHATGPT AUTONOMOUS RELEASE TASK ROUTE"
|
||||
if text.count(needle) != 1:
|
||||
raise SystemExit("expected exactly one legacy Action route marker")
|
||||
text = text.replace(needle, bridge_block + "\n" + needle, 1)
|
||||
elif "location ^~ /v2/" not in text or "proxy_pass http://127.0.0.1:18892;" not in text:
|
||||
raise SystemExit("staging Bridge v2 route marker exists but route is incomplete")
|
||||
|
||||
marker = " # AIOS external Action governed-read ownership"
|
||||
if marker in text:
|
||||
raise SystemExit("governed-read locations already present in backup")
|
||||
block = r''' # AIOS external Action governed-read ownership
|
||||
# These reads are owned by the authenticated 18890 facade, not the 18893 model API.
|
||||
location = /api/governed-write-capabilities {
|
||||
@@ -69,7 +94,8 @@ block = r''' # AIOS external Action governed-read ownership
|
||||
needle = " location /api/ {"
|
||||
if text.count(needle) != 1:
|
||||
raise SystemExit("expected exactly one generic /api/ location")
|
||||
text = text.replace(needle, block + "\n" + needle, 1)
|
||||
if marker not in text:
|
||||
text = text.replace(needle, block + "\n" + needle, 1)
|
||||
output.write_text(text, encoding="utf-8")
|
||||
PY
|
||||
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
{
|
||||
"schema": "aios.canonical-task-projection-intent.v1",
|
||||
"projection_id": "projection-v2r2c2-parent-recovery-20260826",
|
||||
"projection_generation": 1,
|
||||
"idempotency_key": "projection-v2r2c2-parent-recovery-20260826-v1",
|
||||
"task": {
|
||||
"task_id": "TASK-CONV-V2R2C2-RECONCILE-SNAPSHOT-20260826",
|
||||
"goal": "Complete V2R2C2 staging-only control-plane convergence and repair the control-plane self-recovery defect exposed by the missing canonical Task projection.",
|
||||
"instructions": "Resume the immutable runbook at runbooks/agent-v2r2c2-control-plane-convergence-20260825.md from branch agent/v2r2c2-convergence-integration-20260825. Preserve the parent task/job/generation/branch/OID. Complete canonical projection, worker claim, execution binding, heartbeat, WriterLease/CAS, required convergence code, tests, immutable staging build/deploy, first acceptance, restart-persistent second acceptance, and governed Evidence Closure. Recovery subgraphs must return to the parent goal. Never mutate main, V2R2C1 protected tag, production baseline, or production services.",
|
||||
"success_criteria": [
|
||||
{
|
||||
"id": "control_plane_self_recovery",
|
||||
"required": true,
|
||||
"expected": "PASS"
|
||||
},
|
||||
{
|
||||
"id": "canonical_task_projection",
|
||||
"required": true,
|
||||
"expected": "PASS"
|
||||
},
|
||||
{
|
||||
"id": "worker_execution_path",
|
||||
"required": true,
|
||||
"expected": "PASS"
|
||||
},
|
||||
{
|
||||
"id": "writer_path",
|
||||
"required": true,
|
||||
"expected": "PASS"
|
||||
},
|
||||
{
|
||||
"id": "governed_mutation",
|
||||
"required": true,
|
||||
"expected": "PASS"
|
||||
},
|
||||
{
|
||||
"id": "v2r2c2_code",
|
||||
"required": true,
|
||||
"expected": "PASS"
|
||||
},
|
||||
{
|
||||
"id": "tests",
|
||||
"required": true,
|
||||
"expected": "PASS"
|
||||
},
|
||||
{
|
||||
"id": "staging_deployment",
|
||||
"required": true,
|
||||
"expected": "PASS"
|
||||
},
|
||||
{
|
||||
"id": "runtime_component_drift",
|
||||
"required": true,
|
||||
"expected": "PASS"
|
||||
},
|
||||
{
|
||||
"id": "release_consistency",
|
||||
"required": true,
|
||||
"expected": "PASS"
|
||||
},
|
||||
{
|
||||
"id": "tag_publication",
|
||||
"required": true,
|
||||
"expected": "PASS"
|
||||
},
|
||||
{
|
||||
"id": "release_acceptance",
|
||||
"required": true,
|
||||
"expected": "PASS"
|
||||
},
|
||||
{
|
||||
"id": "restart_persistence",
|
||||
"required": true,
|
||||
"expected": "PASS"
|
||||
},
|
||||
{
|
||||
"id": "evidence_closure",
|
||||
"required": true,
|
||||
"expected": "PASS"
|
||||
}
|
||||
],
|
||||
"constraints": {
|
||||
"staging_only": true,
|
||||
"production_write": false,
|
||||
"main_direct_write": false,
|
||||
"protected_tag_write": false,
|
||||
"baseline_write": false,
|
||||
"force_push": false,
|
||||
"direct_task_db_write_outside_task_plane": false,
|
||||
"secret_exposure": false,
|
||||
"return_to_parent_after_recovery": true
|
||||
},
|
||||
"required_capabilities": [],
|
||||
"metadata": {
|
||||
"parent_goal": "V2R2C2 staging-only control-plane convergence",
|
||||
"governed_task_id": "TASK-CONV-V2R2C2-RECONCILE-SNAPSHOT-20260826",
|
||||
"bridge_job_id": "job-9eee1142f2fc8bb7b571d065c01f40e0",
|
||||
"recovery_reason": "MISSING_DURABLE_CANONICAL_TASK_PROJECTION_RECOVERY",
|
||||
"runbook_ref": "runbooks/agent-v2r2c2-control-plane-convergence-20260825.md",
|
||||
"runbook_oid": "2afc2cf41fc9090cf01e3882d1b8db8d54cf1844",
|
||||
"runbook_sha256": "a500504a76fb99b26541eaf4edd842a942992ae170fc0f3838530189ca672320"
|
||||
},
|
||||
"priority": 100,
|
||||
"max_attempts": 20,
|
||||
"deadline": null
|
||||
},
|
||||
"governed_binding": {
|
||||
"governed_task_id": "TASK-CONV-V2R2C2-RECONCILE-SNAPSHOT-20260826",
|
||||
"bridge_job_id": "job-9eee1142f2fc8bb7b571d065c01f40e0",
|
||||
"repository": "aios/aios",
|
||||
"branch": "agent/v2r2c2-convergence-integration-20260825",
|
||||
"generation": 2,
|
||||
"base_oid": "2afc2cf41fc9090cf01e3882d1b8db8d54cf1844",
|
||||
"expected_branch_oid": "2afc2cf41fc9090cf01e3882d1b8db8d54cf1844"
|
||||
},
|
||||
"source_artifact": {
|
||||
"ref": "git:2afc2cf41fc9090cf01e3882d1b8db8d54cf1844:runbooks/agent-v2r2c2-control-plane-convergence-20260825.md",
|
||||
"sha256": "a500504a76fb99b26541eaf4edd842a942992ae170fc0f3838530189ca672320"
|
||||
},
|
||||
"task_payload_sha256": "dcc293ffcd9db36ea8dd35b79a15e77cca57ee4ad5d6f976e24ee454d46eb0c9"
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# AIOS Action Connector Schema Final Closure — 2026-08-25
|
||||
|
||||
## Disposition
|
||||
|
||||
`NEEDS_REWORK`.
|
||||
|
||||
The staging runtime and published OpenAPI contract are repaired and the public `/v2` staging route is now reachable. The final P0-C acceptance cannot be claimed because this execution environment has no callable ChatGPT Action/connector inspection or registration tool, and the deployed repository intentionally has no connector acknowledgement/write endpoint. Therefore the required proof of the actual callable tool schema, a real public two-file batch commit, and its public terminal reconcile are not available. The task must not be marked `SUCCEEDED`.
|
||||
|
||||
No main, protected tag, baseline registry, or production write was performed.
|
||||
|
||||
## Scope and live identity
|
||||
|
||||
- Repository: `aios/aios`
|
||||
- Task: `fix-action-contracts-20260825-1407`
|
||||
- Branch: `agent/fix-action-contracts-20260825-1407`
|
||||
- Source worktree: `/var/lib/aios-gitops/bridge-v2-staging/worktrees/fix-action-contracts-20260825-1407`
|
||||
- Source HEAD before this closure patch: `eb72fcd8dce242abaef15f03a21b43ba2c5197fd`
|
||||
- Deployed staging Bridge runtime commit: `2ec49005bde44f6981b14351500847d594e0276c`
|
||||
- Deployed Bridge artifact SHA-256: `f67aed6cc0cce1f08197485c3eea204d3ef4d3989f44dc98e8ea2691e5886cbf`
|
||||
- Bridge unit: `aios-chatgpt-bridge-v2-staging.service`, `aiman:gitops`, `127.0.0.1:18892`
|
||||
- Model API unit: `aios-gitops-model-api-v2r2c1-staging.service`, `127.0.0.1:18893`
|
||||
- Gitea API base projected by staging systemd: `https://g.cnnetmkt.top/git/api/v1`
|
||||
- Runtime state: `STAGING`, `drift_status=PASS`, `legacy_global_writes_enabled=false`, `main_write=false`, `tag_write=false`, `baseline_write=false`, `production_write=false`
|
||||
|
||||
## Root causes and contract chain
|
||||
|
||||
1. Runtime source was already repaired to require `files` for `commitFilesGoverned`. `active-runtime/app/aios_chatgpt_bridge_v2.py` declares the canonical required tuple and `render_openapi_contract_fragment()` emits the operation fragment.
|
||||
2. `scripts/build_v2r2c1_model_api_staging.py` copies the canonical `docs/gitops/openapi-chatgpt-actions.yaml` into the immutable staging artifact and rejects a build missing `/v2/commit-files`.
|
||||
3. The deployed public OpenAPI is served from the model API workspace at `/api/openapi-chatgpt-actions.yaml`; its SHA-256 was `a0775b0719a76e08a95f3257a3d3b2a73b70e1c55c162914250f80dd28da022d`. It contains `/v2/commit-files`, `files`, and item fields `path` and `content_base64`.
|
||||
4. The missing deployment link was Nginx: OpenAPI `servers.url` is `https://cnnetmkt.top` and its operation paths are `/v2/...`, but the live site had no `/v2/` location and the old `/api/v2/` location pointed at absent production port `18920`. The old `/api/file` route is an inactive legacy single-file router.
|
||||
5. A repository-wide and host-side inspection found no formal external ChatGPT connector registration source, schema cache, re-registration command, or acknowledgement endpoint. The existing durable-owner design explicitly states that no connector acknowledgement/write endpoint is provided and the only valid state is `PENDING_EXTERNAL_CONNECTOR`. This is the remaining external blocker, not a reason to create a parallel local schema or reuse another credential.
|
||||
|
||||
## Changes made
|
||||
|
||||
- `ops/gitops-migration/bind_external_action_surface_final_staging.sh`
|
||||
- added an idempotent exact `/v2/` route to staging Bridge `127.0.0.1:18892`;
|
||||
- forwards `Authorization` and `X-AIOS-Token`;
|
||||
- keeps the existing exact governed-read routes on `18890` and generic `/api/` on `18893`;
|
||||
- refuses an incomplete existing staging route and never adds a route to production `18920`.
|
||||
- `tests/test_action_contract_regressions.py`
|
||||
- added a parity/deployment test tying published `/v2/commit-files` to the staging route and asserting no `18920` target in the staging installer.
|
||||
- `runbooks/action-connector-schema-refresh-20260825.md`
|
||||
- added the durable connector refresh and fail-closed acceptance procedure.
|
||||
- `reports/action-connector-schema-final-closure-20260825.md`
|
||||
- this evidence report.
|
||||
|
||||
The source/runtime contract parity test remains in place and still compares runtime required fields, generated fragment, and published schema. The live connector layer cannot be parity-tested because no connector layer is deployed or callable from this environment; the gate therefore remains fail-closed rather than accepting a fixture as proof.
|
||||
|
||||
## Deployment and restart evidence
|
||||
|
||||
The existing staging installer was run after `git diff --check` and focused tests. It created the Nginx backup `/data/aios-gitops/backups/action-surface-final-20260825T105656Z`, passed `nginx -t`, and reloaded Nginx. Public readback then returned HTTP 200 for:
|
||||
|
||||
- `https://cnnetmkt.top/v2/runtime`
|
||||
- `https://cnnetmkt.top/v2/capabilities`
|
||||
- `https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml`
|
||||
|
||||
The staging Bridge and Model API were subsequently restarted once. Both `systemctl is-active` checks returned `active`. Post-restart `/v2/runtime` retained the staging identity and artifact SHA above; `/v2/capabilities` retained `commitFilesGoverned.required_fields` including `files`.
|
||||
|
||||
## Tests and live smoke
|
||||
|
||||
Passing results:
|
||||
|
||||
- `python3 -m pytest -q tests/test_action_contract_regressions.py tests/test_bridge_v2_governed_http_bootstrap.py` — `17 passed`.
|
||||
- Mandatory suite — `264 passed, 28 subtests passed in 4.81s`.
|
||||
- Full repository suite — `720 passed, 2 skipped, 2 failed, 154 subtests passed in 34.02s`. The two failures are unchanged known failures: stale `/tmp/aios-agent-events-test` fixture and deployed signer self-test return code `42`.
|
||||
|
||||
Post-restart live results:
|
||||
|
||||
- `getReleaseConsistency(V2R2C1, ...)`: HTTP 200 structured consistency response; no Gitea URL validation failure. Gitea listing and peeled target readback were visible.
|
||||
- `runReleaseAcceptance(V2R2C1, ...)`: HTTP 200 structured policy result with `accepted=false`; it reached policy evaluation rather than failing URL validation.
|
||||
- `getTagPublicationStatus("V2R2C1")`: HTTP 200 structured state (`REMOTE_PUSH_PENDING` on Bridge v2; the legacy model read surface returned structured `UNKNOWN`).
|
||||
- `getReleaseSaga(nonexistent)`: HTTP 404, normal NOT_FOUND behavior.
|
||||
- Existing terminal job `job-93138017f3afd7f709f691fd05c77923`: HTTP 200, `COMPLETED`, revision `8`; terminal `/v2/job/reconcile` returned HTTP 200 with the same `COMPLETED` receipt and Authority/API/Git remote OID `904e7ea0cd376d204bad19628c36a9dd90ca6621`.
|
||||
- Empty diff: HTTP 200, zero paths/size, `truncated=false`.
|
||||
- Non-empty diff: HTTP 200 with identities, changed paths, stats, total size, patch limit `262144`, and bounded response fields.
|
||||
- Public `POST /v2/commit-files` probes returned `BRIDGE_REQUEST_FIELDS_MISSING` for incomplete bodies; the body containing `files` was not rejected as an unknown field. No write side effect occurred.
|
||||
|
||||
## Missing final acceptance
|
||||
|
||||
The following required evidence is not available and is intentionally not inferred:
|
||||
|
||||
- actual ChatGPT callable Action tool schema showing required `files`;
|
||||
- formal connector re-register/cache invalidation acknowledgement;
|
||||
- an authorized public `commitFilesGoverned` request containing two files;
|
||||
- Authority/Gitea/API remote OID equality for that new batch commit;
|
||||
- its batch Job `COMPLETED` readback and public terminal reconcile;
|
||||
- post-batch connector callable smoke.
|
||||
|
||||
The prior single-file public commit `904e7ea0cd376d204bad19628c36a9dd90ca6621` and its terminal reconcile are preserved as baseline evidence only; they are not substituted for the required batch proof.
|
||||
|
||||
## Protected boundaries and rollback
|
||||
|
||||
No source or deployment operation wrote `main`, a protected tag, the baseline registry, or production. The production-facing `18920` route was not changed; the new `/v2/` location targets only the staging Bridge listener. No secret value was printed, rotated, or reused. Automatic rollback was not triggered because Nginx syntax, staging service health, runtime identity, Gitea readback, and protected-ref boundaries remained valid. If a future connector refresh breaks these checks, restore the Nginx backup and the preceding staging artifact through the existing governed installer, then re-run the same probes.
|
||||
|
||||
## Next required owner/platform action
|
||||
|
||||
Provide the supported staging external Action connector lifecycle (registration/re-registration/cache invalidation) and an authorized staging Action bearer, or have the legitimate external Agent claim the existing continuation task and produce its durable callable-schema and batch-commit evidence. Until then the correct disposition remains `NEEDS_REWORK`; do not mark `SUCCEEDED` based on the server-side OpenAPI alone.
|
||||
|
||||
@@ -78,3 +78,42 @@ The full-suite failures remain the known event-bus fixture failure and the real
|
||||
The final staging services were restarted after deployment and health/runtime identity were re-read. The post-restart `systemctl is-active` result was `active` for both services; model health again reported OID `2ec49005bde44f6981b14351500847d594e0276c`, artifact SHA `f67aed6cc0cce1f08197485c3eea204d3ef4d3989f44dc98e8ea2691e5886cbf`, staging mode, and `production_deployed=false`. Post-restart consistency remained `PASS`, acceptance remained structured `accepted=false` for the pre-existing protected-tag publication state, saga remained `NOT_FOUND` for the nonexistent candidate, and the large diff remained bounded/truncated. The task cannot be promoted to `SUCCEEDED` until the protected-tag publication state and the two known repository-wide failures are resolved through their respective governed owners. No automatic rollback was performed because the staging services stayed healthy, mandatory tests passed, Gitea readback passed, and protected-ref boundaries remained intact.
|
||||
|
||||
Task disposition is `NEEDS_REWORK`, not `SUCCEEDED`: the staging contract repair is materially complete, but the deployed acceptance result is not all-green and the task row has not been legitimately claimed/verified by the online task worker.
|
||||
|
||||
## External Action Surface final closure evidence — 2026-08-25
|
||||
|
||||
### Disposition
|
||||
NEEDS_REWORK. Staging is deployed and P0-A/P0-B have live closure evidence. P0-C is blocked: no formal staging external Action bearer issuance path was available, so the required real public two-file commit and terminal reconcile were not authorized or executed. Do not mark SUCCEEDED. No main, tag, baseline, or production write occurred; untracked runtime/agent-observability and runtime/task-center were preserved.
|
||||
|
||||
### Identity and before-change facts
|
||||
Authority /data/aios-gitops/repo.git; worktree /var/lib/aios-gitops/bridge-v2-staging/worktrees/fix-action-contracts-20260825-1407; branch agent/fix-action-contracts-20260825-1407; initial HEAD 35eec9afe1432e8466d60040b2fb1049bb41242a1. Stable V2R2C1 source 998d30304787e31e5ae607c0bd86b5edb2b0e5ad; evidence closure a8b906d1bc24088c19647e98de6a679268f6d0a2. Repair commit 174e64dd0218cef6d49dd13d58bbd90c78fecadd; protected refs unchanged.
|
||||
|
||||
### P0-A governed-read ownership
|
||||
Root cause: generic /api/ sent governed-write-capabilities to model API 18893 (404), while governed facade 18890 owned it. Fix: ops/gitops-migration/bind_external_action_surface_final_staging.sh installs exact locations for governed-write-capabilities, control-plane-snapshot, online-agent-task, and online-agent-task-events to 18890; generic /api/ remains 18893; auth headers forward and no business host is hard-coded. Backup /data/aios-gitops/backups/action-surface-final-20260825T101714Z; log /data/aios-gitops/logs/action-surface-final-20260825T101714Z.log. nginx -t passed, Nginx active, public GET /api/governed-write-capabilities HTTP 200. Contract reports governed writes enabled but legacy/main/tag/baseline/production writes false.
|
||||
|
||||
### P0-B release semantics
|
||||
Root cause: public model GET used local TagPublisher/PublicationStore lacking tag object binding, so consistency was FAIL/tag_object=false; Bridge v2 had authoritative durable binding. Fix: active-runtime/app/aios_model_api_staging.py constructs one canonical publication request and routes release-consistency/release-acceptance through Bridge v2 getReleaseConsistency/runReleaseAcceptance; tests/test_action_contract_regressions.py covers this. Live V2R2C1 consistency with target 998d30304787e31e5ae607c0bd86b5edb2b0e5ad, closure a8b906d1bc24088c19647e98de6a679268f6d0a2, plan sha256:c0df52008ae7f64cec5ee2d28947aa7b892066c670ab8295e4549140b1a7500e returned HTTP 200 PASS, all checks true, tag object 07fc2746b688f5f243f29b39de39f32edc3dd9d2 and peeled target correct. Acceptance entered structured policy evaluation with accepted=false, not URL failure. Tag publication V2R2C1 returned structured UNKNOWN, not invalid version.
|
||||
|
||||
### P0-C credential and commit blocker
|
||||
Hash-only Action record: account chatgpt-gitops-bot; id chatgpt-action-0a3e55b7659a4d4e8125feabce5e8bf5; kind gpt_action_api_key; consumer aios-gitops-model-api.service; issuer owner_ssh_breakglass_rotation; issued 2026-08-10T04:51:58.900488Z; expires 2026-09-09T04:51:58.900488Z; scopes file:write and agent-task:control; disabled false; owner approval required true. Plaintext was not printed/reused. agent_task_control_token is not an Action bearer. Inspected Writer grants, Gitea consumer, SSH, task broker and old token candidates provide no formal issuer; no unrelated credential substituted. Empty public POST /api/commitFilesGoverned reached staging and returned HTTP 423 gitops_writes_disabled with AIOS_GITOPS_WRITES_ENABLED=false. Writes were not enabled. No real public two-file commit or COMPLETED reconcile is claimed. Blocker: FORMAL_STAGING_ACTION_BEARER_ISSUANCE_NOT_AVAILABLE.
|
||||
|
||||
### Schema/deployment/runtime
|
||||
Source 174e64dd0218cef6d49dd13d58bbd90c78fecadd. Artifact /data/aios-gitops/artifacts/action-contract-final-174e64d/aios-v2r2c1-model-api-174e64dd0218cef6d49dd13d58bbd90c78fecadd.tar.gz; SHA256 8fc811cfb9dbb22d6eb7ca418d58f9ada1d6fdd12e566e3e8f5a02bec47c4f37. Runtime /opt/aios/gitops-model-api/174e64dd0218cef6d49dd13d58bbd90c78fecadd-action-surface-final/runtime. Drop-in /etc/systemd/system/aios-gitops-model-api-v2r2c1-staging.service.d/zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz-action-contract-zzzz-final-999.conf; backup /data/aios-gitops/backups/action-contract-final-deploy-20260825T101959Z/drop-in-directory. User/Group aiman:gitops; API base from systemd https://g.cnnetmkt.top/git/api/v1; writes false; Bridge 18892; STAGING; production_deployed=false. /api/run proved active runtime/readable authority /data/aios-gitops/repo.git and main 3244f0f17f8845592ac94d585f5ebd65693c9f29. /api/openapi.yaml required commitFilesGoverned files, each with path/content_base64.
|
||||
|
||||
### Tests
|
||||
Before mandatory: 264 passed, 28 subtests, 4.82s. After mandatory: 264 passed, 28 subtests, 4.80s. Focused action regression: 11 passed, 0.12s. Full: 719 passed, 2 skipped, 154 subtests, 2 failed, 34.03s. Same known failures: stale /tmp/aios-agent-events-test in test_unified_event_flow; signer adapter self-test return 42 in test_real_signer_help_and_self_test_are_available.
|
||||
|
||||
### Restart and smoke
|
||||
After first deployment, model and Bridge staging services were restarted and remained active. Post-restart health retained source/artifact identity, V2R2C1, STAGING, production false. Capability HTTP 200; consistency HTTP 200/PASS; tag status structured UNKNOWN; nonexistent saga HTTP 404/NOT_FOUND with execution false, shadow true, side effects false. Empty diff HTTP 200, zero paths/size, truncated false. Bounded non-empty diff returned identities, paths/stats, bounded output and patch limit 262144; local pipe-to-head caused only expected curl broken-pipe. Existing large-diff behavior remains bounded/truncated with no transport exception. Gitea readback proven by consistency PASS; protected-ref boundary remained fail-closed.
|
||||
|
||||
### Task continuity and remaining work
|
||||
Earlier execution observed TASK_NOT_FOUND and created continuation fix-action-contracts-20260825-1407-external-surface-closure with parent/source/OID and staging constraints. Later recheck returned parent HTTP 200 QUEUED; continuation also QUEUED, revision 1, claimed_by null, result null. Neither has claim, execution, terminal result, or success evidence. Required rework is formal bearer lifecycle, isolated real two-file public commit and OID/reconcile verification, disposition of the two pre-existing full-suite failures, then full smoke before SUCCEEDED.
|
||||
### Health endpoint clarification
|
||||
|
||||
The service health contract is /api/health: local model port 18893 and public https://cnnetmkt.top/api/health both returned HTTP 200 with the deployed source/artifact identity, STAGING, and production_deployed=false. Public root /health returned HTTP 502 because the existing Nginx root location proxies the separate portal upstream on 127.0.0.1:3000; it is not the Action model health route. This unrelated portal-ingress 502 is recorded as an observed remaining staging risk, and was not changed because this task scope is the Action/Bridge/release surface.
|
||||
|
||||
\n
|
||||
### Formal task-state boundary
|
||||
|
||||
The deployed Task API exposes verification decisions SUCCEEDED, NEEDS_REWORK, and FAILED only through POST /v1/tasks/{id}/verify while the task is VERIFYING. Both the parent and continuation were QUEUED with no claim, so a direct NEEDS_REWORK transition was not a legal API operation. No cancel, database edit, fake claim, or lifecycle bypass was performed. This report is the authoritative disposition for this run: NEEDS_REWORK.
|
||||
|
||||
\n
|
||||
@@ -0,0 +1,92 @@
|
||||
# Action 最小自治修复闭环证据(2026-08-25)
|
||||
|
||||
## 最终状态
|
||||
|
||||
`BLOCKED`(staging 代码/配置已部署并健康,但缺少本次可用的正式 Action bearer,未宣称真实 governed commit 或完整外部 smoke 成功)。
|
||||
|
||||
本轮没有写入 production、`main`、release tag 或 baseline。
|
||||
|
||||
## 修改前事实
|
||||
|
||||
- repository: `aios/aios`
|
||||
- branch: `agent/fix-action-contracts-20260825-1407`
|
||||
- 修改前 HEAD: `d7a0d0db09826d513cb19451a4f018c998c00191`
|
||||
- production baseline: `V2R2C1`
|
||||
- baseline source commit: `998d30304787e31e5ae607c0bd86b5edb2b0e5ad`
|
||||
- Action schema 已限制为 26 operations,但没有外部 read-source/lease 入口;Bridge lease 主要是进程内 runtime binding;Bridge 未做 operation-specific bearer 校验。
|
||||
- 真实 staging model-api source/artifact 在本轮前为 `7e5525cfcbbaa3898c2e5ed3dc9c13e321908e61` / `2163c2e0467f3bd0a97f8d2b95e3277391c47dcf3ebafb8fa372a01c3f530ac0`。
|
||||
|
||||
## 根因与修复
|
||||
|
||||
1. Autonomous Agent 没有安全 read seam:增加 `AuthorityGitExecutor.read_file` 和 `readRepositoryFile`,只读 authoritative bare Git object database,固定响应上限并返回 resolved commit/OID、size、SHA-256、base64。
|
||||
2. Lease 没有跨重启 durable binding:增加 `ScopedLeaseStore` SQLite CAS 状态,绑定 task/repository/branch/expected OID/generation/TTL,支持同 binding 幂等、冲突、过期和 USED replay;commit 先校验 lease,再走既有 detached worktree/Job Controller/Authority CAS chain。
|
||||
3. Lease 曾可由调用者声明治理身份:现在必须命中 durable task binding,公开路径只接受 `CONTINUE_EXISTING`,并要求 expected OID 等于当前 authority branch head。
|
||||
4. Auth scope 曾混合:Bridge 按 read/lease/commit operation 分别检查 scope;model-api facade 认证后透传 bearer。
|
||||
5. runtime contract 与 published schema:增加 read/lease operation,保留 `commitFilesGoverned.files[] = {path, content_base64}`,builder 将 `scoped_lease_store.py` 纳入 immutable artifact。
|
||||
|
||||
## 修改文件与配置
|
||||
|
||||
- `active-runtime/app/aios_authority_git_executor.py`:安全 ref/path/size/read helper。
|
||||
- `active-runtime/app/scoped_lease_store.py`:SQLite durable lease state。
|
||||
- `active-runtime/app/aios_chatgpt_bridge_v2.py`:read operation contract。
|
||||
- `active-runtime/app/aios_chatgpt_bridge_v2_server.py`:read route、task binding、lease store、Bearer/scope matrix、structured errors。
|
||||
- `active-runtime/app/aios_model_api_staging.py`:认证 facade 与 Bridge control proxy。
|
||||
- `docs/gitops/openapi-chatgpt-actions.yaml`:新增 `readRepositoryFile`、`acquireWriterLease`,公开 operation 数 28。
|
||||
- `scripts/build_v2r2c1_model_api_staging.py`:纳入 `scoped_lease_store.py`。
|
||||
- `tests/test_minimum_autonomous_loop.py`:read/path/size、contract、lease 幂等/冲突测试。
|
||||
- `runbooks/agent-minimum-autonomous-loop-20260825.md`:部署与验收手册。
|
||||
- `deploy/*minimal-autonomous-loop.conf`:待随最终 source OID 渲染的 staging unit 配置模板;实际 drop-in 使用 systemd `LoadCredential` 投影 `/data/aios-gitops/runtime/secrets/gitops.tokens`,不复制 token。
|
||||
|
||||
## Git 与构建结果
|
||||
|
||||
- 第一实现提交:`f3a4485be7c803c3769c518578cf4bf86db32c6e`。
|
||||
- 绑定与 scope 收紧提交:`04b69beb369365a1522268e16c3ac7cd1dd745ce`。
|
||||
- 最终实现/部署 source OID:`9f32b26eba5bdaaa0e7c89042ce336426d465dbd`。
|
||||
- build artifact:`/var/lib/aios-gitops/bridge-v2-staging/candidate-artifacts/min-autonomous-9f32b26/aios-v2r2c1-model-api-9f32b26eba5bdaaa0e7c89042ce336426d465dbd.tar.gz`
|
||||
- artifact SHA-256:`ff2031b9ef8a39811c15086c2577d33cc6f187fd060136b7be7a8d6b4152e9f1`
|
||||
- artifact source OID:`9f32b26eba5bdaaa0e7c89042ce336426d465dbd`
|
||||
|
||||
## 测试与真实 staging 结果
|
||||
|
||||
已通过:
|
||||
|
||||
```text
|
||||
pytest -q tests/test_minimum_autonomous_loop.py tests/test_action_contract_regressions.py tests/test_bridge_v2_governed_http_bootstrap.py
|
||||
21 passed
|
||||
python3 -m py_compile ... # changed runtime modules
|
||||
git diff --check
|
||||
```
|
||||
|
||||
完整 mandatory suite(使用仓库需要的 `PYTHONPATH=.:services/aios-release-controller`)结果:
|
||||
|
||||
`724 passed, 2 skipped, 2 failed, 154 subtests passed`。
|
||||
|
||||
两个失败与本轮改动无关,和修改前已知基线一致:`tests/test_agent_event_bus.py::test_unified_event_flow` 受残留 `/tmp/aios-agent-events-test` 数据污染;`tests/test_signer_contract_compatibility.py::SignerContractCompatibilityTests::test_real_signer_help_and_self_test_are_available` 的已部署 signer self-test 返回码为 `42`。
|
||||
|
||||
已验证:
|
||||
|
||||
- `aios-chatgpt-bridge-v2-staging.service`: restart 后 active,ExecStart 指向 source `9f32b26...` runtime,PID `2061371`。
|
||||
- `aios-gitops-model-api-v2r2c1-staging.service`: restart 后 active,ExecStart 指向 source `9f32b26...` runtime,PID `2061686`。
|
||||
- `/api/health` 与 `/v2/runtime`: restart 后 HTTP 200,`STAGING`、`production_deployed=false`、runtime OID `9f32b26...`、artifact SHA `ff2031...`,`drift_status=PASS`。
|
||||
- 实际 staging `/api/openapi-chatgpt-actions.yaml`: HTTP 200,版本 `V2R2C1-chatgpt-actions-28ops`,28 个 `operationId`,含 `readRepositoryFile`、`acquireWriterLease`、`commitFilesGoverned`;`commitFilesGoverned.files` required 且每项 required `path`、`content_base64`。
|
||||
- 无 Bearer 的 model-api read 请求和 loopback Bridge read 请求均返回 HTTP 401;writes runtime flag 保持 false。
|
||||
- systemd effective User/Group 为 `aiman:gitops`;Gitea API 来源为 staging service effective `AIOS_GITOPS_GITEA_API_URL=https://g.cnnetmkt.top/git/api/v1` 与 Bridge `AIOS_GITEA_API_BASE=https://g.cnnetmkt.top/git/api/v1`,不是业务代码硬编码。
|
||||
- effective silent-release root 为 `/var/lib/aios-gitops/model-api-staging/silent-release`,ownership/mode 为 `aiman:gitops 750`,父目录同为 `aiman:gitops 750`;旧报错路径 `/var/lib/aios-gitops/silent-release` 不存在,未被错误地当作当前 runtime 路径。
|
||||
- systemd 使用 `LoadCredential=action_tokens:/data/aios-gitops/runtime/secrets/gitops.tokens` 和 `%d/action_tokens` 投影;registry 文件 `root:root 600`,未打印或复制 bearer。
|
||||
- 当前 token registry 是 hash-only 记录;本次没有打印、重用 Gitea/SSH/service token,也没有启用 dev auth bypass。
|
||||
|
||||
重启后的 service restart acceptance 已完成:URL、权限、Action schema、version parser 代码产物和 writes=false 均保持有效;production service 未重启。
|
||||
|
||||
未通过/未执行:
|
||||
|
||||
- 无法执行正式 bearer 下的 `read -> lease -> commitFilesGoverned`,因此没有伪造两文件 commit OID、lease USED、Gitea Git/API readback 或 idempotency replay 证据。
|
||||
- 未执行临时 writes=true governed commit;staging 最终保持 `AIOS_GITOPS_WRITES_ENABLED=false`。
|
||||
- 未宣称完整 Action smoke、restart 后 governed commit 或 `TASK_VERIFIED`。
|
||||
|
||||
## 保护边界
|
||||
|
||||
本轮只安装 staging artifact/drop-in;production service 未重启。`main`、`V2R2C1` tag、baseline target/evidence closure 未写入。两个既有 untracked 目录 `runtime/agent-observability/`、`runtime/task-center/` 原样保留。
|
||||
|
||||
## 剩余风险与下一步
|
||||
|
||||
主要闭环阻塞是:需要 Owner/正式 Action broker 提供当前有效 bearer(不在聊天中传递)。此外 mandatory suite 仍有上述两个已知环境/部署失败。拿到 bearer 后应从当前 branch HEAD 重新读取 source OID,按 runbook 执行一次真实两文件 governed commit、Gitea 三方 OID readback、idempotency/stale-OID 负例、staging restart 后复测;在此之前 Task 必须保持 `BLOCKED/NEEDS_REWORK`,不能标记 `SUCCEEDED`。
|
||||
@@ -0,0 +1,224 @@
|
||||
# AIOS Action OpenAPI 操作数上限修复证据
|
||||
|
||||
## 结论
|
||||
|
||||
staging 上的 `https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml` 已从 32 个
|
||||
实际 OpenAPI operations 收缩到 26 个,低于 ChatGPT Action 的 30 操作限制。
|
||||
公网重新抓取返回 HTTP 200,schema version 为
|
||||
`V2R2C1-chatgpt-actions-26ops`。`commitFilesGoverned` 仍是公开的批量 governed
|
||||
writer,并且现在在 OpenAPI 中显式要求 `bearerAuth`、标记为
|
||||
`x-openai-isConsequential: true`,required `files` 契约保持一致。
|
||||
|
||||
本次只修改 branch/staging overlay;没有写入 production service、main、release
|
||||
tag 或 baseline registry。ChatGPT Action 编辑器仍需要重新导入或刷新这个 URL,
|
||||
因为客户端可能缓存旧的 32-operation schema。
|
||||
|
||||
## 修改前事实
|
||||
|
||||
- Repository:`aios/aios`
|
||||
- Branch:`agent/fix-action-contracts-20260825-1407`
|
||||
- 修复前 branch tip:`767c7ff6a975fe18a701b95cd6dacca8600eddc0`
|
||||
- 本轮最终 HEAD:`7e5525cfcbbaa3898c2e5ed3dc9c13e321908e61`
|
||||
- staging model-api unit:`aios-gitops-model-api-v2r2c1-staging.service`
|
||||
- staging model-api port:`127.0.0.1:18893`
|
||||
- staging Bridge port:`127.0.0.1:18892`
|
||||
- systemd Gitea API URL:`https://g.cnnetmkt.top/git/api/v1`
|
||||
- 当前 service User/Group:`aiman:gitops`
|
||||
- 修复前公网 schema:HTTP 200,24,721 bytes,SHA256
|
||||
`a0775b0719a76e08a95f3257a3d3b2a73b70e1c55c162914250f80dd28da022d`;30 paths、
|
||||
32 operations。两个 path 同时有多个 method,因此 path 数不是 operation 数。
|
||||
- 修复前触发错误:`OpenAPI 规范最多可以包含 30 个操作`。
|
||||
|
||||
## 根因与决策
|
||||
|
||||
根因不是 URL host 或 YAML 传输错误,而是公开 Action projection 中混入了旧式
|
||||
Git 写原语和同义 approval alias。公开 schema 同时暴露 legacy primitives 与
|
||||
`commitFilesGoverned`,最终 operation 数为 32。
|
||||
|
||||
本次投影收缩为 26 个 operation:
|
||||
|
||||
- 先前修复中移除粗粒度旧别名 `POST /api/file` (`commitFile`);
|
||||
- 先前修复中移除当前 isolated-writer policy 明确禁止的 `POST /api/commit`
|
||||
(`commitChanges`);
|
||||
- 本轮移除 `submitSignedApproval`,因为它是 `submitOwnerApproval` 的 alias;
|
||||
- 本轮从 Actions projection 隐藏 `createPatch`、`createBranch`、
|
||||
`commitFileAtomic` 三个旧式 Git 写原语。runtime 兼容处理没有删除,canonical
|
||||
对外批量写入为 `/v2/commit-files`;
|
||||
- 保留 release candidate、release consistency、release acceptance、tag
|
||||
publication status、release saga、getDiff 和 recovery 相关既有能力,满足本任务
|
||||
的既有 smoke contract。尚未实现的 `getReleaseSnapshot`/`getReleaseVerification`
|
||||
聚合 API 不在本次冒险重构范围内。
|
||||
|
||||
此外,`commitFilesGoverned` 的 runtime fragment 生成器现在生成:
|
||||
|
||||
- `security: [{bearerAuth: []}]`;
|
||||
- `x-openai-isConsequential: true`;
|
||||
- `files.maxItems: 50`;
|
||||
- `idempotency_key` 的长度和字符约束。
|
||||
|
||||
## 修改文件与提交
|
||||
|
||||
- `docs/gitops/openapi-chatgpt-actions.yaml`:canonical Action projection,26-op
|
||||
allowlist、移除 legacy/alias、补齐 batch writer security。
|
||||
- `active-runtime/app/aios_chatgpt_bridge_v2.py`:runtime contract fragment 的
|
||||
单一生成来源与公开 security/batch constraints。
|
||||
- `tests/test_action_contract_regressions.py`:26-operation gate、legacy operation
|
||||
negative assertions、runtime/schema parity、bearer/consequential assertions。
|
||||
- `runbooks/action-openapi-operation-limit-20260825.md`:操作上限、projection
|
||||
allowlist、构建和刷新手册。
|
||||
- `ops/gitops-migration/bind_external_action_surface_final_staging.sh` 及先前的
|
||||
staging binding 测试/报告:本任务分支已将 `/v2/` 公开路由绑定到 staging Bridge
|
||||
`127.0.0.1:18892`,没有使用 production `18920`。
|
||||
|
||||
提交:
|
||||
|
||||
- `4313f3e44a1e4be69d2608fb1fc18833aa0c82e8`
|
||||
`fix(action): keep public schema within operation limit`
|
||||
- `7e5525cfcbbaa3898c2e5ed3dc9c13e321908e61`
|
||||
`fix(action): project governed core within 26 operations`
|
||||
|
||||
保留的未跟踪目录 `runtime/agent-observability/`、`runtime/task-center/` 未被加入
|
||||
提交或删除。
|
||||
|
||||
## 测试结果
|
||||
|
||||
修改后 focused regression:
|
||||
|
||||
```text
|
||||
13 passed in 0.15s
|
||||
```
|
||||
|
||||
包含既有 Action/release/staging focused tests:
|
||||
|
||||
```text
|
||||
32 passed, 11 subtests passed in 0.36s
|
||||
```
|
||||
|
||||
mandatory suite:
|
||||
|
||||
```text
|
||||
264 passed, 28 subtests passed in 4.79s
|
||||
```
|
||||
|
||||
完整 suite:
|
||||
|
||||
```text
|
||||
721 passed, 2 skipped, 2 failed, 154 subtests passed in 34.02s
|
||||
```
|
||||
|
||||
两个失败与本次变更无关且在修改前已复现:
|
||||
|
||||
1. `tests/test_agent_event_bus.py::test_unified_event_flow` 使用了残留的
|
||||
`/tmp/aios-agent-events-test`,已有 1 个旧 event,断言得到 2;
|
||||
2. `tests/test_signer_contract_compatibility.py::test_real_signer_help_and_self_test_are_available`
|
||||
的已部署 signer `self-test` 返回 42。`sign --help` 仍正常,live signer integration
|
||||
另行 skip。
|
||||
|
||||
没有出现由本次 schema/runtime 改动引入的 mandatory regression。
|
||||
|
||||
## staging 构建与部署
|
||||
|
||||
使用仓库现有流程:
|
||||
|
||||
```text
|
||||
scripts/build_v2r2c1_model_api_staging.py
|
||||
source_oid: 7e5525cfcbbaa3898c2e5ed3dc9c13e321908e61
|
||||
artifact: /data/aios-gitops/artifacts/action-openapi-limit-20260825-7e5525cf/aios-v2r2c1-model-api-7e5525cfcbbaa3898c2e5ed3dc9c13e321908e61.tar.gz
|
||||
artifact_sha256: 2163c2e0467f3bd0a97f8d2b95e3277391c47dcf3ebafb8fa372a01c3f530ac0
|
||||
deployment_state: STAGING
|
||||
```
|
||||
|
||||
systemd 新 drop-in 持久化了 artifact/runtime identity、Gitea URL、`STAGING`、
|
||||
`AIOS_GITOPS_WRITES_ENABLED=false`、`aiman:gitops` 和精确的 read/write paths。
|
||||
当前生效 runtime:
|
||||
|
||||
```text
|
||||
/opt/aios/gitops-model-api/7e5525cfcbbaa3898c2e5ed3dc9c13e321908e61-action-openapi-limit/runtime
|
||||
```
|
||||
|
||||
silent-release 权限在两次重启后均为:
|
||||
|
||||
```text
|
||||
/var/lib/aios-gitops/model-api-staging/silent-release aiman:gitops 750
|
||||
ACL: user::rwx, group::r-x, other::---
|
||||
```
|
||||
|
||||
部署前的原生 final drop-in 已备份至:
|
||||
|
||||
```text
|
||||
/data/aios-gitops/backups/action-openapi-limit-deploy-20260825T112700Z/
|
||||
```
|
||||
|
||||
第一次 restart 后 MainPID 为 `1775067`;第二次按要求 restart 后 MainPID 为
|
||||
`1788253`,两次均 active/running。
|
||||
|
||||
## 公网 Action smoke
|
||||
|
||||
最终公网抓取:
|
||||
|
||||
```text
|
||||
HTTP 200
|
||||
Content-Type: application/yaml; charset=utf-8
|
||||
bytes: 21553
|
||||
SHA256: 798cac0ebd40097c63e5e01ab52c9fdbc255707c4f3820b77161c83349cd5199
|
||||
info.version: V2R2C1-chatgpt-actions-26ops
|
||||
paths: 25
|
||||
operations: 26
|
||||
```
|
||||
|
||||
关键 operation contract:
|
||||
|
||||
```text
|
||||
commitFilesGoverned required:
|
||||
task_id, repository, expected_branch_oid, target_branch, generation,
|
||||
idempotency_key, files, message
|
||||
files item required: path, content_base64
|
||||
security: bearerAuth
|
||||
x-openai-isConsequential: true
|
||||
```
|
||||
|
||||
restart 后复测:
|
||||
|
||||
- `/api/health`:200,`status=ok`,`deployment_state=STAGING`,runtime OID 为
|
||||
`7e5525cf…`,artifact SHA 与 receipt 一致,`production_deployed=false`;
|
||||
- `/v2/runtime`:200,active,Bridge schema `aios.chatgpt-gitops-bridge.v2`;
|
||||
- `/v2/capabilities`:200,governed writes enabled、legacy/main/baseline writes
|
||||
false;
|
||||
- `/api/release-consistency?version=V2R2C1...`:200,`status=PASS`,包含
|
||||
Gitea listing/tag/OID readback;
|
||||
- `/api/release-acceptance?version=V2R2C1...`:200,进入 policy evaluation,
|
||||
`accepted=false` 是既有 acceptance policy 结果,不是 URL 校验错误;
|
||||
- `/api/tag-publication-status?version=V2R2C1`:200,结构化 `UNKNOWN` 状态;
|
||||
- `/getReleaseSaga?candidate_id=nonexistent-action-limit-20260825`:404,结构化
|
||||
`NOT_FOUND`/empty-state,不是权限错误;
|
||||
- empty diff:200,`total_size_bytes=0`、`truncated=false`;
|
||||
- non-empty diff:200,31 changed paths、208,639 bytes;
|
||||
- large diff:200,732 changed paths、3,577,251 bytes、`truncated=true`、patch
|
||||
bounded at 262,144 bytes,无 transport exception。
|
||||
|
||||
保护边界复测:
|
||||
|
||||
```text
|
||||
refs/heads/main = 3244f0f17f8845592ac94d585f5ebd65693c9f29
|
||||
refs/tags/V2R2C1 = 07fc2746b688f5f243f29b39de39f32edc3dd9d2
|
||||
service writes_enabled = false
|
||||
service deployment_state = STAGING
|
||||
```
|
||||
|
||||
main/tag/baseline registry/production 未被本次部署写入。
|
||||
|
||||
## 剩余风险与后续动作
|
||||
|
||||
- ChatGPT 客户端是否已刷新不能由 staging 服务端证明;请在 Action 编辑器重新导入
|
||||
URL。若仍显示旧数量,删除旧 schema 后重新导入,确认 `V2R2C1-chatgpt-actions-26ops`。
|
||||
- 完整 suite 的两个环境失败仍待独立清理 `/tmp` fixture 和修复 signer self-test;
|
||||
它们不是本次 Action schema 修复的回归。
|
||||
- 未来若实现正式 `getReleaseSnapshot`、`getReleaseVerification` 和 governed task
|
||||
Action projection,应单独设计/测试,不应通过隐藏本任务仍要求的 release read paths
|
||||
来制造假收缩。
|
||||
|
||||
## 状态
|
||||
|
||||
`STAGING_REPAIR_PASS`:公网 staging schema、runtime、release/Gitea readback、
|
||||
restart 后 smoke 和保护边界均通过;production 未触碰。外部 ChatGPT Action UI
|
||||
刷新仍是客户端侧动作,不作为服务端部署失败。
|
||||
@@ -0,0 +1,21 @@
|
||||
# Runbook Addendum — External Action Connector Schema Refresh
|
||||
|
||||
This addendum is staging-only and applies to the `fix-action-contracts-20260825-1407` closure.
|
||||
|
||||
## Contract source
|
||||
|
||||
Use `active-runtime/app/aios_chatgpt_bridge_v2.py` as the runtime source of required fields. Use `render_openapi_contract_fragment()` and `docs/gitops/openapi-chatgpt-actions.yaml` for the generated/published projection. `commitFilesGoverned` must require `files`, and each item must require `path` and `content_base64`.
|
||||
|
||||
The public OpenAPI server is the site root and its Bridge v2 paths are `/v2/...`. Install the route with `ops/gitops-migration/bind_external_action_surface_final_staging.sh`; it must target `127.0.0.1:18892`, preserve the Authorization headers, pass `nginx -t`, reload Nginx, and never target production `18920`.
|
||||
|
||||
## Connector lifecycle gate
|
||||
|
||||
The external Action connector must be refreshed through its supported registration or re-registration lifecycle. Record the connector identity, schema source URL, fetched schema digest, cache invalidation or registration acknowledgement, and a callable-tool inspection showing required `files`. Do not make a local fixture or a second hand-written schema a substitute for this evidence.
|
||||
|
||||
If the connector registration API, cache lifecycle, callable inspection, or formal staging Action bearer is unavailable, stop at `PENDING_EXTERNAL_CONNECTOR` and record `FORMAL_STAGING_ACTION_BEARER_ISSUANCE_NOT_AVAILABLE`. Never reuse Gitea, SSH, task-control, Writer, or old token credentials.
|
||||
|
||||
## Acceptance
|
||||
|
||||
After connector refresh, use the actual callable Action once to submit two files through `commitFilesGoverned` on an isolated `agent/*` branch. Verify the task/lease lineage, Authority ref, Git remote ref, and Gitea API branch OID are equal. Read the durable job, require `COMPLETED`, then call terminal reconcile and require an idempotent successful read-back. Restart only staging Action/Bridge/model services and repeat callable schema inspection and the batch/reconcile smoke.
|
||||
|
||||
Do not mark `SUCCEEDED` until the callable schema and batch evidence exist. If refresh breaks health, routes, auth, schema, or protected-ref boundaries, restore the recorded Nginx backup and previous staging artifact through the governed deployment procedure, then diagnose.
|
||||
@@ -0,0 +1,58 @@
|
||||
# ChatGPT Action OpenAPI 操作数上限运行手册
|
||||
|
||||
## 目的
|
||||
|
||||
`https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml` 必须保持不超过 ChatGPT
|
||||
Action 导入支持的 30 个实际操作;当前投影硬门槛收紧为 26,为未来治理、诊断
|
||||
和任务协议保留余量。OpenAPI 中同一路径的多个 HTTP method 分别计数;路径数量
|
||||
不等于操作数量。
|
||||
|
||||
## 单一来源与构建
|
||||
|
||||
规范唯一来源是 `docs/gitops/openapi-chatgpt-actions.yaml`。模型 API staging
|
||||
构建脚本 `scripts/build_v2r2c1_model_api_staging.py` 将该文件复制到 immutable
|
||||
artifact,并同时校验 `/v2/commit-files` runtime contract。禁止直接编辑已部署
|
||||
artifact 或 production 文件。
|
||||
|
||||
变更后至少执行:
|
||||
|
||||
```bash
|
||||
python3 - <<'PY'
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
doc = yaml.safe_load(Path('docs/gitops/openapi-chatgpt-actions.yaml').read_text())
|
||||
methods = {'get', 'post', 'put', 'patch', 'delete', 'head', 'options'}
|
||||
operations = [(p, m) for p, item in doc['paths'].items() for m in item if m in methods]
|
||||
assert len(operations) <= 26, len(operations)
|
||||
print(f'paths={len(doc["paths"])} operations={len(operations)}')
|
||||
PY
|
||||
PYTHONPATH=active-runtime/app python3 -m pytest -q tests/test_action_contract_regressions.py
|
||||
```
|
||||
|
||||
## 公开面选择
|
||||
|
||||
公开 schema 保留受治理的 `/v2/commit-files` 以及 release、
|
||||
tag publication、consistency、acceptance、saga 和 diff 操作。粗粒度旧别名
|
||||
`/api/file`、`/api/commit`、`/api/branch`、`/api/patch` POST、`/api/commit-file`
|
||||
以及 `/submitSignedApproval` 等旧别名/旧写原语不得重新加入公开 Action schema;
|
||||
批量写入使用 `/v2/commit-files`。完整 runtime 兼容路由不因此删除。
|
||||
|
||||
`commitFilesGoverned` 必须显式声明 `security: [{bearerAuth: []}]` 和
|
||||
`x-openai-isConsequential: true`;schema 与 Bridge runtime 的 required fields、
|
||||
batch limits 必须由同一生成链维护。
|
||||
|
||||
## 部署与复验
|
||||
|
||||
只使用 `scripts/build_v2r2c1_model_api_staging.py` 生成 staging artifact,保留
|
||||
当前 systemd drop-in 备份,更新 staging model-api 的 artifact 路径后执行
|
||||
`daemon-reload` 和 staging unit restart。部署后从公网重新抓取 schema,验证:
|
||||
|
||||
- HTTP 200,Content-Type 为 YAML;
|
||||
- 实际操作数为 30 或更少;
|
||||
- `commitFilesGoverned` 仍包含 required `files`,每项包含 `path`、`content_base64`;
|
||||
- `/v2/runtime`、capabilities、release consistency/acceptance、tag status、
|
||||
saga empty-state、diff small/empty/large smoke 均正常;
|
||||
- staging unit active,production/main/tag/baseline 未写入。
|
||||
|
||||
Action 客户端可能缓存旧规范。公网 schema 复验通过后,在 ChatGPT Action 编辑器
|
||||
重新导入该 URL 或执行刷新;服务端无法替代客户端已保存的旧 schema cache。
|
||||
@@ -215,3 +215,11 @@ The runtime configuration is staging-only: `User=aiman`, `Group=gitops`, API bas
|
||||
The release executor now binds both the configured staging baseline and the immutable runtime package's `versions/V2R2C1.json` projection. The bridge passes `AIOS_GITOPS_BASELINE_AUTHORITY` into `RuntimeConfig`; this prevents release consistency and acceptance from falling back to missing publication-workspace files.
|
||||
|
||||
The final acceptance result is recorded in `reports/action-contract-repair-evidence-20260825.md`. Contract smoke is green, and release consistency is `PASS`; release acceptance is structured policy output but remains `accepted=false` because the pre-existing publication state is `REMOTE_PUSH_PENDING` for the protected `V2R2C1` tag. The task must therefore remain `NEEDS_REWORK` until an authorized owner resolves that independent protected-tag state.
|
||||
|
||||
## External Action Surface final closure attempt (2026-08-25)
|
||||
|
||||
本轮仅部署 staging/non-production;没有直接写入 main�release tag�baseline registry 或 production。
|
||||
|
||||
Public route ownership: /api/governed-write-capabilities, /api/control-plane-snapshot, /api/online-agent-task, and /api/online-agent-task-events are exact-location proxied to 127.0.0.1:18890; generic /api/ remains on 127.0.0.1:18893. The durable installer is ops/gitops-migration/bind_external_action_surface_final_staging.sh; it backs up Nginx, runs nginx -t, reloads, and forwards Authorization/X-AIOS-Token.
|
||||
|
||||
A task-control token, local Writer grant, Gitea consumer, SSH credential, or old token file is not an external Action bearer. If formal issuance is unavailable, do not print or rotate secrets, enable disabled writes, or manufacture commit evidence; record FORMAL_STAGING_ACTION_BEARER_ISSUANCE_NOT_AVAILABLE. Public release-consistency and release-acceptance use the canonical Bridge v2 evaluator. A Task API continuation remains QUEUED until a legitimate Agent claims it and gives durable result evidence.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# AIOS 最小自治修复闭环运行手册
|
||||
|
||||
## 范围
|
||||
|
||||
本手册只覆盖 staging/non-production 的最小链路:
|
||||
|
||||
```text
|
||||
readRepositoryFile -> acquireWriterLease -> commitFilesGoverned
|
||||
-> immutable staging build/reload -> runtime/read-back
|
||||
```
|
||||
|
||||
不覆盖 release/recovery/listPatches 语义,不得写入 `main`、release tag、baseline
|
||||
或 production。
|
||||
|
||||
## Action contract
|
||||
|
||||
公开 Action schema 的 canonical URL 是:
|
||||
`https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml`。
|
||||
|
||||
本轮增加两个 operation,公开面共 28 个,保留 30 个上限余量:
|
||||
|
||||
- `readRepositoryFile`:`POST /readRepositoryFile`,必填 `repository`, `ref`, `path`, `max_bytes`;只从 authoritative bare Git object database 读取,禁止绝对路径、`..`、反斜线、NUL 和 `.git`,单次最多 262144 bytes。
|
||||
- `acquireWriterLease`:`POST /acquireWriterLease`,只接受 `CONTINUE_EXISTING`,必须绑定 durable task、repository、target branch、base OID、当前 expected branch OID、generation 和短 TTL。
|
||||
- `commitFilesGoverned`:既有 `POST /v2/commit-files`,必须先有同一 task/generation/expected OID 的 lease;`files` 为数组,每项必须含 `path` 与 `content_base64`。
|
||||
|
||||
model-api facade 对上述三个入口执行 Action bearer 认证,并把 Authorization/X-AIOS-Token 透传给 loopback Bridge。Bridge 再执行 operation-specific scope:read 允许 `repository:read` 或兼容的 `file:write`,lease 需要 `agent-task:control`,commit 需要 `file:write`。
|
||||
|
||||
## 构建与部署
|
||||
|
||||
从当前隔离 agent branch 的完整 source OID 构建:
|
||||
|
||||
```bash
|
||||
python3 scripts/build_v2r2c1_model_api_staging.py \
|
||||
--source-root . \
|
||||
--output-root /var/lib/aios-gitops/bridge-v2-staging/candidate-artifacts/<id> \
|
||||
--source-oid <40-hex-source-oid>
|
||||
```
|
||||
|
||||
构建 receipt 必须声明 `STAGING`、`aios/aios`、`legacy_global_writes_enabled=false`,并记录 artifact SHA-256。使用 versioned systemd drop-in template,将 source OID、artifact SHA-256 和 immutable runtime path 渲染到 staging 两个 unit;同时使用:
|
||||
|
||||
```ini
|
||||
LoadCredential=action_tokens:/data/aios-gitops/runtime/secrets/gitops.tokens
|
||||
Environment=AIOS_GITOPS_TOKEN_FILE=%d/action_tokens
|
||||
```
|
||||
|
||||
这样由 systemd 投影已有 Action token registry,不复制或打印 token,不使用 Gitea token 代替 Action bearer。部署后只执行:
|
||||
|
||||
```bash
|
||||
systemctl daemon-reload
|
||||
systemctl restart aios-chatgpt-bridge-v2-staging.service
|
||||
systemctl restart aios-gitops-model-api-v2r2c1-staging.service
|
||||
```
|
||||
|
||||
## 验收顺序
|
||||
|
||||
1. `systemctl is-active` 两个 staging unit;读取 `/api/health`、`/v2/runtime`。
|
||||
2. 从公网 URL 重新抓取 YAML,解析 operation 数量,确认 `<=30`,并确认 `files`/`path`/`content_base64` required parity。
|
||||
3. 使用正式 Action bearer:
|
||||
|
||||
```text
|
||||
readRepositoryFile(repository, ref=agent/<branch> or exact OID, path, max_bytes)
|
||||
acquireWriterLease(task_id, repository, CONTINUE_EXISTING, base_oid,
|
||||
expected_branch_oid=<read resolved OID>, target_branch,
|
||||
generation, lease_seconds)
|
||||
commitFilesGoverned(task_id, repository, expected_branch_oid, target_branch,
|
||||
generation, idempotency_key, two files, message)
|
||||
```
|
||||
|
||||
4. 校验 authority branch OID、Gitea Git ref OID、Gitea API branch OID 和 `commit_visible` 一致;重复相同 idempotency key 不得生成第二个 commit,旧 expected OID 必须拒绝。
|
||||
5. 验证 staging runtime source OID/artifact SHA-256 与 build receipt 一致,再重复完整 smoke。
|
||||
6. 若 bearer 不可用,状态必须是 `BLOCKED`,不能用 SSH、root、Gitea、旧 token、service credential 或 dev bypass 代替。
|
||||
|
||||
完整 suite 使用:
|
||||
|
||||
```bash
|
||||
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=.:services/aios-release-controller pytest -p no:cacheprovider -q
|
||||
```
|
||||
|
||||
## 回滚边界
|
||||
|
||||
只回滚本轮创建的 staging artifact/drop-in;不得回滚 production release。若 health、Gitea readback、protected-ref boundary 或 mandatory suite 回归,恢复前一份 staging drop-in,采集 `journalctl -u` 日志,再继续诊断。
|
||||
@@ -1,311 +0,0 @@
|
||||
# Agent Execution Contract — V2R2C2 Control-Plane Convergence
|
||||
|
||||
## Objective
|
||||
|
||||
Complete the AIOS Phase -1 authority/runtime/contract convergence on the existing
|
||||
single server with no interactive prompts. The source integration branch already
|
||||
contains the staging-validated Action contract repair lineage plus the new minimal
|
||||
convergence contracts. The Agent's job is to verify, finish the one remaining
|
||||
release-publication reconciliation defect if it is still reproducible, build and
|
||||
deploy **staging only**, prove the same-source runtime, and return machine evidence.
|
||||
|
||||
This is not authorization to modify production, protected tags, production baseline,
|
||||
or `main` directly.
|
||||
|
||||
## Canonical identities
|
||||
|
||||
- Repository: `aios/aios`
|
||||
- Authoritative main observed at task start:
|
||||
`3244f0f17f8845592ac94d585f5ebd65693c9f29`
|
||||
- Production baseline: `V2R2C1`
|
||||
- Production target:
|
||||
`998d30304787e31e5ae607c0bd86b5edb2b0e5ad`
|
||||
- Production evidence closure:
|
||||
`a8b906d1bc24088c19647e98de6a679268f6d0a2`
|
||||
- Approval plan:
|
||||
`sha256:c0df52008ae7f64cec5ee2d28947aa7b892066c670ab8295e4549140b1a7500e`
|
||||
- Staging repair source lineage:
|
||||
`174e64dd0218cef6d49dd13d58bbd90c78fecadd`
|
||||
- Integration branch:
|
||||
`agent/v2r2c2-convergence-integration-20260825`
|
||||
- Integration branch OID at handoff:
|
||||
`7526fef619edd8170c03c31a87e32cd1ea52a4ec`
|
||||
|
||||
## Code already completed
|
||||
|
||||
The integration branch includes the complete governed staging repair lineage rooted
|
||||
at the authoritative main revision, including:
|
||||
|
||||
- canonical release-version validation;
|
||||
- startup-safe Gitea API URL validation;
|
||||
- operation contract metadata and `commitFilesGoverned` schema parity;
|
||||
- bounded `getDiff`;
|
||||
- idempotent terminal job reconciliation;
|
||||
- CREATE_NEW branch CAS recovery;
|
||||
- release baseline/version projection;
|
||||
- external governed-read routing;
|
||||
- canonical release consistency routing;
|
||||
- durable NOT_FOUND release-saga semantics.
|
||||
|
||||
It also adds:
|
||||
|
||||
- `active-runtime/app/aios_convergence_contracts.py`
|
||||
- `tests/test_convergence_contracts.py`
|
||||
|
||||
The new convergence contracts deliberately have no external side effects. They add:
|
||||
|
||||
- fail-closed `AcceptanceVector` / `AcceptanceResult`;
|
||||
- domain-preserving final-writer ownership validation;
|
||||
- `DesiredState` / `ObservedState`;
|
||||
- pure reconciliation planning with stable idempotency identity;
|
||||
- `SideEffectReceipt`;
|
||||
- `ContinuationManifest`.
|
||||
|
||||
Local isolated validation before handoff: `8 passed`.
|
||||
|
||||
## Current observed runtime facts
|
||||
|
||||
At handoff:
|
||||
|
||||
- model API staging service is active at source
|
||||
`174e64dd0218cef6d49dd13d58bbd90c78fecadd`;
|
||||
- external `getControlPlaneSnapshot` is reachable;
|
||||
- external `getGovernedWriteCapabilities` is reachable;
|
||||
- `getReleaseConsistency(V2R2C1, ...)` returns `status=PASS` and all checks true;
|
||||
- remote/local tag object is
|
||||
`07fc2746b688f5f243f29b39de39f32edc3dd9d2`;
|
||||
- peeled target is
|
||||
`998d30304787e31e5ae607c0bd86b5edb2b0e5ad`;
|
||||
- production health returns HTTP 200 and the same target/artifact identity;
|
||||
- `runReleaseAcceptance` still returns `accepted=false` despite consistency PASS;
|
||||
- `getTagPublicationStatus(V2R2C1)` currently returns `UNKNOWN`;
|
||||
- `getControlPlaneSnapshot.runtime.runtime_release_commit` reports
|
||||
`2ec49005bde44f6981b14351500847d594e0276c`, while the model API runtime
|
||||
reports `174e64dd0218cef6d49dd13d58bbd90c78fecadd`.
|
||||
|
||||
Those last two facts are the remaining convergence defects.
|
||||
|
||||
## Non-interactive execution policy
|
||||
|
||||
Run continuously without asking the Owner to type "continue".
|
||||
|
||||
Automatically:
|
||||
|
||||
1. collect facts;
|
||||
2. run tests;
|
||||
3. diagnose failures;
|
||||
4. make the smallest correct repair;
|
||||
5. rebuild immutable staging artifacts;
|
||||
6. deploy/restart only affected staging services;
|
||||
7. observe external truth;
|
||||
8. retry recoverable failures;
|
||||
9. roll back newly introduced staging regressions;
|
||||
10. produce final evidence.
|
||||
|
||||
Stop only for:
|
||||
|
||||
- authority/approval that cannot be satisfied by existing bindings;
|
||||
- irreversible production change;
|
||||
- protected-ref mutation;
|
||||
- security-boundary change;
|
||||
- genuine goal ambiguity;
|
||||
- no-progress after bounded diagnosis/repair attempts.
|
||||
|
||||
## Phase A — pre-edit facts
|
||||
|
||||
Before any further edit, persist:
|
||||
|
||||
- current authoritative HEAD;
|
||||
- integration branch OID and remote readback;
|
||||
- `git merge-base --is-ancestor 3244f0f... 7526fef...`;
|
||||
- staging service source/artifact identity for both model API and Bridge;
|
||||
- systemd unit + drop-in ordering for both services;
|
||||
- exact environment sources for baseline, Gitea API, state roots and PYTHONPATH;
|
||||
- current release consistency, acceptance and tag-publication status;
|
||||
- current control-plane snapshot;
|
||||
- current governed-write capabilities operation contracts.
|
||||
|
||||
Do not trust a report if a direct runtime observation is available.
|
||||
|
||||
## Phase B — mandatory tests
|
||||
|
||||
Run at minimum:
|
||||
|
||||
```text
|
||||
PYTHONPATH=active-runtime/app python3 -m pytest -q tests/test_convergence_contracts.py tests/test_action_contract_regressions.py tests/test_bridge_v2_governed_http_bootstrap.py
|
||||
|
||||
PYTHONPATH=active-runtime/app python3 -m pytest -q tests/test_silent_release_execution_readiness.py tests/test_silent_release_controller.py tests/test_silent_release_hardening.py tests/test_silent_release_production_contract.py tests/test_chatgpt_actions_tag.py tests/test_gitea_tag_publication.py tests/test_authoritative_git_context.py
|
||||
```
|
||||
|
||||
Then run the repository-wide suite. Compare failures to the persisted pre-existing
|
||||
baseline. No new regression is allowed.
|
||||
|
||||
## Phase C — release-publication truth reconciliation
|
||||
|
||||
Only execute this code repair if the following remains simultaneously true on the
|
||||
same staging runtime:
|
||||
|
||||
```text
|
||||
getReleaseConsistency.status == PASS
|
||||
runReleaseAcceptance.accepted == false
|
||||
getTagPublicationStatus(V2R2C1) in {UNKNOWN, REMOTE_PUSH_PENDING}
|
||||
```
|
||||
|
||||
### Required semantics
|
||||
|
||||
Do **not** hard-code `accepted=true` and do not reinterpret UNKNOWN as PASS.
|
||||
|
||||
The publication controller must reconcile durable publication state from observed
|
||||
external truth:
|
||||
|
||||
1. Resolve the approved version binding.
|
||||
2. Read authoritative local tag object and peeled commit.
|
||||
3. Read Gitea tag object and peeled commit.
|
||||
4. Read registry/baseline approved tag object and target.
|
||||
5. Compare all identities to the approved release binding.
|
||||
6. If every required observation is present and exactly equal:
|
||||
- persist the existing terminal-success publication state used by the codebase
|
||||
(`PUBLISHED`/equivalent);
|
||||
- mark the transition as observation-only/reconciled;
|
||||
- record the observed tag object, peeled target, repository identity and evidence;
|
||||
- perform **no tag create/push**.
|
||||
7. If any identity differs:
|
||||
- fail closed as drift/conflict;
|
||||
- never mutate the protected tag.
|
||||
8. If any required observation is unavailable:
|
||||
- remain `UNKNOWN`/reconciling;
|
||||
- do not mark accepted.
|
||||
9. Replaying reconciliation must be idempotent and must not create a second
|
||||
side effect.
|
||||
|
||||
`runReleaseAcceptance` may return accepted only after both:
|
||||
|
||||
```text
|
||||
release consistency == PASS
|
||||
AND reconciled publication state == terminal success
|
||||
```
|
||||
|
||||
### Mandatory regression tests
|
||||
|
||||
Add focused tests for:
|
||||
|
||||
- missing local publication journal + exact observed remote tag -> terminal success;
|
||||
- stale `REMOTE_PUSH_PENDING` + exact observed remote tag -> terminal success;
|
||||
- remote tag object mismatch -> fail closed;
|
||||
- peeled target mismatch -> fail closed;
|
||||
- Gitea observation unavailable -> UNKNOWN, not accepted;
|
||||
- replay after reconciled success -> identical durable receipt, no tag write/push;
|
||||
- existing protected tag is never force-updated or deleted.
|
||||
|
||||
Use the existing `TagPublisher`/publication store and existing reconciliation/state
|
||||
model. Do not create a second release database or a second release controller.
|
||||
|
||||
## Phase D — same-source staging convergence
|
||||
|
||||
Build from integration branch OID after tests. Deploy the same immutable artifact
|
||||
lineage to every staging component participating in these Actions.
|
||||
|
||||
Acceptance requires the externally visible snapshot to stop presenting mixed
|
||||
source identity. At minimum model API and Bridge must report the same intended
|
||||
source/build identity or explicitly report component-level identities without
|
||||
claiming a single aggregate runtime commit.
|
||||
|
||||
Do not hide component drift by overwriting the snapshot value.
|
||||
|
||||
Preferred fix if the snapshot currently has only one `runtime_release_commit`:
|
||||
extent it to expose component identities, for example:
|
||||
|
||||
```text
|
||||
runtime.components.model_api.source_oid
|
||||
runtime.components.bridge.source_oid
|
||||
runtime.aggregate_drift_status
|
||||
```
|
||||
|
||||
Aggregate PASS is legal only if the required components are on the approved
|
||||
compatible source set.
|
||||
|
||||
## Phase E — staging smoke
|
||||
|
||||
On the deployed staging artifact run:
|
||||
|
||||
- runtime status;
|
||||
- control-plane snapshot;
|
||||
- governed-write capabilities;
|
||||
- release consistency;
|
||||
- release acceptance;
|
||||
- tag publication status;
|
||||
- nonexistent release saga;
|
||||
- bounded large diff;
|
||||
- completed-job reconcile idempotency;
|
||||
- isolated two-file governed commit on a throwaway `agent/` branch.
|
||||
|
||||
Required end state:
|
||||
|
||||
```text
|
||||
release_consistency = PASS
|
||||
release_acceptance.accepted = true
|
||||
tag_publication = terminal success
|
||||
control_plane_snapshot = reachable
|
||||
governed_capabilities = reachable
|
||||
runtime_component_drift = PASS
|
||||
legacy_writes = false
|
||||
force_push = false
|
||||
main_write = false
|
||||
tag_write = false
|
||||
baseline_write = false
|
||||
production_write = false
|
||||
```
|
||||
|
||||
## Phase F — restart persistence
|
||||
|
||||
Restart only affected staging services, without changing files.
|
||||
|
||||
Rerun Phase E. All identities and outcomes must remain stable.
|
||||
|
||||
## Governance and branch handling
|
||||
|
||||
Do not directly modify `main`, `V2R2C1`, the production baseline registry or
|
||||
production services.
|
||||
|
||||
The current Bridge writer binding has a known limitation: after a successful
|
||||
single-file CAS commit, reacquiring the same task lease can return the original
|
||||
expected branch OID, and rebinding the same task/generation can conflict.
|
||||
|
||||
Do not work around that with direct Git writes.
|
||||
|
||||
For source changes use the existing governed task/lease/commit flow. If the platform
|
||||
requires one governed task binding per branch advance, create an explicitly named
|
||||
follow-up governed task on the **same branch**, with:
|
||||
|
||||
- `base_oid` = previously verified branch OID;
|
||||
- `expected_branch_oid` = same previously verified branch OID;
|
||||
- same repository and branch;
|
||||
- no protected-ref write.
|
||||
|
||||
Record this as `TASK_BINDING_EXPECTED_OID_NOT_ADVANCING` evidence.
|
||||
|
||||
## Final evidence
|
||||
|
||||
Commit a durable report:
|
||||
|
||||
`reports/v2r2c2-control-plane-convergence-evidence-20260825.md`
|
||||
|
||||
It must contain:
|
||||
|
||||
- pre-change facts;
|
||||
- exact commits and changed paths;
|
||||
- all test commands/results;
|
||||
- release-publication reconciliation proof;
|
||||
- proof that no protected tag write occurred during observation reconciliation;
|
||||
- build artifact SHA;
|
||||
- staging component source identities;
|
||||
- pre/post restart smoke;
|
||||
- branch authority/Git remote/API readback;
|
||||
- remaining known risks;
|
||||
- final disposition.
|
||||
|
||||
Do not declare `SUCCEEDED` unless every required end-state line in Phase E is
|
||||
proven from the actually deployed staging runtime.
|
||||
|
||||
Production promotion is a separate Owner-approved release step and is **not** part
|
||||
of this execution contract.
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a deterministic plan-bound production payload from one clean source tree."""
|
||||
from __future__ import annotations
|
||||
import argparse, hashlib, json, shutil, tarfile, tempfile
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE_FILES = (
|
||||
"active-runtime/app/deployment_state.py",
|
||||
"active-runtime/app/v2r1c6_privacy_tools.py",
|
||||
"active-runtime/app/v2r2c2_health_service.py",
|
||||
"deploy/aios-v2/systemd/aios-v2r2c2-production.service",
|
||||
)
|
||||
|
||||
def canon(value: object) -> bytes:
|
||||
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for block in iter(lambda: f.read(1024 * 1024), b""): h.update(block)
|
||||
return h.hexdigest()
|
||||
|
||||
def require_oid(value: str, label: str) -> str:
|
||||
if len(value) != 40 or any(c not in "0123456789abcdef" for c in value): raise SystemExit(label + "_invalid")
|
||||
return value
|
||||
|
||||
def require_sha(value: str, label: str) -> str:
|
||||
if len(value) != 64 or any(c not in "0123456789abcdef" for c in value): raise SystemExit(label + "_invalid")
|
||||
return value
|
||||
|
||||
def build(source: Path, output: Path, *, version: str, source_oid: str, tree_oid: str,
|
||||
rollback_version: str, rollback_target_oid: str, rollback_artifact_sha256: str,
|
||||
service: str, port: int) -> dict[str, object]:
|
||||
if not source.is_dir() or source.is_symlink(): raise SystemExit("source_directory_invalid")
|
||||
require_oid(source_oid, "source_oid"); require_oid(tree_oid, "tree_oid")
|
||||
require_oid(rollback_target_oid, "rollback_target_oid"); require_sha(rollback_artifact_sha256, "rollback_artifact_sha256")
|
||||
if version != "V2R2C2" or rollback_version == version: raise SystemExit("release_version_binding_invalid")
|
||||
if service != "aios-v2r2c2-production.service" or port != 18928: raise SystemExit("adapter_policy_binding_invalid")
|
||||
if output.exists(): raise SystemExit("output_exists")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="trusted-release-build-") as td:
|
||||
payload = Path(td) / "payload"; payload.mkdir()
|
||||
for rel in SOURCE_FILES:
|
||||
source_file = source / rel
|
||||
if not source_file.is_file() or source_file.is_symlink(): raise SystemExit("required_source_missing:" + rel)
|
||||
dest = payload / rel; dest.parent.mkdir(parents=True, exist_ok=True); shutil.copyfile(source_file, dest)
|
||||
(payload / "VERSION").write_text(version + "\n", encoding="utf-8")
|
||||
build_meta = {"schema": "aios.trusted-release-artifact-build.v1", "version": version,
|
||||
"source_commit": source_oid, "tree_oid": tree_oid, "production_deployed": False,
|
||||
"tag_created": False, "baseline_changed": False, "adapter_service": service, "adapter_port": port}
|
||||
(payload / "BUILD-METADATA.json").write_bytes(canon(build_meta))
|
||||
rollback = {"schema": "aios.trusted-release-rollback.v1", "version": rollback_version,
|
||||
"target_oid": rollback_target_oid, "artifact_sha256": rollback_artifact_sha256,
|
||||
"automatic_rollback": False}
|
||||
(payload / "ROLLBACK-METADATA.json").write_bytes(canon(rollback))
|
||||
files = []
|
||||
for item in sorted(p for p in payload.rglob("*") if p.is_file()):
|
||||
if item.name != "ARTIFACT-MANIFEST.json":
|
||||
files.append({"path": "payload/" + item.relative_to(payload).as_posix(), "sha256": digest(item), "size": item.stat().st_size})
|
||||
manifest = {"schema": "aios.trusted-release-artifact-manifest.v1", "version": version,
|
||||
"source_commit": source_oid, "tree_oid": tree_oid, "files": files,
|
||||
"deployable": True, "production_deployed": False}
|
||||
(payload / "ARTIFACT-MANIFEST.json").write_bytes(canon(manifest))
|
||||
files.append({"path": "payload/ARTIFACT-MANIFEST.json", "sha256": digest(payload / "ARTIFACT-MANIFEST.json"), "size": (payload / "ARTIFACT-MANIFEST.json").stat().st_size})
|
||||
tar_path = Path(td) / "artifact.tar"
|
||||
with tarfile.open(tar_path, "w") as archive:
|
||||
for item in sorted(p for p in payload.rglob("*") if p.is_file()):
|
||||
info = archive.gettarinfo(str(item), arcname="payload/" + item.relative_to(payload).as_posix())
|
||||
info.uid = 0; info.gid = 0; info.uname = "root"; info.gname = "root"; info.mtime = 0; info.mode = 0o644
|
||||
with item.open("rb") as stream: archive.addfile(info, stream)
|
||||
shutil.copyfile(tar_path, output)
|
||||
receipt = {"schema": "aios.trusted-release-artifact-receipt.v1", "version": version,
|
||||
"source_commit": source_oid, "tree_oid": tree_oid, "artifact_sha256": digest(output),
|
||||
"artifact_size": output.stat().st_size, "manifest_sha256": hashlib.sha256(canon(manifest)).hexdigest(),
|
||||
"package_closure_sha256": hashlib.sha256(canon({"source_commit": source_oid, "tree_oid": tree_oid, "files": files})).hexdigest(),
|
||||
"production_deployed": False, "tag_created": False, "baseline_changed": False,
|
||||
"service": service, "port": port}
|
||||
output.with_suffix(".manifest.json").write_bytes(canon(receipt))
|
||||
return receipt
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(); p.add_argument("--source", type=Path, required=True); p.add_argument("--output", type=Path, required=True)
|
||||
p.add_argument("--version", required=True); p.add_argument("--source-oid", required=True); p.add_argument("--tree-oid", required=True)
|
||||
p.add_argument("--rollback-version", required=True); p.add_argument("--rollback-target-oid", required=True); p.add_argument("--rollback-artifact-sha256", required=True)
|
||||
p.add_argument("--service", required=True); p.add_argument("--port", type=int, required=True); a = p.parse_args()
|
||||
result = build(a.source, a.output, version=a.version, source_oid=a.source_oid, tree_oid=a.tree_oid,
|
||||
rollback_version=a.rollback_version, rollback_target_oid=a.rollback_target_oid,
|
||||
rollback_artifact_sha256=a.rollback_artifact_sha256, service=a.service, port=a.port)
|
||||
print(json.dumps(result, sort_keys=True)); return 0
|
||||
if __name__ == "__main__": raise SystemExit(main())
|
||||
@@ -28,12 +28,14 @@ REQUIRED_APP = (
|
||||
'aios_continuation_executor.py',
|
||||
'aios_continuation_lease.py',
|
||||
'aios_authority_git_executor.py',
|
||||
'scoped_lease_store.py',
|
||||
'aios_gitops_reconciliation_core.py',
|
||||
'authorized_task_writer_provisioning.py',
|
||||
'signer_contract_compatibility.py',
|
||||
)
|
||||
OPTIONAL_APP = (
|
||||
'aios_chatgpt_actions_api.py',
|
||||
'aios_model_api_staging_list_patches_overlay.py',
|
||||
'aios_continuation_executor.py',
|
||||
'aios_continuation_lease.py',
|
||||
'aios_control_plane_snapshot.py',
|
||||
@@ -117,7 +119,7 @@ def main() -> int:
|
||||
'repository_identity': 'aios/aios',
|
||||
'deployment_state': 'STAGING',
|
||||
'runtime_mode': 'IMMUTABLE_STAGING',
|
||||
'runtime_entrypoint': 'app/aios_model_api_staging.py',
|
||||
'runtime_entrypoint': 'app/aios_model_api_staging_list_patches_overlay.py',
|
||||
'legacy_global_writes_enabled': False,
|
||||
'governed_release_routing': 'bridge-v2',
|
||||
'artifact_sha256': 'provided-by-build-receipt-and-install-manifest',
|
||||
|
||||
@@ -6,6 +6,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
@@ -53,9 +54,9 @@ def test_public_release_reads_use_the_bridge_canonical_evaluator():
|
||||
"approval_plan_id": "sha256:plan",
|
||||
}
|
||||
source = inspect.getsource(model_api.H.do_GET)
|
||||
assert "publication_publisher()" in source
|
||||
assert "get_release_consistency" in source
|
||||
assert "run_release_acceptance" in source
|
||||
assert "self.bridge_release(" in source
|
||||
assert "'/getReleaseConsistency'" in source
|
||||
assert "'/runReleaseAcceptance'" in source
|
||||
|
||||
|
||||
def test_governed_read_routes_are_bound_to_authenticated_facade():
|
||||
@@ -74,6 +75,57 @@ def test_governed_read_routes_are_bound_to_authenticated_facade():
|
||||
assert "Authorization $http_authorization" in route_script
|
||||
|
||||
|
||||
def test_published_action_v2_paths_are_bound_to_staging_bridge():
|
||||
route_script = (
|
||||
ROOT / "ops/gitops-migration/bind_external_action_surface_final_staging.sh"
|
||||
).read_text()
|
||||
schema = (ROOT / "docs/gitops/openapi-chatgpt-actions.yaml").read_text()
|
||||
assert "servers:" in schema
|
||||
assert "- url: https://cnnetmkt.top" in schema
|
||||
assert " /v2/commit-files:" in schema
|
||||
assert "location ^~ /v2/" in route_script
|
||||
assert "proxy_pass http://127.0.0.1:18892;" in route_script
|
||||
assert "proxy_pass http://127.0.0.1:18920;" not in route_script
|
||||
assert "Authorization $http_authorization" in route_script
|
||||
|
||||
|
||||
def test_published_action_schema_stays_within_chatgpt_operation_limit():
|
||||
schema = yaml.safe_load(
|
||||
(ROOT / "docs/gitops/openapi-chatgpt-actions.yaml").read_text()
|
||||
)
|
||||
operations = [
|
||||
(path, method, operation)
|
||||
for path, item in schema["paths"].items()
|
||||
for method, operation in item.items()
|
||||
if method.lower() in {"get", "post", "put", "patch", "delete", "head", "options"}
|
||||
]
|
||||
assert len(operations) <= 30
|
||||
assert len(operations) == 28
|
||||
assert "commitFilesGoverned" in {
|
||||
operation["operationId"] for _, _, operation in operations
|
||||
}
|
||||
assert {"readRepositoryFile", "acquireWriterLease"}.issubset(
|
||||
{operation["operationId"] for _, _, operation in operations}
|
||||
)
|
||||
assert "/api/file" not in schema["paths"]
|
||||
assert "/api/commit" not in schema["paths"]
|
||||
operation_ids = {operation["operationId"] for _, _, operation in operations}
|
||||
assert {
|
||||
"submitSignedApproval",
|
||||
"createPatch",
|
||||
"createBranch",
|
||||
"commitFileAtomic",
|
||||
"commitFile",
|
||||
"commitChanges",
|
||||
}.isdisjoint(operation_ids)
|
||||
governed = next(
|
||||
operation for _, _, operation in operations
|
||||
if operation["operationId"] == "commitFilesGoverned"
|
||||
)
|
||||
assert governed["security"] == [{"bearerAuth": []}]
|
||||
assert governed["x-openai-isConsequential"] is True
|
||||
|
||||
|
||||
def test_commit_files_runtime_and_published_schema_have_same_required_fields():
|
||||
runtime = operation_contracts()["commitFilesGoverned"]["required_fields"]
|
||||
assert runtime == list(OPERATION_REQUIRED_FIELDS["commitFilesGoverned"])
|
||||
|
||||
@@ -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
|
||||
@@ -92,8 +92,8 @@ def test_slash_branch_uses_paginated_collection():
|
||||
def test_http_route_contract_is_complete():
|
||||
expected = {
|
||||
"/v2/runtime", "/v2/capabilities", "/v2/governed-task",
|
||||
"/v2/execution-envelope",
|
||||
"/v2/writer-lease", "/v2/commit-file", "/v2/commit-files",
|
||||
"/v2/writer-lease", "/v2/commit-file", "/v2/commit-files",
|
||||
"/v2/source/file",
|
||||
"/v2/job", "/v2/job/resume", "/v2/job/reconcile",
|
||||
"/v2/tag/approval-request",
|
||||
"/v2/tag/publish",
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
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_projection_recovery import (
|
||||
CanonicalTaskProjectionIntent,
|
||||
CanonicalTaskProjectionStore,
|
||||
ProjectionRecoveryError,
|
||||
ProjectionState,
|
||||
sha256_json,
|
||||
)
|
||||
from aios_agent_task_projection_recovery_http import recover_from_json
|
||||
|
||||
|
||||
TASK_ID = "TASK-CONV-V2R2C2-RECONCILE-SNAPSHOT-20260826"
|
||||
JOB_ID = "job-9eee1142f2fc8bb7b571d065c01f40e0"
|
||||
OID = "2afc2cf41fc9090cf01e3882d1b8db8d54cf1844"
|
||||
RUNBOOK_SHA = "a500504a76fb99b26541eaf4edd842a942992ae170fc0f3838530189ca672320"
|
||||
|
||||
|
||||
def make_db(tmp_path: Path) -> Path:
|
||||
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
|
||||
);
|
||||
"""
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
return path
|
||||
|
||||
|
||||
def manifest() -> dict:
|
||||
task = {
|
||||
"task_id": TASK_ID,
|
||||
"goal": "Complete V2R2C2 staging convergence",
|
||||
"instructions": "Resume the immutable runbook and close the parent goal.",
|
||||
"success_criteria": [{"id": "closure", "required": True, "expected": "PASS"}],
|
||||
"constraints": {
|
||||
"staging_only": True,
|
||||
"production_write": False,
|
||||
"main_direct_write": False,
|
||||
},
|
||||
"required_capabilities": [],
|
||||
"metadata": {
|
||||
"governed_task_id": TASK_ID,
|
||||
"bridge_job_id": JOB_ID,
|
||||
"runbook_sha256": RUNBOOK_SHA,
|
||||
},
|
||||
"priority": 100,
|
||||
"max_attempts": 20,
|
||||
"deadline": None,
|
||||
}
|
||||
return {
|
||||
"schema": "aios.canonical-task-projection-intent.v1",
|
||||
"projection_id": "projection-v2r2c2-parent-recovery-20260826",
|
||||
"projection_generation": 1,
|
||||
"idempotency_key": "projection-v2r2c2-parent-recovery-20260826-v1",
|
||||
"task": task,
|
||||
"governed_binding": {
|
||||
"governed_task_id": TASK_ID,
|
||||
"bridge_job_id": JOB_ID,
|
||||
"repository": "aios/aios",
|
||||
"branch": "agent/v2r2c2-convergence-integration-20260825",
|
||||
"generation": 2,
|
||||
"base_oid": OID,
|
||||
"expected_branch_oid": OID,
|
||||
},
|
||||
"source_artifact": {
|
||||
"ref": f"git:{OID}:runbooks/agent-v2r2c2-control-plane-convergence-20260825.md",
|
||||
"sha256": RUNBOOK_SHA,
|
||||
},
|
||||
"task_payload_sha256": sha256_json(task),
|
||||
}
|
||||
|
||||
|
||||
def test_missing_task_is_recovered_with_same_identity(tmp_path):
|
||||
db = make_db(tmp_path)
|
||||
intent = CanonicalTaskProjectionIntent.from_mapping(manifest())
|
||||
result = CanonicalTaskProjectionStore(db).reconcile(intent)
|
||||
|
||||
assert result["state"] == ProjectionState.CONVERGED.value
|
||||
assert result["canonical_task_id"] == TASK_ID
|
||||
assert result["bridge_job_id"] == JOB_ID
|
||||
assert result["created_new_row"] is True
|
||||
|
||||
con = sqlite3.connect(db)
|
||||
row = con.execute(
|
||||
"SELECT task_id,status,attempt,revision,claimed_by,"
|
||||
"required_capabilities_json,metadata_json FROM tasks WHERE task_id=?",
|
||||
(TASK_ID,),
|
||||
).fetchone()
|
||||
assert row[:5] == (TASK_ID, "QUEUED", 0, 1, None)
|
||||
assert json.loads(row[5]) == []
|
||||
metadata = json.loads(row[6])
|
||||
assert "execution" not in metadata
|
||||
assert "execution_projection" not in metadata
|
||||
events = con.execute(
|
||||
"SELECT event_type FROM events WHERE task_id=? ORDER BY event_id",
|
||||
(TASK_ID,),
|
||||
).fetchall()
|
||||
assert events == [("TASK_CREATED",)]
|
||||
|
||||
|
||||
def test_replay_is_idempotent_and_creates_no_duplicate_event(tmp_path):
|
||||
db = make_db(tmp_path)
|
||||
intent = CanonicalTaskProjectionIntent.from_mapping(manifest())
|
||||
store = CanonicalTaskProjectionStore(db)
|
||||
first = store.reconcile(intent)
|
||||
second = store.reconcile(intent)
|
||||
|
||||
assert first["idempotent"] is False
|
||||
assert second["idempotent"] is True
|
||||
con = sqlite3.connect(db)
|
||||
assert con.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] == 1
|
||||
assert con.execute("SELECT COUNT(*) FROM events").fetchone()[0] == 1
|
||||
assert con.execute(
|
||||
"SELECT COUNT(*) FROM task_projection_receipts"
|
||||
).fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_existing_matching_row_converges_without_second_task_created_event(tmp_path):
|
||||
db = make_db(tmp_path)
|
||||
intent = CanonicalTaskProjectionIntent.from_mapping(manifest())
|
||||
store = CanonicalTaskProjectionStore(db)
|
||||
store.reconcile(intent)
|
||||
|
||||
con = sqlite3.connect(db)
|
||||
con.execute("DELETE FROM task_projection_receipts")
|
||||
con.execute("DELETE FROM task_projection_intents")
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
result = store.reconcile(intent)
|
||||
assert result["created_new_row"] is False
|
||||
|
||||
con = sqlite3.connect(db)
|
||||
assert con.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] == 1
|
||||
assert con.execute("SELECT COUNT(*) FROM events").fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_existing_different_task_payload_fails_closed(tmp_path):
|
||||
db = make_db(tmp_path)
|
||||
intent = CanonicalTaskProjectionIntent.from_mapping(manifest())
|
||||
store = CanonicalTaskProjectionStore(db)
|
||||
store.reconcile(intent)
|
||||
|
||||
con = sqlite3.connect(db)
|
||||
con.execute("DELETE FROM task_projection_receipts")
|
||||
con.execute("DELETE FROM task_projection_intents")
|
||||
con.execute("UPDATE tasks SET goal='drifted'")
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
with pytest.raises(
|
||||
ProjectionRecoveryError,
|
||||
match="CANONICAL_TASK_PROJECTION_IDENTITY_CONFLICT",
|
||||
):
|
||||
store.reconcile(intent)
|
||||
|
||||
|
||||
def test_intent_replay_with_same_generation_but_different_request_conflicts(tmp_path):
|
||||
db = make_db(tmp_path)
|
||||
first = manifest()
|
||||
intent = CanonicalTaskProjectionIntent.from_mapping(first)
|
||||
store = CanonicalTaskProjectionStore(db)
|
||||
store.persist_intent(intent)
|
||||
|
||||
other = manifest()
|
||||
other["task"]["instructions"] = "different"
|
||||
other["task_payload_sha256"] = sha256_json(other["task"])
|
||||
other_intent = CanonicalTaskProjectionIntent.from_mapping(other)
|
||||
with pytest.raises(
|
||||
ProjectionRecoveryError,
|
||||
match="PROJECTION_INTENT_IDEMPOTENCY_CONFLICT",
|
||||
):
|
||||
store.persist_intent(other_intent)
|
||||
|
||||
|
||||
def test_recovery_refuses_non_pristine_execution_metadata(tmp_path):
|
||||
value = manifest()
|
||||
value["task"]["metadata"]["execution"] = {"action": "agent_runtime"}
|
||||
value["task_payload_sha256"] = sha256_json(value["task"])
|
||||
with pytest.raises(
|
||||
ProjectionRecoveryError,
|
||||
match="TASK_RECOVERY_REQUIRES_PRISTINE_METADATA",
|
||||
):
|
||||
CanonicalTaskProjectionIntent.from_mapping(value)
|
||||
|
||||
|
||||
def test_recovery_refuses_runtime_capability_in_pristine_row(tmp_path):
|
||||
value = manifest()
|
||||
value["task"]["required_capabilities"] = ["agent.runtime.openclaw"]
|
||||
value["task_payload_sha256"] = sha256_json(value["task"])
|
||||
with pytest.raises(
|
||||
ProjectionRecoveryError,
|
||||
match="TASK_RECOVERY_REQUIRES_PRISTINE_CAPABILITIES",
|
||||
):
|
||||
CanonicalTaskProjectionIntent.from_mapping(value)
|
||||
|
||||
|
||||
def test_http_adapter_rejects_path_body_task_mismatch(tmp_path):
|
||||
db = make_db(tmp_path)
|
||||
status, body = recover_from_json(db, "TASK-other-123", manifest())
|
||||
assert status == 409
|
||||
assert body["error"] == "PROJECTION_TASK_IDENTITY_MISMATCH"
|
||||
|
||||
|
||||
def test_projection_readback_contains_durable_mapping_and_receipt(tmp_path):
|
||||
db = make_db(tmp_path)
|
||||
intent = CanonicalTaskProjectionIntent.from_mapping(manifest())
|
||||
store = CanonicalTaskProjectionStore(db)
|
||||
store.reconcile(intent)
|
||||
|
||||
projection = store.get_projection(TASK_ID)
|
||||
assert projection is not None
|
||||
assert projection["task_id"] == TASK_ID
|
||||
assert projection["bridge_job_id"] == JOB_ID
|
||||
assert projection["state"] == "CONVERGED"
|
||||
assert projection["receipt"]["canonical_task_id"] == TASK_ID
|
||||
@@ -1,333 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_gitops_job_controller import BindingConflict, GitOpsJobController, JobError, JobState, JobStore, sha256_json
|
||||
|
||||
|
||||
TASK = "TASK-CONV-V2R2C2-RECONCILE-SNAPSHOT-20260826"
|
||||
EXTERNAL = "job-external-v7"
|
||||
BRANCH = "agent/v2r2c2-convergence-integration-20260825"
|
||||
BASE = "e" * 40
|
||||
COMMIT = "1" * 40
|
||||
|
||||
|
||||
def make_store(tmp_path: Path) -> tuple[GitOpsJobController, str, dict]:
|
||||
store = JobStore(tmp_path / "job-root")
|
||||
controller = GitOpsJobController(store)
|
||||
request = {
|
||||
"idempotency_key": "v7-replay",
|
||||
"campaign_id": TASK,
|
||||
"task_id": TASK,
|
||||
"external_job_id": EXTERNAL,
|
||||
"repository": "aios/aios",
|
||||
"operation": "CONTINUATION_COMMIT_FILES",
|
||||
"branch": BRANCH,
|
||||
"target_branch": BRANCH,
|
||||
"branch_mode": "CONTINUE_EXISTING",
|
||||
"base_oid": BASE,
|
||||
"lineage_anchor_oid": BASE,
|
||||
"expected_branch_oid": BASE,
|
||||
"generation": 2,
|
||||
"payload_digest": "a" * 64,
|
||||
"files_digest": "b" * 64,
|
||||
"message_digest": "c" * 64,
|
||||
"worker_principal": "aios-autonomy-agent",
|
||||
"worker_lineage": "governed-continuation",
|
||||
}
|
||||
receipt = controller.submit(request)
|
||||
controller.advance(receipt.job_id, JobState.AUTHORIZED, side_effect_state="AUTHORIZED")
|
||||
row = store.read(receipt.job_id)
|
||||
controller.lease_job(
|
||||
receipt.job_id,
|
||||
lease_id="lease-v7-epoch-3",
|
||||
lineage_anchor_oid=BASE,
|
||||
current_head_oid=BASE,
|
||||
generation=2,
|
||||
lease_expires_at="2099-01-01T00:00:00Z",
|
||||
expected_revision=row["revision"],
|
||||
)
|
||||
with store._connect() as connection:
|
||||
connection.execute("ALTER TABLE branch_leases ADD COLUMN epoch INTEGER")
|
||||
connection.execute("UPDATE branch_leases SET epoch=3, state='USED' WHERE job_id=?", (receipt.job_id,))
|
||||
connection.execute("CREATE TABLE branch_lease_events (lease_id TEXT NOT NULL, state TEXT NOT NULL, current_head_oid TEXT NOT NULL, generation INTEGER NOT NULL)")
|
||||
connection.execute("INSERT INTO branch_lease_events VALUES (?, 'USED', ?, 2)", ("lease-v7-epoch-3", BASE))
|
||||
controller.advance(receipt.job_id, JobState.WORKTREE_READY, side_effect_state="WORKTREE_READY")
|
||||
controller.advance(receipt.job_id, JobState.EXECUTING, side_effect_state="IN_PROGRESS")
|
||||
completed = {
|
||||
"commit_oid": COMMIT,
|
||||
"authority_oid": COMMIT,
|
||||
"remote_verified": True,
|
||||
"worker_principal": request["worker_principal"],
|
||||
"worker_lineage": request["worker_lineage"],
|
||||
}
|
||||
controller.advance(receipt.job_id, JobState.REMOTE_VERIFIED, side_effect_state="REMOTE_VERIFIED", receipt=completed)
|
||||
final = controller.advance(receipt.job_id, JobState.COMPLETED, side_effect_state="REMOTE_VERIFIED", receipt=completed)
|
||||
row = store.read(receipt.job_id)
|
||||
envelope = {
|
||||
"task_id": TASK,
|
||||
"external_job_id": EXTERNAL,
|
||||
"internal_job_id": receipt.job_id,
|
||||
"repository": "aios/aios",
|
||||
"target_branch": BRANCH,
|
||||
"generation": 2,
|
||||
"branch_mode": "CONTINUE_EXISTING",
|
||||
"payload_digest": request["payload_digest"],
|
||||
"files_digest": request["files_digest"],
|
||||
"message_digest": request["message_digest"],
|
||||
"commit_oid": COMMIT,
|
||||
"authority_head_oid": COMMIT,
|
||||
"worker_principal": request["worker_principal"],
|
||||
"worker_lineage": request["worker_lineage"],
|
||||
"source_lease_epoch": 3,
|
||||
"historical_lease_head_oid": BASE,
|
||||
"durable_revision": row["revision"],
|
||||
"completion_identity": sha256_json({"job_id": receipt.job_id, "revision": row["revision"], "receipt": completed}),
|
||||
"reason": "COMPLETED_REPLAY_LEASE_EVIDENCE_RECONCILIATION",
|
||||
"provenance": {"source": "v7-test", "authority_readback": True},
|
||||
}
|
||||
assert final.state is JobState.COMPLETED
|
||||
return controller, receipt.job_id, envelope
|
||||
|
||||
|
||||
def test_completed_replay_is_append_only_and_exactly_idempotent(tmp_path: Path):
|
||||
controller, job_id, envelope = make_store(tmp_path)
|
||||
with controller.store._connect() as connection:
|
||||
before = dict(connection.execute("SELECT * FROM branch_leases WHERE job_id=?", (job_id,)).fetchone())
|
||||
before_events = [dict(row) for row in connection.execute("SELECT * FROM branch_lease_events WHERE lease_id=?", ("lease-v7-epoch-3",)).fetchall()]
|
||||
old_receipt = connection.execute("SELECT receipt_json FROM jobs WHERE job_id=?", (job_id,)).fetchone()[0]
|
||||
first = controller.store.append_completed_replay_reconciliation(job_id, envelope, authority_head_oid=COMMIT)
|
||||
second = controller.store.append_completed_replay_reconciliation(job_id, envelope, authority_head_oid=COMMIT)
|
||||
restarted = GitOpsJobController(JobStore(controller.store.root))
|
||||
third = restarted.store.append_completed_replay_reconciliation(job_id, envelope, authority_head_oid=COMMIT)
|
||||
assert first["reconciliation_id"] == second["reconciliation_id"] == third["reconciliation_id"]
|
||||
assert first["current_head_oid"] == COMMIT
|
||||
with controller.store._connect() as connection:
|
||||
after = dict(connection.execute("SELECT * FROM branch_leases WHERE job_id=?", (job_id,)).fetchone())
|
||||
after_events = [dict(row) for row in connection.execute("SELECT * FROM branch_lease_events WHERE lease_id=?", ("lease-v7-epoch-3",)).fetchall()]
|
||||
new_receipt = connection.execute("SELECT receipt_json FROM jobs WHERE job_id=?", (job_id,)).fetchone()[0]
|
||||
count = connection.execute("SELECT COUNT(*) FROM completed_replay_reconciliations WHERE job_id=?", (job_id,)).fetchone()[0]
|
||||
assert before == after
|
||||
assert before_events == after_events
|
||||
assert old_receipt == new_receipt
|
||||
assert count == 1
|
||||
with pytest.raises(sqlite3.IntegrityError, match="COMPLETED_REPLAY_APPEND_ONLY"):
|
||||
with controller.store._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE completed_replay_reconciliations SET reason='tampered' WHERE job_id=?",
|
||||
(job_id,),
|
||||
)
|
||||
with pytest.raises(sqlite3.IntegrityError, match="COMPLETED_REPLAY_APPEND_ONLY"):
|
||||
with controller.store._connect() as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM completed_replay_reconciliations WHERE job_id=?",
|
||||
(job_id,),
|
||||
)
|
||||
|
||||
|
||||
def test_completed_replay_authority_conflict_fails_closed(tmp_path: Path):
|
||||
controller, job_id, envelope = make_store(tmp_path)
|
||||
with pytest.raises(BindingConflict, match="AUTHORITY_CONFLICT"):
|
||||
controller.store.append_completed_replay_reconciliation(job_id, envelope, authority_head_oid="2" * 40)
|
||||
|
||||
|
||||
def test_completed_replay_binding_conflict_fails_closed(tmp_path: Path):
|
||||
controller, job_id, envelope = make_store(tmp_path)
|
||||
conflicting = {**envelope, "files_digest": "d" * 64}
|
||||
with pytest.raises(BindingConflict, match="BINDING_CONFLICT"):
|
||||
controller.store.append_completed_replay_reconciliation(job_id, conflicting, authority_head_oid=COMMIT)
|
||||
|
||||
|
||||
def test_identical_reconciliation_converges_under_contention(tmp_path: Path):
|
||||
controller, job_id, envelope = make_store(tmp_path)
|
||||
|
||||
def run(_: int) -> str:
|
||||
return controller.store.append_completed_replay_reconciliation(
|
||||
job_id, envelope, authority_head_oid=COMMIT
|
||||
)["reconciliation_id"]
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
values = list(pool.map(run, range(16)))
|
||||
assert set(values) == {values[0]}
|
||||
assert controller.store.completed_replay_evidence(job_id)["commit_oid"] == COMMIT
|
||||
|
||||
|
||||
|
||||
def test_public_job_projection_exposes_external_reconciled_head(tmp_path: Path):
|
||||
from aios_chatgpt_bridge_v2_server import GovernedRuntime
|
||||
|
||||
controller, job_id, envelope = make_store(tmp_path)
|
||||
reconciliation = controller.store.append_completed_replay_reconciliation(
|
||||
job_id, envelope, authority_head_oid=COMMIT
|
||||
)
|
||||
|
||||
class Authority:
|
||||
def read_ref(self, ref: str):
|
||||
assert ref == "refs/heads/" + BRANCH
|
||||
return COMMIT
|
||||
|
||||
runtime = object.__new__(GovernedRuntime)
|
||||
runtime.authority = Authority()
|
||||
runtime._controller_for_job = lambda _job_id: controller
|
||||
result = GovernedRuntime.job(runtime, {"job_id": job_id}, "get")
|
||||
assert result["job_id"] == EXTERNAL
|
||||
assert result["current_head_oid"] == COMMIT
|
||||
assert result["completed_replay_reconciliation_id"] == reconciliation["reconciliation_id"]
|
||||
assert result["receipt"]["commit_oid"] == result["receipt"]["authority_oid"] == COMMIT
|
||||
|
||||
|
||||
def test_writer_lease_admits_exact_completed_replay_without_new_lease(tmp_path: Path):
|
||||
from aios_chatgpt_bridge_v2_server import GovernedRuntime
|
||||
|
||||
controller, job_id, envelope = make_store(tmp_path)
|
||||
controller.store.append_completed_replay_reconciliation(
|
||||
job_id, envelope, authority_head_oid=COMMIT
|
||||
)
|
||||
|
||||
class Authority:
|
||||
def read_ref(self, ref: str):
|
||||
assert ref == "refs/heads/" + BRANCH
|
||||
return COMMIT
|
||||
|
||||
runtime = object.__new__(GovernedRuntime)
|
||||
runtime.config = SimpleNamespace(state_root=tmp_path)
|
||||
runtime.controllers = {"job": controller}
|
||||
runtime.authority = Authority()
|
||||
body = {
|
||||
"task_id": TASK,
|
||||
"job_id": EXTERNAL,
|
||||
"repository": "aios/aios",
|
||||
"branch_mode": "CONTINUE_EXISTING",
|
||||
"base_oid": BASE,
|
||||
"expected_branch_oid": BASE,
|
||||
"target_branch": BRANCH,
|
||||
"generation": 2,
|
||||
"lease_seconds": 900,
|
||||
}
|
||||
result = GovernedRuntime.acquire_lease(runtime, body)
|
||||
assert result["completed_replay"] is True
|
||||
assert result["lease_created"] is False
|
||||
assert result["expected_branch_oid"] == BASE
|
||||
assert result["current_head_oid"] == COMMIT
|
||||
assert result["completed_replay_reconciliation_id"].startswith("reconcile-")
|
||||
with controller.store._connect() as connection:
|
||||
assert connection.execute(
|
||||
"SELECT COUNT(*) FROM branch_leases WHERE job_id=?", (job_id,)
|
||||
).fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_conflicting_reconciliation_has_no_ambiguous_winner(tmp_path: Path):
|
||||
controller, job_id, envelope = make_store(tmp_path)
|
||||
conflicting = {**envelope, "reason": "CONFLICTING_PROVENANCE"}
|
||||
values = [envelope, conflicting] * 8
|
||||
|
||||
def run(candidate: dict) -> str:
|
||||
try:
|
||||
result = controller.store.append_completed_replay_reconciliation(
|
||||
job_id, candidate, authority_head_oid=COMMIT
|
||||
)
|
||||
return "ok:" + result["identity_digest"]
|
||||
except BindingConflict:
|
||||
return "conflict"
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
outcomes = list(pool.map(run, values))
|
||||
winners = {item for item in outcomes if item.startswith("ok:")}
|
||||
assert len(winners) == 1
|
||||
assert "conflict" in outcomes
|
||||
with controller.store._connect() as connection:
|
||||
assert connection.execute(
|
||||
"SELECT COUNT(*) FROM completed_replay_reconciliations WHERE job_id=?", (job_id,)
|
||||
).fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_reconciliation_rejects_head_alias_before_append(tmp_path: Path):
|
||||
controller, job_id, envelope = make_store(tmp_path)
|
||||
conflicting = {**envelope, "historical_lease_head_oid": COMMIT}
|
||||
with pytest.raises(BindingConflict, match="HISTORICAL_LEASE_CONFLICT"):
|
||||
controller.store.append_completed_replay_reconciliation(
|
||||
job_id, conflicting, authority_head_oid=COMMIT
|
||||
)
|
||||
with controller.store._connect() as connection:
|
||||
assert connection.execute(
|
||||
"SELECT COUNT(*) FROM completed_replay_reconciliations WHERE job_id=?",
|
||||
(job_id,),
|
||||
).fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_http_commit_allowlist_carries_replay_identity(tmp_path: Path):
|
||||
from aios_chatgpt_bridge_v2_server import BridgeHandler
|
||||
|
||||
handler = object.__new__(BridgeHandler)
|
||||
body = {
|
||||
"task_id": TASK,
|
||||
"job_id": EXTERNAL,
|
||||
"source_lease_epoch": 3,
|
||||
"repository": "aios/aios",
|
||||
"expected_branch_oid": BASE,
|
||||
"target_branch": BRANCH,
|
||||
"generation": 2,
|
||||
"idempotency_key": "v7-replay",
|
||||
"files": [{"path": "x.txt", "content_base64": "eA=="}],
|
||||
"message": "replay",
|
||||
}
|
||||
handler._allowed("commitFilesGoverned", body)
|
||||
|
||||
|
||||
def test_cold_append_requires_existing_database(tmp_path: Path):
|
||||
missing = tmp_path / "missing-job-root"
|
||||
with pytest.raises(JobError, match="JOB_ROOT_NOT_FOUND"):
|
||||
JobStore.for_existing_append(missing)
|
||||
assert not missing.exists()
|
||||
|
||||
|
||||
def test_real_http_post_carries_external_replay_identity(tmp_path: Path):
|
||||
from http.client import HTTPConnection
|
||||
from http.server import HTTPServer
|
||||
from threading import Thread
|
||||
from aios_chatgpt_bridge_v2_server import BridgeHandler
|
||||
|
||||
observed = {}
|
||||
class Adapter:
|
||||
def commitFilesGoverned(self, body):
|
||||
observed.update(body)
|
||||
return {"job_id": body["job_id"], "current_head_oid": COMMIT}
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), BridgeHandler)
|
||||
BridgeHandler.adapter = Adapter()
|
||||
thread = Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
body = {
|
||||
"task_id": TASK,
|
||||
"job_id": EXTERNAL,
|
||||
"source_lease_epoch": 3,
|
||||
"repository": "aios/aios",
|
||||
"expected_branch_oid": BASE,
|
||||
"target_branch": BRANCH,
|
||||
"generation": 2,
|
||||
"idempotency_key": "v7-replay",
|
||||
"files": [{"path": "x.txt", "content_base64": "eA=="}],
|
||||
"message": "replay",
|
||||
}
|
||||
try:
|
||||
connection = HTTPConnection("127.0.0.1", server.server_port, timeout=5)
|
||||
connection.request("POST", "/v2/commit-files", body=json.dumps(body), headers={"Content-Type": "application/json"})
|
||||
response = connection.getresponse()
|
||||
payload = json.loads(response.read().decode())
|
||||
connection.close()
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
BridgeHandler.adapter = None
|
||||
assert response.status == 200
|
||||
assert observed["job_id"] == EXTERNAL
|
||||
assert observed["source_lease_epoch"] == 3
|
||||
assert payload["job_id"] == EXTERNAL
|
||||
assert payload["current_head_oid"] == COMMIT
|
||||
@@ -1,157 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
|
||||
from aios_convergence_contracts import (
|
||||
AcceptanceCriterion,
|
||||
AcceptanceDecision,
|
||||
AcceptanceInvariant,
|
||||
AcceptanceVector,
|
||||
ContinuationManifest,
|
||||
ContinuationStopReason,
|
||||
ConvergenceContractError,
|
||||
DesiredState,
|
||||
EvidenceState,
|
||||
ObservedHealth,
|
||||
ObservedState,
|
||||
ReconciliationState,
|
||||
StateOwnership,
|
||||
evaluate_acceptance,
|
||||
ownership_index,
|
||||
plan_reconciliation,
|
||||
validate_state_ownership,
|
||||
)
|
||||
|
||||
|
||||
def test_acceptance_never_promotes_required_unknown():
|
||||
vector = AcceptanceVector(
|
||||
subject_type="goal",
|
||||
subject_id="goal-1",
|
||||
subject_revision=1,
|
||||
criteria=(
|
||||
AcceptanceCriterion(
|
||||
criterion_id="runtime",
|
||||
required=True,
|
||||
state=EvidenceState.UNKNOWN,
|
||||
verifier_id="runtime-proof",
|
||||
),
|
||||
),
|
||||
)
|
||||
result = evaluate_acceptance(vector)
|
||||
assert result.decision == AcceptanceDecision.UNKNOWN
|
||||
assert result.reasons == ("criterion_unknown:runtime",)
|
||||
|
||||
|
||||
def test_acceptance_rejects_failed_invariant():
|
||||
vector = AcceptanceVector(
|
||||
subject_type="release",
|
||||
subject_id="V2R2C2",
|
||||
subject_revision=1,
|
||||
criteria=(
|
||||
AcceptanceCriterion("tests", True, EvidenceState.PASS, "pytest"),
|
||||
),
|
||||
invariants=(
|
||||
AcceptanceInvariant("protected-ref", EvidenceState.FAIL),
|
||||
),
|
||||
)
|
||||
assert evaluate_acceptance(vector).decision == AcceptanceDecision.REJECT
|
||||
|
||||
|
||||
def test_acceptance_blocks_on_unresolved_blocker():
|
||||
vector = AcceptanceVector(
|
||||
subject_type="goal",
|
||||
subject_id="goal-1",
|
||||
subject_revision=1,
|
||||
criteria=(AcceptanceCriterion("code", True, EvidenceState.PASS, "ci"),),
|
||||
unresolved_blockers=("blocker-1",),
|
||||
)
|
||||
assert evaluate_acceptance(vector).decision == AcceptanceDecision.BLOCKED
|
||||
|
||||
|
||||
def test_state_ownership_is_unique_and_final_writer_is_enforced():
|
||||
custom = (
|
||||
StateOwnership(
|
||||
object_type="goal",
|
||||
authoritative_service="goal-kernel",
|
||||
authoritative_store="goal-store",
|
||||
final_writer="goal-kernel",
|
||||
),
|
||||
)
|
||||
validate_state_ownership("goal", "goal-kernel", custom, final_state=True)
|
||||
with pytest.raises(ConvergenceContractError, match="STATE_OWNERSHIP_VIOLATION"):
|
||||
validate_state_ownership("goal", "worker", custom, final_state=True)
|
||||
with pytest.raises(ConvergenceContractError, match="duplicate_state_owner"):
|
||||
ownership_index(custom + custom)
|
||||
|
||||
|
||||
def test_missing_or_unknown_observation_never_converges():
|
||||
desired = DesiredState("contract:gpt", "external_projection", 2, {"hash": "abc"})
|
||||
missing = plan_reconciliation(desired, None)
|
||||
assert missing.state == ReconciliationState.OBSERVING
|
||||
assert missing.observation_required is True
|
||||
|
||||
unknown = ObservedState(
|
||||
resource_id="contract:gpt",
|
||||
observed_generation=None,
|
||||
observed_spec={},
|
||||
health=ObservedHealth.UNKNOWN,
|
||||
source_identity="gpt-action",
|
||||
observed_at="2026-08-25T00:00:00Z",
|
||||
)
|
||||
result = plan_reconciliation(desired, unknown)
|
||||
assert result.state == ReconciliationState.OBSERVING
|
||||
|
||||
|
||||
def test_equal_healthy_observation_converges_and_key_is_stable():
|
||||
desired = DesiredState("worker:a", "worker_binding", 3, {"build": "x"})
|
||||
observed = ObservedState(
|
||||
resource_id="worker:a",
|
||||
observed_generation=3,
|
||||
observed_spec={"build": "x"},
|
||||
health=ObservedHealth.HEALTHY,
|
||||
source_identity="worker-registry",
|
||||
observed_at="2026-08-25T00:00:00Z",
|
||||
)
|
||||
one = plan_reconciliation(desired, observed)
|
||||
two = plan_reconciliation(desired, observed)
|
||||
assert one.state == ReconciliationState.CONVERGED
|
||||
assert one.idempotency_key == two.idempotency_key
|
||||
|
||||
|
||||
def test_drift_is_diff_found_not_auto_applied():
|
||||
desired = DesiredState("projection:openapi", "external_projection", 4, {"hash": "new"})
|
||||
observed = ObservedState(
|
||||
resource_id="projection:openapi",
|
||||
observed_generation=3,
|
||||
observed_spec={"hash": "old"},
|
||||
health=ObservedHealth.HEALTHY,
|
||||
source_identity="published-openapi",
|
||||
observed_at="2026-08-25T00:00:00Z",
|
||||
)
|
||||
result = plan_reconciliation(desired, observed)
|
||||
assert result.state == ReconciliationState.DIFF_FOUND
|
||||
assert result.observation_required is False
|
||||
|
||||
|
||||
def test_continuation_manifest_is_durable_and_hashable():
|
||||
manifest = ContinuationManifest(
|
||||
trace_id="trace-1",
|
||||
goal_id="goal-1",
|
||||
goal_revision=1,
|
||||
task_id="task-1",
|
||||
run_id="run-1",
|
||||
current_node_id="node-4",
|
||||
acceptance_vector_ref="acceptance:goal-1:1",
|
||||
next_action="resume node-4",
|
||||
stop_reason=ContinuationStopReason.CONTEXT_BOUNDARY,
|
||||
checkpoint_ref="checkpoint-9",
|
||||
unresolved_blocker_refs=("blocker-a",),
|
||||
)
|
||||
assert manifest.as_dict()["stop_reason"] == "context_boundary"
|
||||
assert manifest.content_hash.startswith("sha256:")
|
||||
@@ -0,0 +1,169 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
MODULE_PATH = (
|
||||
Path(__file__).parents[1]
|
||||
/ "active-runtime"
|
||||
/ "app"
|
||||
/ "governed_production_release.py"
|
||||
)
|
||||
|
||||
|
||||
def _load_module():
|
||||
dependency = types.ModuleType("gitea_tag_publication")
|
||||
for name in (
|
||||
"GiteaTagClient",
|
||||
"GitTagBackend",
|
||||
"PublicationManualOwnerReviewRequired",
|
||||
"PublicationPending",
|
||||
"PublicationReconciliationRequired",
|
||||
"PublicationRequest",
|
||||
"TagPublisher",
|
||||
):
|
||||
setattr(dependency, name, type(name, (), {}))
|
||||
sys.modules["gitea_tag_publication"] = dependency
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"governed_production_release_under_test", MODULE_PATH
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
release_module = _load_module()
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
status = 200
|
||||
|
||||
def __init__(self, body):
|
||||
self._payload = json.dumps(body).encode("utf-8")
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
def _health_body(binding, artifact_sha256):
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": binding.version,
|
||||
"commit_oid": binding.target_oid,
|
||||
"artifact_sha256": artifact_sha256,
|
||||
"deployment_state": "ACTIVE",
|
||||
"production_deployed": True,
|
||||
}
|
||||
|
||||
|
||||
def _run_acceptance(result):
|
||||
executor = object.__new__(release_module.GovernedProductionReleaseExecutor)
|
||||
records = []
|
||||
executor.state = SimpleNamespace(
|
||||
record=lambda *args: records.append(args),
|
||||
)
|
||||
executor.consistency = lambda request: deepcopy(result)
|
||||
return executor.acceptance({}), records
|
||||
|
||||
|
||||
def test_all_checks_true_preserves_health_status_and_accepts():
|
||||
binding = release_module.ReleaseBinding()
|
||||
artifact_sha256 = "a" * 64
|
||||
|
||||
with patch(
|
||||
"urllib.request.urlopen",
|
||||
return_value=FakeResponse(_health_body(binding, artifact_sha256)),
|
||||
):
|
||||
health = release_module._health(
|
||||
"https://example.test/health",
|
||||
binding,
|
||||
{"artifact_sha256": artifact_sha256},
|
||||
)
|
||||
|
||||
assert health["status"] == "ok"
|
||||
assert health["verification_status"] == "ACTIVE_VERIFIED"
|
||||
assert health["http_status"] == 200
|
||||
|
||||
result, records = _run_acceptance(
|
||||
{"status": "PASS", "production_health": health}
|
||||
)
|
||||
assert result["accepted"] is True
|
||||
assert records[-1][0] == "ACCEPTED"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value", "artifact_sha256"),
|
||||
[
|
||||
("commit_oid", "0" * 40, "a" * 64),
|
||||
("artifact_sha256", "b" * 64, "a" * 64),
|
||||
("deployment_state", "STAGING", "a" * 64),
|
||||
],
|
||||
ids=["commit-mismatch", "artifact-mismatch", "deployment-not-active"],
|
||||
)
|
||||
def test_health_conflicts_keep_external_status_and_reject(field, value, artifact_sha256):
|
||||
binding = release_module.ReleaseBinding()
|
||||
body = _health_body(binding, artifact_sha256)
|
||||
body[field] = value
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=FakeResponse(body)):
|
||||
health = release_module._health(
|
||||
"https://example.test/health",
|
||||
binding,
|
||||
{"artifact_sha256": artifact_sha256},
|
||||
)
|
||||
|
||||
assert health["status"] == "ok"
|
||||
assert health["verification_status"] == "CONFLICT"
|
||||
result, _ = _run_acceptance(
|
||||
{"status": "PASS", "production_health": health}
|
||||
)
|
||||
assert result["accepted"] is False
|
||||
|
||||
|
||||
def test_health_unavailable_has_internal_verdict_and_rejects():
|
||||
binding = release_module.ReleaseBinding()
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=TimeoutError("boom")):
|
||||
health = release_module._health(
|
||||
"https://example.test/health",
|
||||
binding,
|
||||
{"artifact_sha256": "a" * 64},
|
||||
)
|
||||
|
||||
assert health["status"] == "UNAVAILABLE"
|
||||
assert health["verification_status"] == "UNAVAILABLE"
|
||||
assert health["error"] == "TimeoutError"
|
||||
result, _ = _run_acceptance(
|
||||
{"status": "PASS", "production_health": health}
|
||||
)
|
||||
assert result["accepted"] is False
|
||||
|
||||
|
||||
def test_acceptance_rejects_when_overall_check_is_not_pass():
|
||||
result, records = _run_acceptance(
|
||||
{
|
||||
"status": "CONFLICT",
|
||||
"production_health": {
|
||||
"status": "ok",
|
||||
"verification_status": "ACTIVE_VERIFIED",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert result["accepted"] is False
|
||||
assert records[-1][0] == "RECONCILIATION_REQUIRED"
|
||||
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OVERLAY = ROOT / "active-runtime" / "app" / "aios_model_api_staging_list_patches_overlay.py"
|
||||
|
||||
|
||||
class StubHandler:
|
||||
def sendj(self, data, code=200):
|
||||
self.sent = (data, code)
|
||||
return self.sent
|
||||
|
||||
|
||||
def load_overlay() -> ModuleType:
|
||||
base = ModuleType("aios_model_api_staging")
|
||||
base.H = StubHandler
|
||||
base.main = lambda: None
|
||||
previous = sys.modules.get("aios_model_api_staging")
|
||||
sys.modules["aios_model_api_staging"] = base
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location("list_patches_overlay_test", OVERLAY)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
finally:
|
||||
if previous is None:
|
||||
sys.modules.pop("aios_model_api_staging", None)
|
||||
else:
|
||||
sys.modules["aios_model_api_staging"] = previous
|
||||
|
||||
|
||||
def test_overlay_keeps_legacy_keys_but_only_emits_bounded_summaries():
|
||||
overlay = load_overlay()
|
||||
records = [
|
||||
{
|
||||
"id": "oversized",
|
||||
"branch": "agent/oversized",
|
||||
"created_at": "2026-08-26T15:00:00Z",
|
||||
"text": "X" * 2_000_000,
|
||||
"body": "B" * 2_000_000,
|
||||
"diff": "D" * 2_000_000,
|
||||
"evidence": {"raw": "E" * 2_000_000},
|
||||
}
|
||||
] + [
|
||||
{"id": f"patch-{index:03d}", "created_at": f"2026-08-25T{index % 24:02d}:00:00Z", "description": "summary"}
|
||||
for index in range(1, 25)
|
||||
]
|
||||
handler = overlay.ListPatchesOverlayHandler.__new__(overlay.ListPatchesOverlayHandler)
|
||||
handler.path = "/api/patch"
|
||||
result = handler.sendj(
|
||||
{
|
||||
"metadata_patches": records,
|
||||
"patches": records,
|
||||
"authoritative_branches": [{"name": "agent/example", "oid": "a" * 40, "secret": "drop"}],
|
||||
"orphan_metadata": records,
|
||||
"branches_without_metadata": records,
|
||||
"branches": [f"agent/{index}" for index in range(40)],
|
||||
"repository_available": True,
|
||||
"query_error": None,
|
||||
}
|
||||
)
|
||||
payload, code = result
|
||||
assert code == 200
|
||||
assert payload["list_patches_overlay"] == "bounded-summary-v1"
|
||||
assert payload["count"] <= overlay.PATCH_DEFAULT_LIMIT
|
||||
assert payload["has_more"] is True
|
||||
assert payload["next_cursor"]
|
||||
encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
assert len(encoded) <= overlay.PATCH_RESPONSE_BUDGET
|
||||
assert all(set(item) <= {
|
||||
"patch_id", "name", "branch", "ref", "status", "risk", "created_at", "updated_at",
|
||||
"commit_oid", "summary", "summary_truncated",
|
||||
} for item in payload["items"])
|
||||
assert "body" not in json.dumps(payload, ensure_ascii=False)
|
||||
assert len(payload["branches"]) <= overlay.PATCH_COMPAT_LIST_LIMIT
|
||||
|
||||
|
||||
def test_overlay_cursor_pagination_and_invalid_limit_are_deterministic():
|
||||
overlay = load_overlay()
|
||||
records = [
|
||||
{"id": f"p-{index:02d}", "created_at": f"2026-08-26T{index:02d}:00:00Z"}
|
||||
for index in range(1, 26)
|
||||
]
|
||||
first = overlay._page(records, {"limit": ["1"]})
|
||||
second = overlay._page(records, {"limit": ["1"], "cursor": [first["next_cursor"]]})
|
||||
assert first["count"] == second["count"] == 1
|
||||
assert first["items"][0]["patch_id"] != second["items"][0]["patch_id"]
|
||||
assert first["has_more"] and second["has_more"]
|
||||
for value in ("0", "101", "not-an-int"):
|
||||
try:
|
||||
overlay._page(records, {"limit": [value]})
|
||||
except ValueError as exc:
|
||||
assert str(exc) == "invalid_limit"
|
||||
else:
|
||||
raise AssertionError("invalid limit accepted")
|
||||
try:
|
||||
overlay._page(records, {"limit": ["1"], "cursor": ["bad"]})
|
||||
except ValueError as exc:
|
||||
assert str(exc) == "invalid_cursor"
|
||||
else:
|
||||
raise AssertionError("invalid cursor accepted")
|
||||
|
||||
|
||||
def test_overlay_delegates_non_patch_routes_unchanged():
|
||||
overlay = load_overlay()
|
||||
handler = overlay.ListPatchesOverlayHandler.__new__(overlay.ListPatchesOverlayHandler)
|
||||
handler.path = "/api/health"
|
||||
result = handler.sendj({"status": "ok"}, 200)
|
||||
assert result == ({"status": "ok"}, 200)
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
|
||||
from aios_authority_git_executor import AuthorityGitError, AuthorityGitExecutor
|
||||
from aios_chatgpt_bridge_v2 import OPERATION_REQUIRED_FIELDS, operation_contracts
|
||||
from scoped_lease_store import ScopedLeaseStore, ScopedLeaseError
|
||||
|
||||
|
||||
def _fixture_repo(tmp_path: Path) -> tuple[Path, str]:
|
||||
bare = tmp_path / "repo.git"
|
||||
source = tmp_path / "source"
|
||||
subprocess.run(["git", "init", "--bare", str(bare)], check=True, capture_output=True)
|
||||
subprocess.run(["git", "init", str(source)], check=True, capture_output=True)
|
||||
(source / "README.md").write_text("fixture\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(source), "add", "README.md"], check=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(source), "-c", "user.name=test", "-c", "user.email=test@example.invalid", "commit", "-m", "fixture"],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
oid = subprocess.check_output(["git", "-C", str(source), "rev-parse", "HEAD"], text=True).strip()
|
||||
subprocess.run(["git", "-C", str(source), "push", str(bare), "HEAD:refs/heads/main"], check=True, capture_output=True)
|
||||
return bare, oid
|
||||
|
||||
|
||||
def test_read_repository_file_is_bounded_and_ref_scoped(tmp_path):
|
||||
bare, oid = _fixture_repo(tmp_path)
|
||||
executor = AuthorityGitExecutor(bare, tmp_path / "worktrees")
|
||||
result = executor.read_file("main", "README.md", 1024)
|
||||
assert result["resolved_commit_oid"] == oid
|
||||
assert base64.b64decode(result["content_base64"]) == b"fixture\n"
|
||||
assert result["read_only"] is True
|
||||
with pytest.raises(AuthorityGitError, match="SOURCE_PATH_INVALID"):
|
||||
executor.read_file("main", "../README.md", 1024)
|
||||
with pytest.raises(AuthorityGitError, match="SOURCE_FILE_TOO_LARGE"):
|
||||
executor.read_file("main", "README.md", 1)
|
||||
with pytest.raises(AuthorityGitError, match="SOURCE_REF_SCOPE_DENIED"):
|
||||
executor.read_file("refs/remotes/origin/main", "README.md", 1024)
|
||||
|
||||
|
||||
def test_runtime_and_published_contract_keep_read_and_commit_fields_explicit():
|
||||
contracts = operation_contracts()
|
||||
assert set(OPERATION_REQUIRED_FIELDS["readRepositoryFile"]) == {"repository", "ref", "path", "max_bytes"}
|
||||
assert contracts["commitFilesGoverned"]["required_fields"] == list(OPERATION_REQUIRED_FIELDS["commitFilesGoverned"])
|
||||
assert "files" in contracts["commitFilesGoverned"]["required_fields"]
|
||||
|
||||
|
||||
def test_scoped_lease_is_idempotent_and_rejects_cross_binding(tmp_path):
|
||||
store = ScopedLeaseStore(tmp_path / "leases.sqlite3")
|
||||
request = {
|
||||
"schema": "aios.task-writer-lease.v2",
|
||||
"task_id": "task-min-loop",
|
||||
"repository": "aios/aios",
|
||||
"branch_mode": "CONTINUE_EXISTING",
|
||||
"base_oid": "a" * 40,
|
||||
"expected_branch_oid": "a" * 40,
|
||||
"target_branch": "agent/min-loop",
|
||||
"generation": 1,
|
||||
"lease_seconds": 60,
|
||||
}
|
||||
first, created = store.acquire(request)
|
||||
assert created is True and first["state"] == "ACTIVE"
|
||||
replay, created = store.acquire(request)
|
||||
assert created is False and replay["lease_key"] == first["lease_key"]
|
||||
conflict = dict(request, expected_branch_oid="b" * 40)
|
||||
with pytest.raises(ScopedLeaseError, match="LEASE_BINDING_CONFLICT"):
|
||||
store.acquire(conflict)
|
||||
|
||||
@@ -1,506 +0,0 @@
|
||||
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": "AIOS_TRUSTED_RELEASE_BUILDER",
|
||||
"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="AIOS_TRUSTED_RELEASE_BUILDER",
|
||||
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="AIOS_TRUSTED_RELEASE_BUILDER")),
|
||||
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="AIOS_TRUSTED_RELEASE_BUILDER")
|
||||
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="AIOS_TRUSTED_RELEASE_BUILDER")
|
||||
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="AIOS_TRUSTED_RELEASE_BUILDER")),
|
||||
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 = "rc-v2r2c2-d2b5a8339a34"
|
||||
source_commit = "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6"
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def test_host_adapter_accepts_intent_only_without_caller_candidate_id():
|
||||
api = importlib.import_module('aios_model_api_staging')
|
||||
calls = []
|
||||
|
||||
class IntentClient:
|
||||
def build(self, version, candidate_id, source_commit, *, nonce):
|
||||
calls.append((version, candidate_id, source_commit, nonce))
|
||||
raise RuntimeError('intent-only probe')
|
||||
|
||||
with pytest.raises(RuntimeError, match='intent-only probe'):
|
||||
api._HostTrustedCandidateBuilderAdapter(IntentClient()).build({
|
||||
'event_id': 'event-intent-only',
|
||||
'intent_id': 'intent-intent-only',
|
||||
'source_commit': COMMIT,
|
||||
'version': 'V2R2C2',
|
||||
})
|
||||
assert calls[0][0] == 'V2R2C2'
|
||||
assert calls[0][1] == ''
|
||||
assert calls[0][2] == COMMIT
|
||||
assert len(calls[0][3]) >= 32
|
||||
@@ -1,46 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
FIXTURE_SOURCE = '"""x"""\nimport re\nfrom aios_online_agent_execution import (\n GovernedExecutionBinding,\n validate_execution_envelope,\n)\n\nclass InitialExecutionProjectionHandler:\n store = None\n def _authorized(self):\n return True\n def _json(self, value, status):\n pass\n def do_GET(self):\n if self.path == "/health":\n self._json(\n {"status": "ok", "service": "aios-agent-task-initial-execution-projection"},\n 200,\n )\n else:\n self._json({"error": "NOT_FOUND"}, 404)\n\n def do_POST(self):\n initial_match = re.fullmatch(\n r"/v1/tasks/([^/]+)/initial-execution-projection", self.path\n )\n recovery_match = re.fullmatch(\n r"/v1/tasks/([^/]+)/source-capture-projection-recovery", self.path\n )\n if initial_match is None and recovery_match is None:\n self._json({"error": "NOT_FOUND"}, 404)\n return\n if not self._authorized():\n self._json({"error": "UNAUTHORIZED"}, 401)\n return\n try:\n value = {}\n if self.store is None:\n raise RuntimeError("RUNTIME_UNAVAILABLE")\n if initial_match is not None:\n request = InitialExecutionProjectionRequest.from_mapping(value)\n result = self.store.project(initial_match.group(1), request)\n else:\n recovery_request = SourceCaptureProjectionRecoveryRequest.from_mapping(value)\n result = self.store.project_from_source_capture(\n recovery_match.group(1), recovery_request\n )\n self._json(result, 200 if result["idempotent"] else 201)\n except Exception:\n pass\n'
|
||||
|
||||
|
||||
def test_patcher_is_strict_and_idempotent(tmp_path):
|
||||
source = tmp_path / "aios_agent_task_initial_execution_projection.py"
|
||||
source.write_text(FIXTURE_SOURCE, encoding="utf-8")
|
||||
patcher = Path(__file__).resolve().parents[1] / "tools" / "integrate_projection_recovery.py"
|
||||
|
||||
first = subprocess.run(
|
||||
[sys.executable, str(patcher), str(source)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert first.returncode == 0, first.stderr
|
||||
assert first.stdout.strip() == "CHANGED"
|
||||
text = source.read_text()
|
||||
assert "/canonical-projection-recovery" in text
|
||||
assert "/canonical-projection" in text
|
||||
assert "recover_canonical_projection" in text
|
||||
|
||||
second = subprocess.run(
|
||||
[sys.executable, str(patcher), str(source)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert second.returncode == 0
|
||||
assert second.stdout.strip() == "ALREADY_APPLIED"
|
||||
|
||||
|
||||
def test_patcher_fails_closed_on_unexpected_source(tmp_path):
|
||||
source = tmp_path / "wrong.py"
|
||||
source.write_text("print('different')\n")
|
||||
patcher = Path(__file__).resolve().parents[1] / "tools" / "integrate_projection_recovery.py"
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(patcher), str(source)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 2
|
||||
assert "ANCHOR_COUNT" in result.stderr
|
||||
@@ -1,32 +0,0 @@
|
||||
from pathlib import Path
|
||||
import ast
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_r7_amendment_module_is_valid_and_fail_closed():
|
||||
text = (
|
||||
ROOT
|
||||
/ "active-runtime/app/aios_task_deterministic_execution_amendment.py"
|
||||
).read_text()
|
||||
ast.parse(text)
|
||||
assert "validate_execution_envelope" in text
|
||||
assert "SIGNED_EXECUTION_AMENDMENT_REQUIRED" in text
|
||||
assert "BEFORE UPDATE OF status ON tasks" in text
|
||||
assert "APPLY_STATES" in text
|
||||
assert '"FAILED"' in text and '"QUEUED"' in text
|
||||
assert "AMENDMENT_EXECUTION_OPERATIONS_REQUIRED" in text
|
||||
assert "AMENDMENT_PROTECTED_WRITE_FORBIDDEN" in text
|
||||
|
||||
|
||||
def test_bridge_unit_keeps_strict_sandbox_and_required_projection():
|
||||
text = (
|
||||
ROOT
|
||||
/ "deploy/systemd/aios-chatgpt-bridge-v2-staging.service"
|
||||
).read_text()
|
||||
assert "ProtectSystem=strict" in text
|
||||
assert "ProtectHome=true" in text
|
||||
assert (
|
||||
"LoadCredential=gitea_token:"
|
||||
) in text
|
||||
assert "/data/aios-gitops/repo.git/worktrees" in text
|
||||
@@ -110,8 +110,6 @@ class SignerContractCompatibilityTests(unittest.TestCase):
|
||||
signer = "/usr/local/libexec/aios-grant-signer-adapter"
|
||||
if not Path(signer).is_file():
|
||||
self.skipTest("deployed signer is not present")
|
||||
if os.geteuid() != 0:
|
||||
self.skipTest("deployed signer self-test requires root")
|
||||
help_result = subprocess.run([signer, "sign", "--help"], capture_output=True, text=True, check=False)
|
||||
self.assertEqual(help_result.returncode, 0)
|
||||
self.assertTrue(deployed_signer_help(help_result.stdout, help_result.stderr))
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
APP = Path(__file__).resolve().parents[1] / "active-runtime" / "app"
|
||||
sys.path.insert(0, str(APP))
|
||||
import trusted_builder_signer_provisioning as provisioning # noqa: E402
|
||||
|
||||
|
||||
def _source(tmp_path: Path) -> tuple[Path, list[str]]:
|
||||
root = tmp_path / "source"
|
||||
for relative in provisioning.SOURCE_EVIDENCE_PATHS:
|
||||
path = root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if relative != provisioning.PACKAGE_MANIFEST_PATH:
|
||||
path.write_text("source:" + relative + "\n", encoding="utf-8")
|
||||
os.chmod(path, 0o644)
|
||||
manifest = root / provisioning.PACKAGE_MANIFEST_PATH
|
||||
manifest.write_bytes(provisioning.pretty_json(provisioning.package_contract_document()))
|
||||
os.chmod(manifest, 0o644)
|
||||
return root, list(provisioning.SOURCE_EVIDENCE_PATHS)
|
||||
|
||||
|
||||
def _payload(tmp_path: Path):
|
||||
source, paths = _source(tmp_path)
|
||||
output = tmp_path / "payload.json"
|
||||
value = provisioning.build_payload(
|
||||
source, output, main_oid="c" * 40, tree_oid="d" * 40,
|
||||
pr_number=70, pr_head_oid="e" * 40,
|
||||
merge_parent_oids=["a" * 40, "e" * 40], source_files=paths,
|
||||
nonce="test-provisioning-nonce-001",
|
||||
)
|
||||
return source, output, value
|
||||
|
||||
|
||||
def test_fresh_payload_is_bound_to_final_common_main_and_ordered_pr_parent(tmp_path: Path):
|
||||
_source_root, output, payload = _payload(tmp_path)
|
||||
assert output.read_bytes().startswith(b"{")
|
||||
provisioning._validate_payload(payload)
|
||||
assert payload["source"]["gitea_main_oid"] == payload["source"]["governed_main_oid"]
|
||||
assert payload["source"]["merge_parent_oids"][1] == payload["source"]["pr_head_oid"]
|
||||
assert set(payload["source"]["source_evidence_sha256"]) == set(provisioning.SOURCE_EVIDENCE_PATHS)
|
||||
assert set(payload["allowed_capabilities"]) == provisioning.ALLOWED_CAPABILITIES
|
||||
assert set(payload["denied_capabilities"]) == provisioning.DENIED_CAPABILITIES
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["allowed_capabilities", "denied_capabilities"])
|
||||
def test_payload_rejects_capability_scope_tampering(tmp_path: Path, field: str):
|
||||
_source_root, _output, payload = _payload(tmp_path)
|
||||
payload[field] = ["owner_release_approval"]
|
||||
with pytest.raises(provisioning.ProvisioningError, match="CAPABILITY_SCOPE_INVALID"):
|
||||
provisioning._validate_payload(payload)
|
||||
|
||||
|
||||
def test_payload_rejects_main_divergence_and_wrong_second_parent(tmp_path: Path):
|
||||
_source_root, _output, payload = _payload(tmp_path)
|
||||
payload["source"]["governed_main_oid"] = "f" * 40
|
||||
with pytest.raises(provisioning.ProvisioningError, match="SOURCE_MAIN_NOT_CONVERGED"):
|
||||
provisioning._validate_payload(payload)
|
||||
_source_root, _output, payload = _payload(tmp_path / "second")
|
||||
payload["source"]["merge_parent_oids"][1] = "a" * 40
|
||||
with pytest.raises(provisioning.ProvisioningError, match="SOURCE_PR_HEAD_SECOND_PARENT_INVALID"):
|
||||
provisioning._validate_payload(payload)
|
||||
|
||||
|
||||
def test_payload_rejects_missing_or_extra_source_evidence(tmp_path: Path):
|
||||
_source_root, _output, payload = _payload(tmp_path)
|
||||
payload["source"]["source_evidence_sha256"].pop(next(iter(payload["source"]["source_evidence_sha256"])))
|
||||
with pytest.raises(provisioning.ProvisioningError, match="SOURCE_EVIDENCE_SET_INVALID"):
|
||||
provisioning._validate_payload(payload)
|
||||
_source_root, _output, payload = _payload(tmp_path / "second")
|
||||
payload["source"]["source_evidence_sha256"]["extra.txt"] = "0" * 64
|
||||
with pytest.raises(provisioning.ProvisioningError, match="SOURCE_EVIDENCE_SET_INVALID"):
|
||||
provisioning._validate_payload(payload)
|
||||
|
||||
|
||||
def test_package_contract_rejects_incompatible_source_mode_and_tampering(tmp_path: Path):
|
||||
source, _paths = _source(tmp_path)
|
||||
service = source / "deploy/systemd/aios-trusted-candidate-builder.service"
|
||||
os.chmod(service, 0o601)
|
||||
with pytest.raises(provisioning.ProvisioningError, match="SOURCE_EVIDENCE_MODE_INVALID"):
|
||||
provisioning.verify_package_contract(source)
|
||||
os.chmod(service, 0o644)
|
||||
manifest = source / provisioning.PACKAGE_MANIFEST_PATH
|
||||
value = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
value["activation"]["credential_installation"] = True
|
||||
manifest.write_text(json.dumps(value), encoding="utf-8")
|
||||
with pytest.raises(provisioning.ProvisioningError, match="PACKAGE_CONTRACT_INVALID"):
|
||||
provisioning.verify_package_contract(source)
|
||||
|
||||
|
||||
def test_package_contract_declares_socket_activation_no_credentials_and_0755_launcher(tmp_path: Path):
|
||||
source, _paths = _source(tmp_path)
|
||||
provisioning.verify_package_contract(source)
|
||||
value = json.loads((source / provisioning.PACKAGE_MANIFEST_PATH).read_text(encoding="utf-8"))
|
||||
launcher = next(item for item in value["files"] if item["source_path"] == "deploy/libexec/aios-trusted-builder-signer-provisioner")
|
||||
assert launcher["install_mode"] == "0755"
|
||||
assert value["activation"] == {
|
||||
"daemon_reload": True,
|
||||
"enable_units": ["aios-trusted-candidate-builder.socket"],
|
||||
"service_start": "socket-activation-only",
|
||||
"credential_installation": False,
|
||||
"signer_invocation": False,
|
||||
}
|
||||
|
||||
|
||||
def test_provisioner_self_test_has_no_mutation(capsys):
|
||||
assert provisioning.main(["--self-test"]) == 0
|
||||
assert capsys.readouterr().out.strip() == "PASS"
|
||||
|
||||
|
||||
def _replay_fixture(tmp_path: Path):
|
||||
_source_root, payload_path, payload = _payload(tmp_path)
|
||||
raw = payload_path.read_bytes()
|
||||
signature = tmp_path / "owner.sig"
|
||||
signature.write_bytes(b"owner-signature-test")
|
||||
os.chmod(signature, 0o600)
|
||||
|
||||
config = provisioning.ProvisionerConfig()
|
||||
config.credential_store = tmp_path / "credstore"
|
||||
config.public_keyring = tmp_path / "state" / "public-keyring.json"
|
||||
config.binding_file = tmp_path / "state" / "service-principal-binding.json"
|
||||
config.receipt_log = tmp_path / "state" / "provisioning-receipts.jsonl"
|
||||
config.credential_store.mkdir(mode=0o700)
|
||||
os.chmod(config.credential_store, 0o700)
|
||||
config.public_keyring.parent.mkdir(mode=0o700)
|
||||
|
||||
public = b"p" * 32
|
||||
now = provisioning.utc_now()
|
||||
keyring = provisioning._keyring(payload["signing_identity"], public, now)
|
||||
config.public_keyring.write_bytes(provisioning.pretty_json(keyring))
|
||||
os.chmod(config.public_keyring, 0o600)
|
||||
binding = {
|
||||
"schema": "aios.trusted-builder.service-principal-binding.v1",
|
||||
"purpose": provisioning.PURPOSE,
|
||||
"service_principal": payload["signing_identity"]["service_principal"],
|
||||
"key_id": payload["signing_identity"]["key_id"],
|
||||
"generation": payload["signing_identity"]["key_generation"],
|
||||
"algorithm": "Ed25519",
|
||||
"private_key_export": False,
|
||||
"public_key_sha256": provisioning.sha256_bytes(public),
|
||||
"source_commit": payload["source"]["gitea_main_oid"],
|
||||
"source_tree": payload["source"]["gitea_main_tree_oid"],
|
||||
"payload_sha256": provisioning.sha256_bytes(raw),
|
||||
}
|
||||
config.binding_file.write_bytes(provisioning.pretty_json(binding))
|
||||
os.chmod(config.binding_file, 0o600)
|
||||
for name in ("signing_private_key", "builder_hmac"):
|
||||
target = config.credential_store / name
|
||||
target.write_bytes(("encrypted-" + name).encode())
|
||||
os.chmod(target, 0o600)
|
||||
signature_bytes = signature.read_bytes()
|
||||
receipt = {
|
||||
"schema": "aios.trusted-builder-signer-provisioning-receipt.v1",
|
||||
"status": "COMPLETED",
|
||||
"created_at": provisioning.iso(now),
|
||||
"operation_key": "payload:" + provisioning.sha256_bytes(raw) + ":signature:" + provisioning.sha256_bytes(signature_bytes),
|
||||
"payload_sha256": provisioning.sha256_bytes(raw),
|
||||
"signature_sha256": provisioning.sha256_bytes(signature_bytes),
|
||||
"source_commit": payload["source"]["gitea_main_oid"],
|
||||
"source_tree": payload["source"]["gitea_main_tree_oid"],
|
||||
"key_id": payload["signing_identity"]["key_id"],
|
||||
"generation": payload["signing_identity"]["key_generation"],
|
||||
"algorithm": "Ed25519",
|
||||
"purpose": provisioning.PURPOSE,
|
||||
"service_principal": payload["signing_identity"]["service_principal"],
|
||||
"service_principal_bound": True,
|
||||
"public_key_sha256": provisioning.sha256_bytes(public),
|
||||
"private_key_exportable": False,
|
||||
"credential_projection": "systemd-encrypted-host-credential",
|
||||
"negative_capability_tests": {name: True for name in sorted(provisioning.DENIED_CAPABILITIES)},
|
||||
"production_mutation": False,
|
||||
"protected_ref_mutation": False,
|
||||
"tag_mutation": False,
|
||||
"baseline_mutation": False,
|
||||
}
|
||||
return config, payload_path, payload, signature, receipt
|
||||
|
||||
|
||||
def test_legacy_multiline_receipt_parse(tmp_path: Path):
|
||||
path = tmp_path / "receipts.jsonl"
|
||||
receipt = {"schema": "legacy", "status": "COMPLETED", "nested": {"value": True}}
|
||||
path.write_bytes(provisioning.pretty_json(receipt))
|
||||
assert provisioning._receipts(path) == [receipt]
|
||||
|
||||
|
||||
def test_new_canonical_jsonl_receipt_parse(tmp_path: Path):
|
||||
path = tmp_path / "receipts.jsonl"
|
||||
rows = [{"id": 1}, {"id": 2}]
|
||||
for row in rows:
|
||||
provisioning._append(path, row)
|
||||
assert path.read_bytes() == b"".join(provisioning.canonical_json(row) + b"\n" for row in rows)
|
||||
assert provisioning._receipts(path) == rows
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content",
|
||||
[b'{"status":"COMPLETED"}\nnot-json\n', b'{"status":"COMPLETED"'],
|
||||
ids=["malformed", "truncated"],
|
||||
)
|
||||
def test_malformed_or_truncated_receipt_fails_closed(tmp_path: Path, content: bytes):
|
||||
path = tmp_path / "receipts.jsonl"
|
||||
path.write_bytes(content)
|
||||
with pytest.raises(provisioning.ProvisioningError, match="RECEIPT_LOG_INVALID"):
|
||||
provisioning._receipts(path)
|
||||
|
||||
|
||||
def test_preexisting_valid_state_is_adopted_without_mutation(tmp_path: Path, monkeypatch):
|
||||
config, payload_path, _payload_value, signature, receipt = _replay_fixture(tmp_path)
|
||||
config.receipt_log.write_bytes(provisioning.pretty_json(receipt))
|
||||
os.chmod(config.receipt_log, 0o600)
|
||||
before = {
|
||||
path: path.read_bytes()
|
||||
for path in (
|
||||
config.receipt_log, config.public_keyring, config.binding_file,
|
||||
config.credential_store / "signing_private_key",
|
||||
config.credential_store / "builder_hmac",
|
||||
)
|
||||
}
|
||||
monkeypatch.setattr(provisioning, "_run_signature_verify", lambda *_args: None)
|
||||
monkeypatch.setattr(provisioning, "_verify_replay_source", lambda *_args: None)
|
||||
monkeypatch.setattr(provisioning.os, "geteuid", lambda: 0)
|
||||
result = provisioning.provision(config, payload_path, signature)
|
||||
assert result["status"] == "IDEMPOTENT_REPLAY_PASS"
|
||||
assert result["mutation_executed"] is False
|
||||
assert result["existing_signer_state_adopted"] is True
|
||||
assert result["private_key_exportable"] is False
|
||||
assert before == {path: path.read_bytes() for path in before}
|
||||
|
||||
|
||||
def test_duplicate_replay_is_idempotent(tmp_path: Path, monkeypatch):
|
||||
config, payload_path, _payload_value, signature, receipt = _replay_fixture(tmp_path)
|
||||
config.receipt_log.write_bytes(provisioning.pretty_json(receipt))
|
||||
os.chmod(config.receipt_log, 0o600)
|
||||
monkeypatch.setattr(provisioning, "_run_signature_verify", lambda *_args: None)
|
||||
monkeypatch.setattr(provisioning, "_verify_replay_source", lambda *_args: None)
|
||||
monkeypatch.setattr(provisioning.os, "geteuid", lambda: 0)
|
||||
first = provisioning.provision(config, payload_path, signature)
|
||||
receipt_bytes = config.receipt_log.read_bytes()
|
||||
second = provisioning.provision(config, payload_path, signature)
|
||||
assert first == second
|
||||
assert config.receipt_log.read_bytes() == receipt_bytes
|
||||
|
||||
|
||||
def test_wrong_payload_is_rejected_against_preexisting_state(tmp_path: Path, monkeypatch):
|
||||
config, payload_path, payload, signature, receipt = _replay_fixture(tmp_path)
|
||||
config.receipt_log.write_bytes(provisioning.pretty_json(receipt))
|
||||
os.chmod(config.receipt_log, 0o600)
|
||||
payload["nonce"] = "wrong-payload-nonce-0002"
|
||||
payload_path.write_bytes(provisioning.pretty_json(payload))
|
||||
monkeypatch.setattr(provisioning, "_run_signature_verify", lambda *_args: None)
|
||||
monkeypatch.setattr(provisioning, "_verify_source", lambda *_args: None)
|
||||
monkeypatch.setattr(provisioning.os, "geteuid", lambda: 0)
|
||||
with pytest.raises(provisioning.ProvisioningError, match="PREEXISTING_STATE_CONFLICT"):
|
||||
provisioning.provision(config, payload_path, signature)
|
||||
|
||||
|
||||
def test_wrong_signature_is_rejected(tmp_path: Path, monkeypatch):
|
||||
config, payload_path, _payload_value, signature, receipt = _replay_fixture(tmp_path)
|
||||
config.receipt_log.write_bytes(provisioning.pretty_json(receipt))
|
||||
monkeypatch.setattr(
|
||||
provisioning,
|
||||
"_run_signature_verify",
|
||||
lambda *_args: (_ for _ in ()).throw(provisioning.ProvisioningError("OWNER_SIGNATURE_VERIFY_FAILED")),
|
||||
)
|
||||
monkeypatch.setattr(provisioning.os, "geteuid", lambda: 0)
|
||||
with pytest.raises(provisioning.ProvisioningError, match="OWNER_SIGNATURE_VERIFY_FAILED"):
|
||||
provisioning.provision(config, payload_path, signature)
|
||||
|
||||
|
||||
def test_wrong_purpose_is_rejected(tmp_path: Path):
|
||||
_source_root, _payload_path, payload = _payload(tmp_path)
|
||||
payload["signing_identity"]["purpose"] = "WRONG_PURPOSE"
|
||||
with pytest.raises(provisioning.ProvisioningError, match="SIGNING_IDENTITY_INVALID"):
|
||||
provisioning._validate_payload(payload)
|
||||
|
||||
|
||||
def test_replay_rejects_state_binding_tampering(tmp_path: Path, monkeypatch):
|
||||
config, payload_path, _payload_value, signature, receipt = _replay_fixture(tmp_path)
|
||||
receipt["service_principal"] = "wrong.service"
|
||||
config.receipt_log.write_bytes(provisioning.pretty_json(receipt))
|
||||
monkeypatch.setattr(provisioning, "_run_signature_verify", lambda *_args: None)
|
||||
monkeypatch.setattr(provisioning, "_verify_replay_source", lambda *_args: None)
|
||||
monkeypatch.setattr(provisioning.os, "geteuid", lambda: 0)
|
||||
with pytest.raises(provisioning.ProvisioningError, match="RECEIPT_CONFLICT"):
|
||||
provisioning.provision(config, payload_path, signature)
|
||||
@@ -1,592 +0,0 @@
|
||||
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-v2r2c1-staging.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", "rc-v2r2c2-d2b5a8339a34", "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6", 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"] == "v12-durable-trusted-release-controller-activation-20260829"
|
||||
assert result["silent_release_bundle"]["candidate"]["candidate_id"] == "rc-v2r2c2-d2b5a8339a34"
|
||||
assert result["base_oid"] == "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6"
|
||||
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] == "AIOS_TRUSTED_RELEASE_BUILDER"
|
||||
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", "rc-v2r2c2-d2b5a8339a34", "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6", 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",
|
||||
"rc-v2r2c2-d2b5a8339a34",
|
||||
"d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6",
|
||||
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",
|
||||
"rc-v2r2c2-d2b5a8339a34",
|
||||
"d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6",
|
||||
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", "rc-v2r2c2-d2b5a8339a34", "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6", nonce="c" * 32)
|
||||
first = server.handle_request(request, connection=object())
|
||||
assert server.handle_request(request, connection=object()) == first
|
||||
|
||||
conflict = client.create_request("V2R2C2", "rc-v2r2c2-d2b5a8339a34", "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6", 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",
|
||||
"rc-v2r2c2-d2b5a8339a34",
|
||||
"d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6",
|
||||
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",
|
||||
"rc-v2r2c2-d2b5a8339a34",
|
||||
"d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6",
|
||||
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",
|
||||
"rc-v2r2c2-d2b5a8339a34",
|
||||
"d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6",
|
||||
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",
|
||||
"rc-v2r2c2-d2b5a8339a34",
|
||||
"d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6",
|
||||
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 == "AIOS_TRUSTED_RELEASE_BUILDER"
|
||||
return private.sign(payload)
|
||||
|
||||
server, client, _ = make_seam(signer_override=Ed25519Signer())
|
||||
response = server.handle_request(
|
||||
client.create_request(
|
||||
"V2R2C2",
|
||||
"rc-v2r2c2-d2b5a8339a34",
|
||||
"d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6",
|
||||
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="AIOS_TRUSTED_RELEASE_BUILDER",
|
||||
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", "rc-v2r2c2-d2b5a8339a34", "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6", 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", "rc-v2r2c2-d2b5a8339a34", "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6", 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", "rc-v2r2c2-d2b5a8339a34", "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6", 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", "rc-v2r2c2-d2b5a8339a34", "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6", 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", "rc-v2r2c2-d2b5a8339a34", "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6", 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("trusted-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"),
|
||||
("trusted-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-v2r2c1-staging.service"
|
||||
]
|
||||
@@ -1,60 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "active-runtime" / "app"))
|
||||
from governed_production_release import ReleaseBlocked, TrustedPlanReleaseExecutor, build_executor_config
|
||||
from trusted_production_adapter import AdapterBlocked, _binding
|
||||
|
||||
|
||||
def config(tmp_path: Path, version: str = "V2R2C2") -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
state_root=tmp_path,
|
||||
target_version=version,
|
||||
release_plan_path=None,
|
||||
authoritative_git_dir=tmp_path,
|
||||
baseline_path=tmp_path / "baseline.json",
|
||||
registry_root=tmp_path / "registry",
|
||||
api_base="http://127.0.0.1:3000/api/v1",
|
||||
token_path=tmp_path / "token",
|
||||
remote_name="gitea-http",
|
||||
)
|
||||
|
||||
|
||||
def test_non_legacy_runtime_selects_trusted_plan_handler(tmp_path: Path):
|
||||
handler = build_executor_config(config(tmp_path))
|
||||
assert isinstance(handler, TrustedPlanReleaseExecutor)
|
||||
|
||||
|
||||
def test_trusted_handler_requires_server_side_plan(tmp_path: Path):
|
||||
handler = build_executor_config(config(tmp_path))
|
||||
with pytest.raises(ReleaseBlocked, match="TRUSTED_RELEASE_PLAN_REQUIRED"):
|
||||
handler.consistency({"version": "V2R2C2"})
|
||||
|
||||
|
||||
def test_legacy_runtime_keeps_compatibility_path(tmp_path: Path):
|
||||
handler = build_executor_config(config(tmp_path, "V2R2C1"))
|
||||
assert handler.__class__.__name__ == "GovernedProductionReleaseExecutor"
|
||||
|
||||
|
||||
def test_generic_adapter_requires_candidate_and_plan_binding():
|
||||
value = {
|
||||
"version": "V2R2C2",
|
||||
"target_oid": "1" * 40,
|
||||
"artifact_sha256": "2" * 64,
|
||||
"artifact_path": "/opt/aios/releases/V2R2C2.tar",
|
||||
"artifact_size": 1,
|
||||
"production_service": "aios-v2r2c2-production.service",
|
||||
"production_port": 18928,
|
||||
"candidate_id": "rc-v2r2c2-001",
|
||||
"release_plan_sha256": "sha256:" + "3" * 64,
|
||||
}
|
||||
assert _binding(value)["candidate_id"] == "rc-v2r2c2-001"
|
||||
with pytest.raises(AdapterBlocked, match="legacy_or_unbound_release_rejected"):
|
||||
_binding({**value, "version": "V2R1C7"})
|
||||
with pytest.raises(AdapterBlocked, match="trusted_binding_incomplete"):
|
||||
_binding({key: item for key, item in value.items() if key != "release_plan_sha256"})
|
||||
@@ -1,23 +0,0 @@
|
||||
from pathlib import Path
|
||||
import json, subprocess, sys
|
||||
|
||||
def test_trusted_builder_emits_plan_bound_payload(tmp_path):
|
||||
source = tmp_path / "source"; source.mkdir()
|
||||
files = {
|
||||
"active-runtime/app/deployment_state.py": "x\n",
|
||||
"active-runtime/app/v2r1c6_privacy_tools.py": "x\n",
|
||||
"active-runtime/app/v2r2c2_health_service.py": "x\n",
|
||||
"deploy/aios-v2/systemd/aios-v2r2c2-production.service": "x\n",
|
||||
}
|
||||
for rel, text in files.items():
|
||||
path = source / rel; path.parent.mkdir(parents=True, exist_ok=True); path.write_text(text)
|
||||
output = tmp_path / "release.tar"
|
||||
script = Path(__file__).resolve().parents[1] / "scripts/autonomous-release/build_trusted_release_artifact.py"
|
||||
result = subprocess.run([sys.executable, str(script), "--source", str(source), "--output", str(output),
|
||||
"--version", "V2R2C2", "--source-oid", "1" * 40, "--tree-oid", "2" * 40,
|
||||
"--rollback-version", "V2R2C1", "--rollback-target-oid", "3" * 40,
|
||||
"--rollback-artifact-sha256", "4" * 64, "--service", "aios-v2r2c2-production.service", "--port", "18928"],
|
||||
check=True, capture_output=True, text=True)
|
||||
receipt = json.loads(result.stdout)
|
||||
assert receipt["source_commit"] == "1" * 40
|
||||
assert output.is_file() and receipt["artifact_size"] == output.stat().st_size
|
||||
@@ -1,61 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "active-runtime" / "app"))
|
||||
from trusted_release_execution_plan import PlanError, TrustedReleasePlan, canonical_json
|
||||
|
||||
|
||||
def plan():
|
||||
return {
|
||||
"schema": "aios.trusted-release-execution-plan.v1",
|
||||
"version": "V2R2C2",
|
||||
"candidate_id": "rc-v2r2c2-contract-001",
|
||||
"source_commit": "1" * 40,
|
||||
"tree_oid": "2" * 40,
|
||||
"artifact_sha256": "3" * 64,
|
||||
"manifest_sha256": "4" * 64,
|
||||
"package_closure_sha256": "5" * 64,
|
||||
"deployment_plan_sha256": "6" * 64,
|
||||
"policy_sha256": "7" * 64,
|
||||
"release_plan_id": "sha256:" + "8" * 64,
|
||||
"approval_plan_id": "sha256:" + "9" * 64,
|
||||
"evidence_closure_oid": "a" * 40,
|
||||
"campaign_authorization_sha256": "b" * 64,
|
||||
"campaign_generation": 4,
|
||||
"artifact": {"path": "/var/lib/aios/releases/V2R2C2/artifact.tar.zst", "size": 123},
|
||||
"rollback": {"version": "V2R2C1", "target_oid": "c" * 40, "artifact_sha256": "d" * 64},
|
||||
"baseline": {"version": "V2R2C1", "target_oid": "c" * 40},
|
||||
"adapter_policy": {"policy_id": "aios.production-adapter.allowlisted.v1", "service": "aios-gitops-model-api", "port": 18927},
|
||||
}
|
||||
|
||||
|
||||
def test_plan_digest_is_recomputed_and_binding_is_exact():
|
||||
value = plan()
|
||||
parsed = TrustedReleasePlan.from_mapping(value)
|
||||
assert parsed.release_plan_sha256 == hashlib.sha256(canonical_json(value)).hexdigest()
|
||||
assert parsed.candidate_binding_sha256 == hashlib.sha256(canonical_json({k: value[k] for k in ("candidate_id", "source_commit", "tree_oid", "artifact_sha256", "manifest_sha256")})).hexdigest()
|
||||
parsed.assert_binding({"version": "V2R2C2", "target_oid": "1" * 40, "evidence_closure_oid": "a" * 40, "artifact_sha256": "3" * 64, "artifact_size": 123, "approval_plan_id": "sha256:" + "9" * 64, "candidate_id": "rc-v2r2c2-contract-001"})
|
||||
|
||||
|
||||
def test_claimed_digest_or_candidate_tamper_fails_closed():
|
||||
value = plan()
|
||||
value["release_plan_sha256"] = "f" * 64
|
||||
with pytest.raises(PlanError, match="release_plan_sha256_mismatch"):
|
||||
TrustedReleasePlan.from_mapping(value)
|
||||
value = plan()
|
||||
value["candidate_id"] = "different-candidate"
|
||||
parsed = TrustedReleasePlan.from_mapping(value)
|
||||
with pytest.raises(PlanError, match="binding_plan_mismatch"):
|
||||
parsed.assert_binding({"version": "V2R2C2", "target_oid": "1" * 40, "evidence_closure_oid": "a" * 40, "artifact_sha256": "3" * 64, "artifact_size": 123, "approval_plan_id": "sha256:" + "9" * 64, "candidate_id": "rc-v2r2c2-contract-001"})
|
||||
|
||||
|
||||
def test_plan_cannot_supply_executable_or_shell_path():
|
||||
value = plan()
|
||||
value["adapter_policy"] = {"policy_id": "aios.production-adapter.allowlisted.v1", "service": "aios-gitops-model-api", "port": 18927, "executable": "/tmp/evil"}
|
||||
with pytest.raises(PlanError, match="release_plan_fields_invalid|forbidden_plan_key"):
|
||||
TrustedReleasePlan.from_mapping(value)
|
||||
@@ -1,137 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
IMPORT_LINE = (
|
||||
"from aios_agent_task_projection_recovery_http import "
|
||||
"get_projection as get_canonical_projection, "
|
||||
"recover_from_json as recover_canonical_projection\n"
|
||||
)
|
||||
|
||||
IMPORT_ANCHOR = '''from aios_online_agent_execution import (
|
||||
GovernedExecutionBinding,
|
||||
validate_execution_envelope,
|
||||
)
|
||||
'''
|
||||
|
||||
GET_OLD = ''' if self.path == "/health":
|
||||
self._json(
|
||||
{"status": "ok", "service": "aios-agent-task-initial-execution-projection"},
|
||||
200,
|
||||
)
|
||||
else:
|
||||
self._json({"error": "NOT_FOUND"}, 404)
|
||||
'''
|
||||
|
||||
GET_NEW = ''' if self.path == "/health":
|
||||
self._json(
|
||||
{"status": "ok", "service": "aios-agent-task-initial-execution-projection"},
|
||||
200,
|
||||
)
|
||||
else:
|
||||
canonical_match = re.fullmatch(
|
||||
r"/v1/tasks/([^/]+)/canonical-projection", self.path
|
||||
)
|
||||
if canonical_match is None:
|
||||
self._json({"error": "NOT_FOUND"}, 404)
|
||||
return
|
||||
if not self._authorized():
|
||||
self._json({"error": "UNAUTHORIZED"}, 401)
|
||||
return
|
||||
if self.store is None:
|
||||
self._json({"error": "RUNTIME_UNAVAILABLE"}, 503)
|
||||
return
|
||||
status, result = get_canonical_projection(
|
||||
self.store.db_path, canonical_match.group(1)
|
||||
)
|
||||
self._json(result, status)
|
||||
'''
|
||||
|
||||
POST_MATCH_ANCHOR = ''' initial_match = re.fullmatch(
|
||||
r"/v1/tasks/([^/]+)/initial-execution-projection", self.path
|
||||
)
|
||||
'''
|
||||
|
||||
POST_MATCH_REPLACEMENT = POST_MATCH_ANCHOR + ''' canonical_match = re.fullmatch(
|
||||
r"/v1/tasks/([^/]+)/canonical-projection-recovery", self.path
|
||||
)
|
||||
'''
|
||||
|
||||
POST_VALIDATION_OLD = ''' if initial_match is None and recovery_match is None:
|
||||
self._json({"error": "NOT_FOUND"}, 404)
|
||||
return
|
||||
'''
|
||||
|
||||
POST_VALIDATION_NEW = ''' if (
|
||||
initial_match is None
|
||||
and recovery_match is None
|
||||
and canonical_match is None
|
||||
):
|
||||
self._json({"error": "NOT_FOUND"}, 404)
|
||||
return
|
||||
'''
|
||||
|
||||
VALUE_HANDLER_OLD = ''' if initial_match is not None:
|
||||
request = InitialExecutionProjectionRequest.from_mapping(value)
|
||||
'''
|
||||
|
||||
VALUE_HANDLER_NEW = ''' if canonical_match is not None:
|
||||
status, result = recover_canonical_projection(
|
||||
self.store.db_path, canonical_match.group(1), value
|
||||
)
|
||||
self._json(result, status)
|
||||
return
|
||||
if initial_match is not None:
|
||||
request = InitialExecutionProjectionRequest.from_mapping(value)
|
||||
'''
|
||||
|
||||
|
||||
class PatchError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def replace_once(text: str, old: str, new: str, label: str) -> str:
|
||||
count = text.count(old)
|
||||
if count != 1:
|
||||
raise PatchError(f"{label}_ANCHOR_COUNT_{count}")
|
||||
return text.replace(old, new, 1)
|
||||
|
||||
|
||||
def apply(path: Path) -> bool:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if "canonical-projection-recovery" in text and IMPORT_LINE.strip() in text:
|
||||
return False
|
||||
text = replace_once(text, IMPORT_ANCHOR, IMPORT_ANCHOR + IMPORT_LINE, "IMPORT")
|
||||
text = replace_once(text, GET_OLD, GET_NEW, "GET")
|
||||
text = replace_once(
|
||||
text, POST_MATCH_ANCHOR, POST_MATCH_REPLACEMENT, "POST_MATCH"
|
||||
)
|
||||
text = replace_once(
|
||||
text, POST_VALIDATION_OLD, POST_VALIDATION_NEW, "POST_VALIDATION"
|
||||
)
|
||||
text = replace_once(
|
||||
text, VALUE_HANDLER_OLD, VALUE_HANDLER_NEW, "VALUE_HANDLER"
|
||||
)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("path", type=Path)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
changed = apply(args.path)
|
||||
except (OSError, PatchError) as exc:
|
||||
print(f"ERROR:{exc}", file=sys.stderr)
|
||||
return 2
|
||||
print("CHANGED" if changed else "ALREADY_APPLIED")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user