Compare commits
82
Commits
@@ -0,0 +1,10 @@
|
||||
title = "AIOS repository secret scan policy"
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[allowlist]
|
||||
description = "Exact non-secret Task Admission test idempotency fixture in the historical PR commit"
|
||||
condition = "AND"
|
||||
paths = ['''tests/test_task_admission_bootstrap_http\.py$''']
|
||||
regexes = ['''idempotency-1''']
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Staging HTTP overlay for the exact Task lineage supersession capability."""
|
||||
from __future__ import annotations
|
||||
|
||||
from http.server import ThreadingHTTPServer
|
||||
from typing import Any, Callable, Mapping
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from aios_task_lineage_supersession import (
|
||||
DECISION_SCHEMA,
|
||||
TaskLineageSupersessionController,
|
||||
TaskLineageSupersessionError,
|
||||
)
|
||||
|
||||
SUPERSESSION_ROUTE = "/v1/control/task-lineage-supersession"
|
||||
|
||||
|
||||
def _status(error: TaskLineageSupersessionError) -> int:
|
||||
return error.http_status
|
||||
|
||||
|
||||
class LineageSupersessionEndpoint:
|
||||
"""Framework-neutral endpoint; authentication must precede all parsing/mutation."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_path,
|
||||
verifier,
|
||||
authenticate_control: Callable[[Mapping[str, str]], Any],
|
||||
*,
|
||||
clock=None,
|
||||
identity_digest=None,
|
||||
) -> None:
|
||||
self.authenticate_control = authenticate_control
|
||||
self.controller = TaskLineageSupersessionController(
|
||||
db_path, verifier, clock=clock, identity_digest=identity_digest
|
||||
)
|
||||
|
||||
def post(self, headers: Mapping[str, str], body: Mapping[str, Any]) -> tuple[int, dict[str, Any]]:
|
||||
self.authenticate_control(headers)
|
||||
try:
|
||||
result = self.controller.supersede(body)
|
||||
except TaskLineageSupersessionError as exc:
|
||||
return _status(exc), {"error": exc.code}
|
||||
return (200 if result.get("idempotent") else 201), result
|
||||
|
||||
|
||||
def build_runtime():
|
||||
"""Bind to the already-active recovery API and its formal Owner verifier lazily."""
|
||||
try:
|
||||
from aios_agent_task_api import APIConfig
|
||||
from aios_agent_task_api_p0_failed_lease_recovery import (
|
||||
P0FailedLeaseRecoveryHandler,
|
||||
P0FailedLeaseRecoveryTaskApplication,
|
||||
)
|
||||
from aios_agent_task_protocol import TaskProtocolError
|
||||
from aios_owner_approval_verifier import (
|
||||
GrantRootOwnerApprovalVerifier,
|
||||
OwnerApprovalVerifierError,
|
||||
)
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("LINEAGE_SUPERSESSION_RUNTIME_OVERLAY_UNAVAILABLE") from exc
|
||||
|
||||
# The active entrypoint layers failed- and active-lease recovery on top of
|
||||
# expired recovery. Inherit that current layer so adding lineage does not
|
||||
# silently remove either already-deployed recovery route.
|
||||
class Application(P0FailedLeaseRecoveryTaskApplication):
|
||||
def __init__(self, config: Any) -> None:
|
||||
super().__init__(config)
|
||||
try:
|
||||
verifier = GrantRootOwnerApprovalVerifier()
|
||||
except OwnerApprovalVerifierError as exc:
|
||||
raise TaskProtocolError(str(exc), 503) from exc
|
||||
self.lineage_supersession = LineageSupersessionEndpoint(
|
||||
config.db_path, verifier, self.authenticate_control
|
||||
)
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
value = dict(super().health())
|
||||
value.update({
|
||||
"task_lineage_supersession": True,
|
||||
"task_lineage_supersession_schema": DECISION_SCHEMA,
|
||||
"task_lineage_supersession_exact_scope": True,
|
||||
"task_lineage_supersession_exact_cas": True,
|
||||
"task_lineage_supersession_append_only": True,
|
||||
"task_lineage_supersession_caller_payload": False,
|
||||
})
|
||||
return value
|
||||
|
||||
class Handler(P0FailedLeaseRecoveryHandler):
|
||||
app: Application | None = None
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path != SUPERSESSION_ROUTE:
|
||||
super().do_POST()
|
||||
return
|
||||
|
||||
def action() -> None:
|
||||
if self.app is None:
|
||||
raise TaskProtocolError("SERVICE_UNAVAILABLE", 503)
|
||||
status, result = self.app.lineage_supersession.post(
|
||||
self._headers(), self._body()
|
||||
)
|
||||
if "error" in result:
|
||||
raise TaskProtocolError(str(result["error"]), status)
|
||||
self._json(result, status)
|
||||
|
||||
self._run(action)
|
||||
|
||||
return APIConfig, Application, Handler
|
||||
|
||||
|
||||
def serve(config=None) -> None:
|
||||
APIConfig, Application, Handler = build_runtime()
|
||||
config = config or APIConfig.from_env()
|
||||
application = Application(config)
|
||||
Handler.app = application
|
||||
ThreadingHTTPServer((config.host, config.port), Handler).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,811 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"""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
|
||||
Executable → Regular
+43
@@ -58,6 +58,11 @@ class AuthorityGitExecutor:
|
||||
value = self._git(["rev-parse", "--verify", ref], check=False)
|
||||
return value if value and OID_RE.fullmatch(value) else None
|
||||
|
||||
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)
|
||||
return result.returncode == 0
|
||||
|
||||
def ancestry_check(self, older_oid: str, newer_oid: str) -> bool:
|
||||
_oid(older_oid, "older_oid")
|
||||
_oid(newer_oid, "newer_oid")
|
||||
@@ -70,6 +75,44 @@ class AuthorityGitExecutor:
|
||||
raise AuthorityGitError("ANCESTRY_CHECK_FAILED")
|
||||
return result.returncode == 0
|
||||
|
||||
def create_detached_worktree(
|
||||
self,
|
||||
base_oid: str,
|
||||
worktree_name: str,
|
||||
root: Path | None = None,
|
||||
) -> Path:
|
||||
_oid(base_oid, "base_oid")
|
||||
if not worktree_name or "/" in worktree_name or "\\" in worktree_name or worktree_name in {".", ".."}:
|
||||
raise AuthorityGitError("WORKTREE_NAME_INVALID")
|
||||
parent = root or self.worktree_root
|
||||
if not parent.is_absolute() or parent.is_symlink():
|
||||
raise AuthorityGitError("WORKTREE_ROOT_INVALID")
|
||||
path = parent / worktree_name
|
||||
if path.exists() or path.is_symlink():
|
||||
raise AuthorityGitError("WORKTREE_ALREADY_EXISTS")
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
self._git(["worktree", "add", "--detach", str(path), base_oid])
|
||||
return path
|
||||
|
||||
def create_task_branch(self, branch: str, base_oid: str) -> str:
|
||||
"""Create an isolated agent branch once, using an empty-ref CAS."""
|
||||
ref = branch if branch.startswith("refs/heads/") else "refs/heads/" + branch
|
||||
if not TASK_BRANCH_RE.fullmatch(ref):
|
||||
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
||||
_oid(base_oid, "base_oid")
|
||||
if not self.commit_exists(base_oid):
|
||||
raise AuthorityGitError("BASE_COMMIT_NOT_FOUND")
|
||||
current = self.read_ref(ref)
|
||||
if current is not None:
|
||||
if current == base_oid:
|
||||
return current
|
||||
raise AuthorityGitError("AUTHORITY_BRANCH_EXPECTATION_MISMATCH")
|
||||
self._git(["update-ref", ref, base_oid, "0" * 40])
|
||||
readback = self.read_ref(ref)
|
||||
if readback != base_oid:
|
||||
raise AuthorityGitError("AUTHORITY_CAS_READBACK_MISMATCH")
|
||||
return readback
|
||||
|
||||
def advance_task_branch_CAS(self, branch: str, expected_oid: str, new_oid: str) -> str:
|
||||
if not TASK_BRANCH_RE.fullmatch(branch):
|
||||
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
||||
|
||||
Executable → Regular
+172
-10
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, uuid
|
||||
import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, urllib.request, uuid
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -22,8 +22,15 @@ from gitea_tag_publication import (
|
||||
TagPublisher,
|
||||
redact,
|
||||
validate_version,
|
||||
validate_gitea_api_url,
|
||||
)
|
||||
from orchestrator.action_monitor import ActionMonitor
|
||||
try:
|
||||
from orchestrator.action_monitor import ActionMonitor
|
||||
except ModuleNotFoundError:
|
||||
# The deployed service normally supplies runtime/agent-os on sys.path.
|
||||
# Keep direct, isolated contract execution bound to the repository copy.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "runtime" / "agent-os"))
|
||||
from orchestrator.action_monitor import ActionMonitor
|
||||
from silent_release_hardening import (
|
||||
ApprovalKeyRing,
|
||||
ApprovalRevocationStore,
|
||||
@@ -63,6 +70,11 @@ except ModuleNotFoundError:
|
||||
repository_snapshot,
|
||||
run_authoritative_git,
|
||||
)
|
||||
try:
|
||||
from aios_repository_discovery import RepositoryDiscovery, RepositoryDiscoveryError
|
||||
except ModuleNotFoundError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from aios_repository_discovery import RepositoryDiscovery, RepositoryDiscoveryError
|
||||
|
||||
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
|
||||
WS = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
|
||||
@@ -108,6 +120,8 @@ READ_ONLY_GET_PATHS = frozenset({
|
||||
'/admin/ai-evolution/api/data',
|
||||
'/api/release-consistency', '/api/tag-publication-status',
|
||||
'/api/release-acceptance',
|
||||
'/api/repository/tree', '/api/repository/search',
|
||||
'/api/repository/diff-index', '/api/repository/file',
|
||||
'/getReleaseCandidate', '/getReleasePlan', '/getReleaseAcceptance',
|
||||
'/getApprovalEnvelope', '/getReleaseSaga', '/getCapabilityStatus',
|
||||
'/getRecoveryStatus',
|
||||
@@ -308,8 +322,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}}'],
|
||||
@@ -342,9 +355,80 @@ def required_authoritative_git_dir() -> Path:
|
||||
return Path(str(snapshot["authoritative_git_dir"]))
|
||||
|
||||
|
||||
_DISCOVERY_ACTIONS = {
|
||||
'/api/repository/tree': (
|
||||
'list_repository_tree',
|
||||
{'repository', 'ref', 'limit'},
|
||||
{'prefix', 'depth', 'cursor'},
|
||||
),
|
||||
'/api/repository/search': (
|
||||
'search_repository_source',
|
||||
{'repository', 'ref', 'query', 'max_matches', 'max_bytes'},
|
||||
{'path_globs', 'cursor'},
|
||||
),
|
||||
'/api/repository/diff-index': (
|
||||
'get_diff_index',
|
||||
{'repository', 'from', 'to', 'limit'},
|
||||
{'path_prefix', 'cursor'},
|
||||
),
|
||||
'/api/repository/file': (
|
||||
'read_repository_file',
|
||||
{'repository', 'ref', 'path'},
|
||||
{'max_bytes'},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def repository_discovery_request(
|
||||
path: str, query: dict[str, list[str]]
|
||||
) -> dict[str, Any]:
|
||||
operation, required, optional = _DISCOVERY_ACTIONS[path]
|
||||
if set(query) - required - optional:
|
||||
raise ValueError('unknown repository discovery query field')
|
||||
request: dict[str, Any] = {}
|
||||
for key, values in query.items():
|
||||
if key == 'path_globs':
|
||||
if not values:
|
||||
raise ValueError('empty repository discovery path glob')
|
||||
request[key] = list(values)
|
||||
continue
|
||||
if len(values) != 1:
|
||||
raise ValueError('duplicate repository discovery query field')
|
||||
value: Any = values[0]
|
||||
if key in {'depth', 'limit', 'max_matches', 'max_bytes'}:
|
||||
try:
|
||||
value = int(value, 10)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError('invalid repository discovery integer') from exc
|
||||
request[key] = value
|
||||
if required - set(request):
|
||||
raise ValueError('missing repository discovery query field')
|
||||
if not REPOSITORY_IDENTITY:
|
||||
raise RepositoryDiscoveryError('REPOSITORY_DISCOVERY_UNAVAILABLE')
|
||||
try:
|
||||
discovery = RepositoryDiscovery(
|
||||
allowed_repositories=(REPOSITORY_IDENTITY,),
|
||||
git_dir=required_authoritative_git_dir(),
|
||||
cursor_secret=os.getenv('AIOS_REPOSITORY_DISCOVERY_CURSOR_SECRET'),
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
if isinstance(exc, RepositoryDiscoveryError):
|
||||
raise
|
||||
raise RepositoryDiscoveryError('REPOSITORY_DISCOVERY_UNAVAILABLE') from exc
|
||||
public_to_internal = {
|
||||
'ref': 'revision',
|
||||
'path': 'file_path',
|
||||
'path_prefix': 'prefix',
|
||||
}
|
||||
internal_request = {
|
||||
public_to_internal.get(key, key): value for key, value in request.items()
|
||||
}
|
||||
return dict(getattr(discovery, operation)(internal_request))
|
||||
|
||||
|
||||
def publication_publisher() -> TagPublisher:
|
||||
authoritative_repo = required_authoritative_git_dir()
|
||||
return TagPublisher(
|
||||
publisher = TagPublisher(
|
||||
WS,
|
||||
GitTagBackend(WS, authoritative_repo=authoritative_repo),
|
||||
GiteaTagClient(
|
||||
@@ -355,6 +439,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)
|
||||
@@ -404,15 +492,71 @@ class H(BaseHTTPRequestHandler):
|
||||
if not a['ok']: self.sendj({'error':'unauthorized','hint':'Bearer token required'},401); return None
|
||||
if '*' not in a['scopes'] and scope not in a['scopes']: self.sendj({'error':'forbidden','required_scope':scope,'account':a['account']},403); return None
|
||||
return a
|
||||
def bridge_release(self, path, data):
|
||||
route = {
|
||||
'/createTagApprovalRequest': '/v2/tag/approval-request',
|
||||
'/requestOrCreateTag': '/v2/tag/publish',
|
||||
'/getTagPublicationStatus': '/v2/tag/status',
|
||||
'/buildProductionArtifact': '/v2/release/artifact',
|
||||
'/executeApprovedProductionRelease': '/v2/release/execute',
|
||||
'/getReleaseConsistency': '/v2/release/consistency',
|
||||
'/runReleaseAcceptance': '/v2/release/acceptance',
|
||||
}.get(path)
|
||||
if route is None:
|
||||
raise ValueError('governed_release_route_invalid')
|
||||
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'},
|
||||
method='POST',
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
result = json.loads(response.read().decode('utf-8'))
|
||||
return self.sendj(result, response.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
body = json.loads(exc.read().decode('utf-8'))
|
||||
except Exception:
|
||||
body = {'error': 'bridge_http_error'}
|
||||
return self.sendj(body, exc.code)
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
return self.sendj({'error': 'GOVERNED_BRIDGE_UNAVAILABLE', 'detail': type(exc).__name__}, 503)
|
||||
def do_POST(self):
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
if path in _DISCOVERY_ACTIONS:
|
||||
return self.sendj({'error': 'method_not_allowed'},405)
|
||||
if path in {
|
||||
'/createTagApprovalRequest', '/requestOrCreateTag', '/getTagPublicationStatus',
|
||||
'/buildProductionArtifact', '/executeApprovedProductionRelease',
|
||||
'/getReleaseConsistency', '/runReleaseAcceptance',
|
||||
}:
|
||||
actor = self.need('tag:create')
|
||||
if not actor:
|
||||
return
|
||||
return self.bridge_release(path, self.body())
|
||||
if reject_non_read_http(self, 'POST', path):
|
||||
return
|
||||
return self.sendj({'error': 'not_found', 'path': path}, 404)
|
||||
def do_GET(self):
|
||||
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path
|
||||
if path not in READ_ONLY_GET_PATHS: return self.sendj({'error':'not_found','path':path},404)
|
||||
q=urllib.parse.parse_qs(u.query)
|
||||
if len(self.path) > 8192:
|
||||
return self.sendj({'error':'query_too_large'},413)
|
||||
try:
|
||||
q=urllib.parse.parse_qs(
|
||||
u.query, keep_blank_values=True, max_num_fields=64
|
||||
)
|
||||
except ValueError:
|
||||
return self.sendj({'error':'query_invalid'},400)
|
||||
try:
|
||||
if path in ('/health','/api/health'): return self.sendj({'status':'ok','service':'chatgpt-gitops-adapter','workspace':str(WS)})
|
||||
if path in ('/api/openapi.yaml','/api/openapi-chatgpt-actions.yaml','/api/schema'):
|
||||
return self.sendt(OPENAPI.read_text(encoding='utf-8'), 'application/yaml; charset=utf-8') if OPENAPI.exists() else self.sendj({'error':'schema_missing'},404)
|
||||
if path in ('/api','/api/status'): return self.sendj(api_spec())
|
||||
if path in _DISCOVERY_ACTIONS:
|
||||
return self.sendj(repository_discovery_request(path, q))
|
||||
if path in ('/api/base','/api/baseline','/base'):
|
||||
data=jload(WS/'baseline'/'current.json', {})
|
||||
data.setdefault('authoritative_repository', repository_snapshot(
|
||||
@@ -465,7 +609,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,
|
||||
@@ -577,7 +729,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()
|
||||
@@ -590,6 +743,8 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj({'error':'not_found','path':path},404)
|
||||
except FileNotFoundError:
|
||||
return self.sendj({'error':'release candidate not found'},404)
|
||||
except RepositoryDiscoveryError as e:
|
||||
return self.sendj({'error': redact(e)}, getattr(e, 'status_code', 400))
|
||||
except ValueError as e:
|
||||
error=redact(e)
|
||||
monitor.record_failure('do_GET', error, {'path':path})
|
||||
@@ -606,6 +761,8 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj({'error':'not_found','path':path},404)
|
||||
def do_POST(self):
|
||||
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
||||
if path in _DISCOVERY_ACTIONS:
|
||||
return self.sendj({'error':'method_not_allowed'},405)
|
||||
if reject_non_read_http(self, 'POST', path): return
|
||||
data=self.body()
|
||||
try:
|
||||
@@ -728,7 +885,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',
|
||||
@@ -899,9 +1059,11 @@ return{vs,cmds,ar,cmd,cdet,tot,sok,sfail,runs,baseline,refreshMsg,loadCmd,selVer
|
||||
|
||||
|
||||
def api_spec():
|
||||
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl','POST /createReleaseCandidate','POST /submitOwnerApproval','POST /startDeployment'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True,'silent_release_execution_enabled':SILENT_RELEASE_EXECUTION_ENABLED,'silent_release_shadow_default':True}}
|
||||
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/repository/tree','GET /api/repository/search','GET /api/repository/diff-index','GET /api/repository/file','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl','POST /createReleaseCandidate','POST /submitOwnerApproval','POST /startDeployment'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True,'silent_release_execution_enabled':SILENT_RELEASE_EXECUTION_ENABLED,'silent_release_shadow_default':True}}
|
||||
|
||||
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,349 @@
|
||||
"""ChatGPT GitOps Bridge v2 contract and governed release operations."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_task_admission_bridge_contract import (
|
||||
TASK_ADMISSION_OPERATION_OPTIONAL_FIELDS,
|
||||
TASK_ADMISSION_OPERATION_REQUIRED_FIELDS,
|
||||
TASK_ADMISSION_ROUTES,
|
||||
)
|
||||
|
||||
|
||||
BRIDGE_SCHEMA_VERSION = "aios.chatgpt-gitops-bridge.v2"
|
||||
BRIDGE_OPERATIONS = (
|
||||
"getRuntimeStatus",
|
||||
"getGovernedWriteCapabilities",
|
||||
"createGovernedTask",
|
||||
"projectOnlineAgentExecution",
|
||||
"acquireWriterLease",
|
||||
"commitFileGoverned",
|
||||
"commitFilesGoverned",
|
||||
"getGitOpsJob",
|
||||
"resumeGitOpsJob",
|
||||
"reconcileGitOpsJob",
|
||||
"createTagApprovalRequest",
|
||||
"requestOrCreateTag",
|
||||
"getTagPublicationStatus",
|
||||
"buildProductionArtifact",
|
||||
"executeApprovedProductionRelease",
|
||||
"getReleaseConsistency",
|
||||
"runReleaseAcceptance",
|
||||
"getControlPlaneSnapshot",
|
||||
"listRepositoryTree",
|
||||
"searchRepositorySource",
|
||||
"getDiffIndex",
|
||||
"readRepositoryFile",
|
||||
)
|
||||
|
||||
TASK_ADMISSION_OPERATIONS = tuple(TASK_ADMISSION_OPERATION_REQUIRED_FIELDS)
|
||||
|
||||
OPERATION_REQUIRED_FIELDS = {
|
||||
"createGovernedTask": ("task_id", "repository", "base_oid", "target_branch", "risk", "description", "idempotency_key"),
|
||||
"projectOnlineAgentExecution": ("task_id", "required_capabilities"),
|
||||
"acquireWriterLease": ("schema", "task_id", "job_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "lease_seconds"),
|
||||
"commitFileGoverned": ("task_id", "job_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "path", "content_base64", "message"),
|
||||
"commitFilesGoverned": ("task_id", "job_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "files", "message"),
|
||||
"getGitOpsJob": ("task_id", "job_id"),
|
||||
"resumeGitOpsJob": ("task_id", "job_id", "expected_revision"),
|
||||
"reconcileGitOpsJob": ("task_id", "job_id", "expected_revision"),
|
||||
"createTagApprovalRequest": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id", "owner_approval", "execute"),
|
||||
"requestOrCreateTag": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id", "execute"),
|
||||
"getTagPublicationStatus": ("version",),
|
||||
"buildProductionArtifact": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id"),
|
||||
"executeApprovedProductionRelease": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id", "execute"),
|
||||
"getReleaseConsistency": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id"),
|
||||
"runReleaseAcceptance": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id"),
|
||||
"getControlPlaneSnapshot": (),
|
||||
**TASK_ADMISSION_OPERATION_REQUIRED_FIELDS,
|
||||
"listRepositoryTree": ("repository", "ref", "limit"),
|
||||
"searchRepositorySource": ("repository", "ref", "query", "max_matches", "max_bytes"),
|
||||
"getDiffIndex": ("repository", "from", "to", "limit"),
|
||||
"readRepositoryFile": ("repository", "ref", "path"),
|
||||
}
|
||||
|
||||
OPERATION_OPTIONAL_FIELDS = {
|
||||
"listRepositoryTree": ("prefix", "depth", "cursor"),
|
||||
"searchRepositorySource": ("path_globs", "cursor"),
|
||||
"getDiffIndex": ("path_prefix", "cursor"),
|
||||
"readRepositoryFile": ("max_bytes",),
|
||||
}
|
||||
|
||||
READ_ONLY_OPERATIONS = frozenset(
|
||||
{
|
||||
"getRuntimeStatus",
|
||||
"getGovernedWriteCapabilities",
|
||||
"getControlPlaneSnapshot",
|
||||
"listRepositoryTree",
|
||||
"searchRepositorySource",
|
||||
"getDiffIndex",
|
||||
"readRepositoryFile",
|
||||
}
|
||||
)
|
||||
|
||||
_OPERATION_PATHS = {
|
||||
"createGovernedTask": "/v2/governed-task",
|
||||
"projectOnlineAgentExecution": "/v2/execution-envelope",
|
||||
"acquireWriterLease": "/v2/writer-lease",
|
||||
"commitFileGoverned": "/v2/commit-file",
|
||||
"commitFilesGoverned": "/v2/commit-files",
|
||||
"getGitOpsJob": "/v2/job",
|
||||
"resumeGitOpsJob": "/v2/job/resume",
|
||||
"reconcileGitOpsJob": "/v2/job/reconcile",
|
||||
"getTagPublicationStatus": "/v2/tag/status",
|
||||
"getReleaseConsistency": "/v2/release/consistency",
|
||||
"runReleaseAcceptance": "/v2/release/acceptance",
|
||||
**{operation: path for path, operation in TASK_ADMISSION_ROUTES.items()},
|
||||
"listRepositoryTree": "/v2/repository/tree",
|
||||
"searchRepositorySource": "/v2/repository/search",
|
||||
"getDiffIndex": "/v2/repository/diff-index",
|
||||
"readRepositoryFile": "/v2/repository/file",
|
||||
}
|
||||
|
||||
|
||||
def _has_complete_task_admission_handlers(
|
||||
handlers: Mapping[str, Callable[[Mapping[str, Any]], Any]] | None,
|
||||
) -> bool:
|
||||
return handlers is not None and all(
|
||||
callable(handlers.get(operation)) for operation in TASK_ADMISSION_OPERATIONS
|
||||
)
|
||||
|
||||
|
||||
def operation_contracts(
|
||||
handlers: Mapping[str, Callable[[Mapping[str, Any]], Any]] | None = None,
|
||||
) -> dict[str, dict[str, object]]:
|
||||
"""Return the runtime contract consumed by adapters and schema tooling."""
|
||||
contracts = {
|
||||
operation: {
|
||||
"method": "GET" if operation in OPERATION_OPTIONAL_FIELDS else "POST",
|
||||
"path": _OPERATION_PATHS.get(operation),
|
||||
"required_fields": list(fields),
|
||||
"optional_fields": list(OPERATION_OPTIONAL_FIELDS.get(operation, ())),
|
||||
"read_only": operation in READ_ONLY_OPERATIONS,
|
||||
}
|
||||
for operation, fields in OPERATION_REQUIRED_FIELDS.items()
|
||||
if operation not in TASK_ADMISSION_OPERATIONS
|
||||
}
|
||||
if _has_complete_task_admission_handlers(handlers):
|
||||
contracts.update(
|
||||
{
|
||||
operation: {
|
||||
"method": "GET" if operation == "getTaskAdmissionStatus" else "POST",
|
||||
"path": _OPERATION_PATHS[operation],
|
||||
"required_fields": list(
|
||||
TASK_ADMISSION_OPERATION_REQUIRED_FIELDS[operation]
|
||||
),
|
||||
"optional_fields": list(
|
||||
TASK_ADMISSION_OPERATION_OPTIONAL_FIELDS[operation]
|
||||
),
|
||||
}
|
||||
for operation in TASK_ADMISSION_OPERATIONS
|
||||
}
|
||||
)
|
||||
return contracts
|
||||
|
||||
|
||||
def render_openapi_contract_fragment() -> str:
|
||||
required = "\n".join(
|
||||
f" - {field}"
|
||||
for field in OPERATION_REQUIRED_FIELDS["commitFilesGoverned"]
|
||||
)
|
||||
return (
|
||||
" /v2/commit-files:\n"
|
||||
" post:\n"
|
||||
" operationId: commitFilesGoverned\n"
|
||||
" summary: Submit one atomic governed commit containing multiple files\n"
|
||||
" security: [{taskPrincipalAuth: []}]\n"
|
||||
" x-aios-task-principal: Bridge verifies a fresh aios.task-principal-assertion.v1 and exact task/job/repository/branch/generation/revision/attempt/execution-digest/capability binding; caller JSON cannot provide a principal and missing verification is denied.\n"
|
||||
" requestBody:\n"
|
||||
" required: true\n"
|
||||
" content:\n"
|
||||
" application/json:\n"
|
||||
" schema:\n"
|
||||
" type: object\n"
|
||||
" additionalProperties: false\n"
|
||||
" required:\n"
|
||||
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"
|
||||
" files:\n"
|
||||
" type: array\n"
|
||||
" minItems: 1\n"
|
||||
" items:\n"
|
||||
" type: object\n"
|
||||
" additionalProperties: false\n"
|
||||
" required: [path, content_base64]\n"
|
||||
" properties:\n"
|
||||
" path: {type: string, minLength: 1, maxLength: 512}\n"
|
||||
" content_base64: {type: string}\n"
|
||||
" message: {type: string, minLength: 1, maxLength: 200}\n"
|
||||
" responses:\n"
|
||||
" '200': {description: Governed commit job and verified branch result}\n"
|
||||
" '202': {description: Existing external Job alias requires authoritative reconciliation; no replacement Job is created}\n"
|
||||
" '401': {description: No verified Task principal is available}\n"
|
||||
" '403': {description: Verified Task principal lacks exact task, external Job, or capability scope}\n"
|
||||
" '400': {description: Contract or policy rejection}\n"
|
||||
" '409': {description: Branch CAS or idempotency conflict}\n"
|
||||
" '423': {description: Narrow P0 Task-principal mutation gate is frozen; assertion is not consumed}\n"
|
||||
)
|
||||
|
||||
|
||||
class RuntimeDriftError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class BridgeV2Error(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ControlPlaneReleaseManifest:
|
||||
control_plane_release_version: str
|
||||
control_plane_target_oid: str
|
||||
artifact_sha256: str
|
||||
runtime_path: Path
|
||||
bridge_schema_version: str
|
||||
repository_identity: str
|
||||
authoritative_git_dir: Path
|
||||
deployment_state: str
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"control_plane_release_version": self.control_plane_release_version,
|
||||
"control_plane_target_oid": self.control_plane_target_oid,
|
||||
"artifact_sha256": self.artifact_sha256,
|
||||
"runtime_path": str(self.runtime_path),
|
||||
"bridge_schema_version": self.bridge_schema_version,
|
||||
"repository_identity": self.repository_identity,
|
||||
"authoritative_git_dir": str(self.authoritative_git_dir),
|
||||
"deployment_state": self.deployment_state,
|
||||
}
|
||||
|
||||
def validate(self, artifact: Path, *, observed_oid: str, approved_root: Path = Path("/opt/aios/gitops-control-plane")) -> dict[str, object]:
|
||||
try:
|
||||
runtime = self.runtime_path.resolve(strict=True)
|
||||
root = approved_root.resolve(strict=True)
|
||||
artifact_path = artifact.resolve(strict=True)
|
||||
runtime.relative_to(root)
|
||||
artifact_path.relative_to(runtime)
|
||||
except (OSError, ValueError):
|
||||
raise RuntimeDriftError("CONTROL_PLANE_RUNTIME_DRIFT") from None
|
||||
mutable_markers = {"workspace", "workspaces", "worktree", "worktrees"}
|
||||
if mutable_markers.intersection(part.lower() for part in runtime.parts):
|
||||
raise RuntimeDriftError("CONTROL_PLANE_RUNTIME_DRIFT")
|
||||
valid = (
|
||||
len(self.control_plane_target_oid) == 40
|
||||
and observed_oid == self.control_plane_target_oid
|
||||
and len(self.artifact_sha256) == 64
|
||||
and hashlib.sha256(artifact_path.read_bytes()).hexdigest() == self.artifact_sha256
|
||||
and self.bridge_schema_version == BRIDGE_SCHEMA_VERSION
|
||||
and self.repository_identity == "aios/aios"
|
||||
and self.authoritative_git_dir == Path("/data/aios-gitops/repo.git")
|
||||
and self.authoritative_git_dir.is_absolute()
|
||||
and self.deployment_state == "STAGING"
|
||||
)
|
||||
if not valid:
|
||||
raise RuntimeDriftError("CONTROL_PLANE_RUNTIME_DRIFT")
|
||||
return {**self.as_dict(), "drift_status": "PASS"}
|
||||
|
||||
|
||||
class BridgeV2Adapter:
|
||||
"""Thin operation adapter; all mutating handlers are injected seams."""
|
||||
|
||||
def __init__(self, *, runtime_status: Mapping[str, Any] | Callable[[], Mapping[str, Any]], handlers: Mapping[str, Callable[[Mapping[str, Any]], Any]] | None = None) -> None:
|
||||
self._runtime_status_source = runtime_status if callable(runtime_status) else dict(runtime_status)
|
||||
self.handlers = dict(handlers or {})
|
||||
|
||||
def _runtime_status(self) -> dict[str, Any]:
|
||||
value = self._runtime_status_source() if callable(self._runtime_status_source) else self._runtime_status_source
|
||||
if not isinstance(value, Mapping):
|
||||
raise BridgeV2Error("RUNTIME_STATUS_INVALID")
|
||||
return dict(value)
|
||||
|
||||
def getRuntimeStatus(self) -> dict[str, Any]:
|
||||
return {"bridge_schema_version": BRIDGE_SCHEMA_VERSION, **self._runtime_status()}
|
||||
|
||||
def getGovernedWriteCapabilities(self) -> dict[str, Any]:
|
||||
runtime_status = self._runtime_status()
|
||||
governed_writes_enabled = runtime_status.get("governed_writes_enabled") is True
|
||||
return {
|
||||
"bridge_schema_version": BRIDGE_SCHEMA_VERSION,
|
||||
"legacy_writes_enabled": False,
|
||||
"governed_writes_enabled": governed_writes_enabled,
|
||||
"governed_write_blockers": list(
|
||||
runtime_status.get("governed_write_blockers") or []
|
||||
),
|
||||
"operations": [
|
||||
*BRIDGE_OPERATIONS,
|
||||
*(
|
||||
TASK_ADMISSION_OPERATIONS
|
||||
if _has_complete_task_admission_handlers(self.handlers)
|
||||
else ()
|
||||
),
|
||||
],
|
||||
"operation_contracts": operation_contracts(self.handlers),
|
||||
"force_push": False,
|
||||
"main_write": False,
|
||||
"tag_write": False,
|
||||
"baseline_write": False,
|
||||
"production_write": False,
|
||||
"approved_release_operations": True,
|
||||
"approved_release_scope": {
|
||||
"version": "V2R2C1",
|
||||
"target_oid": "998d30304787e31e5ae607c0bd86b5edb2b0e5ad",
|
||||
"evidence_closure_oid": "a8b906d1bc24088c19647e98de6a679268f6d0a2",
|
||||
"approval_plan_id": "sha256:c0df52008ae7f64cec5ee2d28947aa7b892066c670ab8295e4549140b1a7500e",
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def legacy_write_disabled() -> dict[str, object]:
|
||||
return {"error": "LEGACY_WRITE_DISABLED", "replacement": "createGovernedTask", "governed_writes_available": False}
|
||||
|
||||
def _call(self, operation: str, request: Mapping[str, Any]) -> Any:
|
||||
required = OPERATION_REQUIRED_FIELDS[operation]
|
||||
if any(key not in request for key in required):
|
||||
raise BridgeV2Error("BRIDGE_REQUEST_FIELDS_MISSING")
|
||||
if operation in OPERATION_OPTIONAL_FIELDS:
|
||||
allowed = set(required) | set(OPERATION_OPTIONAL_FIELDS[operation])
|
||||
if set(request) - allowed:
|
||||
raise BridgeV2Error("BRIDGE_REQUEST_FIELDS_UNKNOWN")
|
||||
handler = self.handlers.get(operation)
|
||||
if handler is None:
|
||||
raise BridgeV2Error("GOVERNED_HANDLER_UNAVAILABLE")
|
||||
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 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)
|
||||
def createTagApprovalRequest(self, request: Mapping[str, Any]) -> Any: return self._call("createTagApprovalRequest", request)
|
||||
def requestOrCreateTag(self, request: Mapping[str, Any]) -> Any: return self._call("requestOrCreateTag", request)
|
||||
def getTagPublicationStatus(self, request: Mapping[str, Any]) -> Any: return self._call("getTagPublicationStatus", request)
|
||||
def buildProductionArtifact(self, request: Mapping[str, Any]) -> Any: return self._call("buildProductionArtifact", request)
|
||||
def executeApprovedProductionRelease(self, request: Mapping[str, Any]) -> Any: return self._call("executeApprovedProductionRelease", request)
|
||||
def getReleaseConsistency(self, request: Mapping[str, Any]) -> Any: return self._call("getReleaseConsistency", request)
|
||||
def runReleaseAcceptance(self, request: Mapping[str, Any]) -> Any: return self._call("runReleaseAcceptance", request)
|
||||
def getTaskAdmissionEnvelope(self, request: Mapping[str, Any]) -> Any: return self._call("getTaskAdmissionEnvelope", request)
|
||||
def submitTaskAdmissionAuthorization(self, request: Mapping[str, Any]) -> Any: return self._call("submitTaskAdmissionAuthorization", request)
|
||||
def getTaskAdmissionStatus(self, request: Mapping[str, Any]) -> Any: return self._call("getTaskAdmissionStatus", request)
|
||||
def listRepositoryTree(self, request: Mapping[str, Any]) -> Any: return self._call("listRepositoryTree", request)
|
||||
def searchRepositorySource(self, request: Mapping[str, Any]) -> Any: return self._call("searchRepositorySource", request)
|
||||
def getDiffIndex(self, request: Mapping[str, Any]) -> Any: return self._call("getDiffIndex", request)
|
||||
def readRepositoryFile(self, request: Mapping[str, Any]) -> Any: return self._call("readRepositoryFile", request)
|
||||
|
||||
def getControlPlaneSnapshot(self, request: Mapping[str, Any] | None = None) -> Any: return self._call("getControlPlaneSnapshot", request or {})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,615 @@
|
||||
"""Governed continuation executor for exact existing task branches.
|
||||
|
||||
The executor composes the existing continuation lease and durable Job store.
|
||||
All Git and Gitea side effects live behind injected narrow interfaces so the
|
||||
state machine can prove Authority, Git-network, API-branch, and commit-object
|
||||
agreement before it reports completion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Callable, Mapping, Protocol
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import quote, urlsplit
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from aios_authority_git_executor import AuthorityGitExecutor
|
||||
from aios_continuation_lease import WriterLeaseRequestV2
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobState
|
||||
from aios_gitops_reconciliation_core import ReconciliationAction, RemoteObservation, decide_reconciliation
|
||||
from authorized_task_writer_provisioning import validate_writer_lease_request_v2
|
||||
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SECRET_PATTERNS = (
|
||||
re.compile(rb"gh[pousr]_[A-Za-z0-9]{20,}"),
|
||||
re.compile(rb"(?i)(?:token|password|secret|api[_-]?key)\s*[:=]\s*[\"'][^\"'\s]{12,}[\"']"),
|
||||
re.compile(rb"-----BEGIN " + rb"(?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
|
||||
)
|
||||
|
||||
|
||||
class ContinuationExecutionError(RuntimeError):
|
||||
"""Stable fail-closed continuation execution error."""
|
||||
|
||||
|
||||
class DetachedWorkspace(Protocol):
|
||||
def is_detached(self) -> bool: ...
|
||||
def head_oid(self) -> str: ...
|
||||
def commit_files(self, files: Mapping[str, bytes], message: str) -> str: ...
|
||||
|
||||
|
||||
class AuthorityPort(Protocol):
|
||||
def branch_exists(self, branch: str) -> bool: ...
|
||||
def branch_head(self, branch: str) -> str: ...
|
||||
def is_ancestor(self, older: str, newer: str) -> bool: ...
|
||||
def compare_and_swap_branch(self, branch: str, expected: str, new: str) -> str: ...
|
||||
|
||||
|
||||
class RemotePort(Protocol):
|
||||
def repository_identity(self) -> str: ...
|
||||
def push_exact(self, branch: str, expected: str, new: str, *, allow_absent: bool = False) -> None: ...
|
||||
def read_git_ref(self, branch: str) -> str | None: ...
|
||||
def read_api_branch(self, branch: str) -> str | None: ...
|
||||
def commit_visible(self, oid: str) -> bool: ...
|
||||
|
||||
|
||||
class CredentialProjection(Protocol):
|
||||
def project(self) -> None: ...
|
||||
def revoke(self) -> None: ...
|
||||
|
||||
|
||||
def _run(argv: list[str], *, cwd: Path | None = None, environment: Mapping[str, str] | None = None) -> str:
|
||||
result = subprocess.run(
|
||||
argv,
|
||||
cwd=cwd,
|
||||
env=None if environment is None else dict(environment),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise ContinuationExecutionError("SUBPROCESS_FAILED")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AuthorityGitAdapter:
|
||||
"""Branch-name adapter over the existing narrow AuthorityGitExecutor."""
|
||||
|
||||
executor: AuthorityGitExecutor
|
||||
|
||||
@staticmethod
|
||||
def _ref(branch: str) -> str:
|
||||
return f"refs/heads/{branch}"
|
||||
|
||||
def branch_exists(self, branch: str) -> bool:
|
||||
return self.executor.read_ref(self._ref(branch)) is not None
|
||||
|
||||
def branch_head(self, branch: str) -> str:
|
||||
value = self.executor.read_ref(self._ref(branch))
|
||||
if value is None:
|
||||
raise ContinuationExecutionError("EXISTING_BRANCH_MISSING")
|
||||
return value
|
||||
|
||||
def is_ancestor(self, older: str, newer: str) -> bool:
|
||||
return self.executor.ancestry_check(older, newer)
|
||||
|
||||
def compare_and_swap_branch(self, branch: str, expected: str, new: str) -> str:
|
||||
return self.executor.advance_task_branch_CAS(self._ref(branch), expected, new)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DetachedGitWorkspace:
|
||||
path: Path
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.path.is_absolute() or self.path.is_symlink() or not self.path.is_dir():
|
||||
raise ContinuationExecutionError("WORKTREE_PATH_INVALID")
|
||||
top = Path(_run(["git", "rev-parse", "--show-toplevel"], cwd=self.path)).resolve()
|
||||
if top != self.path.resolve():
|
||||
raise ContinuationExecutionError("WORKTREE_PATH_INVALID")
|
||||
|
||||
def is_detached(self) -> bool:
|
||||
result = subprocess.run(
|
||||
["git", "symbolic-ref", "-q", "HEAD"],
|
||||
cwd=self.path,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
return result.returncode == 1
|
||||
|
||||
def head_oid(self) -> str:
|
||||
return _run(["git", "rev-parse", "HEAD"], cwd=self.path)
|
||||
|
||||
def _dirty_paths(self) -> set[str]:
|
||||
command = subprocess.run(
|
||||
["git", "status", "--porcelain=v1", "--untracked-files=all"],
|
||||
cwd=self.path,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if command.returncode != 0:
|
||||
raise ContinuationExecutionError("SUBPROCESS_FAILED")
|
||||
output = command.stdout
|
||||
result: set[str] = set()
|
||||
for line in output.splitlines():
|
||||
path = line[3:]
|
||||
if " -> " in path:
|
||||
path = path.split(" -> ", 1)[1]
|
||||
result.add(path)
|
||||
return result
|
||||
|
||||
def materialize_files(self, files: Mapping[str, bytes]) -> None:
|
||||
dirty = self._dirty_paths()
|
||||
expected_paths = set(files)
|
||||
if dirty and dirty != expected_paths:
|
||||
raise ContinuationExecutionError("WORKTREE_DIRTY_SCOPE_MISMATCH")
|
||||
for relative, content in files.items():
|
||||
target = (self.path / relative).resolve()
|
||||
try:
|
||||
target.relative_to(self.path.resolve())
|
||||
except ValueError as exc:
|
||||
raise ContinuationExecutionError("COMMIT_PATH_REJECTED") from exc
|
||||
if target.is_symlink():
|
||||
raise ContinuationExecutionError("COMMIT_PATH_REJECTED")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(content)
|
||||
|
||||
def commit_files(self, files: Mapping[str, bytes], message: str) -> str:
|
||||
expected_paths = set(files)
|
||||
if self._dirty_paths() != expected_paths:
|
||||
raise ContinuationExecutionError("WORKTREE_DIRTY_SCOPE_MISMATCH")
|
||||
for relative, content in files.items():
|
||||
target = (self.path / relative).resolve()
|
||||
try:
|
||||
target.relative_to(self.path.resolve())
|
||||
except ValueError as exc:
|
||||
raise ContinuationExecutionError("COMMIT_PATH_REJECTED") from exc
|
||||
if not target.is_file() or target.is_symlink() or target.read_bytes() != content:
|
||||
raise ContinuationExecutionError("AUTHORING_PAYLOAD_MISMATCH")
|
||||
_run(["git", "add", "--", *sorted(expected_paths)], cwd=self.path)
|
||||
staged = set(_run(["git", "diff", "--cached", "--name-only"], cwd=self.path).splitlines())
|
||||
if staged != expected_paths:
|
||||
raise ContinuationExecutionError("STAGED_SCOPE_MISMATCH")
|
||||
environment = os.environ.copy()
|
||||
environment.update(
|
||||
{
|
||||
"GIT_AUTHOR_NAME": "AIOS Governed Continuation",
|
||||
"GIT_AUTHOR_EMAIL": "aios-governed-continuation@localhost",
|
||||
"GIT_COMMITTER_NAME": "AIOS Governed Continuation",
|
||||
"GIT_COMMITTER_EMAIL": "aios-governed-continuation@localhost",
|
||||
}
|
||||
)
|
||||
_run(["git", "commit", "--no-gpg-sign", "-m", message, "--", *sorted(expected_paths)], cwd=self.path, environment=environment)
|
||||
if not self.is_detached():
|
||||
raise ContinuationExecutionError("WORKTREE_NOT_DETACHED")
|
||||
return self.head_oid()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ServerSideCredentialProjection:
|
||||
ssh_key_path: Path
|
||||
api_token_path: Path
|
||||
active: bool = False
|
||||
|
||||
def project(self) -> None:
|
||||
for path in (self.ssh_key_path, self.api_token_path):
|
||||
if path.is_symlink() or not path.is_file() or path.stat().st_size < 32:
|
||||
raise ContinuationExecutionError("CREDENTIAL_PROJECTION_MISSING")
|
||||
self.active = True
|
||||
|
||||
def revoke(self) -> None:
|
||||
self.active = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GiteaContinuationRemote:
|
||||
workspace: Path
|
||||
credential: ServerSideCredentialProjection
|
||||
remote_name: str = "gitea"
|
||||
repository: str = "aios/aios"
|
||||
api_base: str = "https://g.cnnetmkt.top/git/api/v1"
|
||||
expected_host: str = "g.cnnetmkt.top"
|
||||
|
||||
@staticmethod
|
||||
def validate_remote_url(value: str, expected_host: str = "g.cnnetmkt.top") -> None:
|
||||
if value.startswith(("/", "file:")) or value.endswith("/data/aios-gitops/repo.git"):
|
||||
raise ContinuationExecutionError("GITEA_REMOTE_IDENTITY_INVALID")
|
||||
if "://" in value:
|
||||
parsed = urlsplit(value)
|
||||
host, path = parsed.hostname, parsed.path
|
||||
else:
|
||||
location = value.split("@", 1)[-1]
|
||||
host, separator, path = location.partition(":")
|
||||
if not separator:
|
||||
raise ContinuationExecutionError("GITEA_REMOTE_IDENTITY_INVALID")
|
||||
path = "/" + path
|
||||
if host != expected_host or path.rstrip("/") not in {"/aios/aios.git", "/git/aios/aios.git"}:
|
||||
raise ContinuationExecutionError("GITEA_REMOTE_IDENTITY_INVALID")
|
||||
|
||||
def repository_identity(self) -> str:
|
||||
url = _run(["git", "remote", "get-url", self.remote_name], cwd=self.workspace)
|
||||
self.validate_remote_url(url, self.expected_host)
|
||||
if self.repository != "aios/aios":
|
||||
raise ContinuationExecutionError("REMOTE_REPOSITORY_IDENTITY_MISMATCH")
|
||||
return self.repository
|
||||
|
||||
def _require_projected(self) -> None:
|
||||
if not self.credential.active:
|
||||
raise ContinuationExecutionError("CREDENTIAL_PROJECTION_MISSING")
|
||||
|
||||
def read_git_ref(self, branch: str) -> str | None:
|
||||
self._require_projected()
|
||||
output = _run(["git", "ls-remote", self.remote_name, f"refs/heads/{branch}"], cwd=self.workspace)
|
||||
if not output:
|
||||
return None
|
||||
oid, ref = output.split(maxsplit=1)
|
||||
if ref != f"refs/heads/{branch}" or not OID_RE.fullmatch(oid):
|
||||
raise ContinuationExecutionError("GITEA_GIT_READBACK_INVALID")
|
||||
return oid
|
||||
|
||||
def push_exact(self, branch: str, expected: str, new: str, *, allow_absent: bool = False) -> None:
|
||||
self._require_projected()
|
||||
self.repository_identity()
|
||||
current = self.read_git_ref(branch)
|
||||
if current != expected and not (allow_absent and current is None):
|
||||
raise ContinuationExecutionError("GITEA_BRANCH_EXPECTATION_MISMATCH")
|
||||
_run(["git", "push", self.remote_name, f"{new}:refs/heads/{branch}"], cwd=self.workspace)
|
||||
|
||||
def _api(self, path: str) -> object:
|
||||
self._require_projected()
|
||||
token = self.credential.api_token_path.read_text(encoding="utf-8").strip()
|
||||
request = Request(
|
||||
self.api_base.rstrip("/") + path,
|
||||
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=20) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
return None
|
||||
raise ContinuationExecutionError(f"GITEA_API_HTTP_{exc.code}") from exc
|
||||
except (URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
|
||||
raise ContinuationExecutionError("GITEA_API_UNAVAILABLE") from exc
|
||||
|
||||
def read_api_branch(self, branch: str) -> str | None:
|
||||
owner, name = self.repository.split("/", 1)
|
||||
page = 1
|
||||
while page <= 100:
|
||||
value = self._api(f"/repos/{quote(owner)}/{quote(name)}/branches?page={page}&limit=50")
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, list):
|
||||
raise ContinuationExecutionError("GITEA_API_BRANCH_COLLECTION_INVALID")
|
||||
for item in value:
|
||||
if not isinstance(item, dict) or item.get("name") != branch:
|
||||
continue
|
||||
oid = item.get("commit", {}).get("id")
|
||||
if not isinstance(oid, str) or not OID_RE.fullmatch(oid):
|
||||
raise ContinuationExecutionError("GITEA_API_BRANCH_INVALID")
|
||||
return oid
|
||||
if len(value) < 50:
|
||||
return None
|
||||
page += 1
|
||||
raise ContinuationExecutionError("GITEA_API_BRANCH_PAGINATION_LIMIT")
|
||||
|
||||
def commit_visible(self, oid: str) -> bool:
|
||||
owner, name = self.repository.split("/", 1)
|
||||
value = self._api(f"/repos/{quote(owner)}/{quote(name)}/git/commits/{oid}")
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
observed = value.get("sha") or value.get("id")
|
||||
return observed == oid
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContinuationPermissions:
|
||||
create_target_branch: bool = False
|
||||
force_push: bool = False
|
||||
tag_write: bool = False
|
||||
baseline_write: bool = False
|
||||
production_write: bool = False
|
||||
main_write: bool = False
|
||||
|
||||
def validate(self) -> None:
|
||||
if any(asdict(self).values()):
|
||||
raise ContinuationExecutionError("MUTATION_PERMISSION_DENIED")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContinuationRuntimeBinding:
|
||||
request: WriterLeaseRequestV2
|
||||
workspace_path: Path
|
||||
lease_not_before: datetime
|
||||
lease_expires_at: datetime
|
||||
original_receipt_valid: bool
|
||||
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)
|
||||
class ContinuationCommit:
|
||||
idempotency_key: str
|
||||
files: Mapping[str, bytes]
|
||||
message: str
|
||||
|
||||
def payload(self) -> bytes:
|
||||
if not self.idempotency_key or not self.message.strip() or not self.files:
|
||||
raise ContinuationExecutionError("COMMIT_REQUEST_INVALID")
|
||||
normalized: dict[str, str] = {}
|
||||
for path, content in self.files.items():
|
||||
pure = PurePosixPath(path)
|
||||
if (
|
||||
not isinstance(path, str)
|
||||
or pure.is_absolute()
|
||||
or ".." in pure.parts
|
||||
or path.startswith((".git/", "baseline/", "production/"))
|
||||
or not isinstance(content, bytes)
|
||||
):
|
||||
raise ContinuationExecutionError("COMMIT_PATH_REJECTED")
|
||||
if any(pattern.search(content) for pattern in SECRET_PATTERNS):
|
||||
raise ContinuationExecutionError("SECRET_LIKE_PAYLOAD_REJECTED")
|
||||
normalized[path] = base64.b64encode(content).decode("ascii")
|
||||
return json.dumps(
|
||||
{"files_base64": normalized, "message": self.message},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
class ContinuationExecutor:
|
||||
def __init__(
|
||||
self,
|
||||
controller: GitOpsJobController,
|
||||
authority: AuthorityPort,
|
||||
workspace: DetachedWorkspace,
|
||||
remote: RemotePort,
|
||||
credential: CredentialProjection,
|
||||
*,
|
||||
now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
||||
) -> None:
|
||||
self.controller = controller
|
||||
self.authority = authority
|
||||
self.workspace = workspace
|
||||
self.remote = remote
|
||||
self.credential = credential
|
||||
self.now = now
|
||||
self._observed_generations: dict[str, int] = {}
|
||||
|
||||
def observe_generation(self, branch: str, generation: int) -> None:
|
||||
self._observed_generations[branch] = max(generation, self._observed_generations.get(branch, 0))
|
||||
|
||||
def _max_generation(self, branch: str) -> int:
|
||||
observed = self._observed_generations.get(branch, 0)
|
||||
with self.controller.store._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT MAX(generation) AS generation FROM branch_leases WHERE branch=?",
|
||||
(branch,),
|
||||
).fetchone()
|
||||
durable = int(row["generation"] or 0)
|
||||
return max(observed, durable)
|
||||
|
||||
@staticmethod
|
||||
def _request_mapping(request: WriterLeaseRequestV2) -> dict[str, object]:
|
||||
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": request.generation,
|
||||
"lease_seconds": request.lease_seconds,
|
||||
}
|
||||
|
||||
def _validate_preflight(self, binding: ContinuationRuntimeBinding) -> None:
|
||||
request = binding.request
|
||||
normalized = validate_writer_lease_request_v2(self._request_mapping(request), self.authority)
|
||||
if normalized != {**self._request_mapping(request), "create_target_branch": False, "worktree_mode": "detached_at_expected_head", "cas_ref_update_required": True}:
|
||||
raise ContinuationExecutionError("CONTINUATION_NORMALIZATION_INVALID")
|
||||
if not binding.original_receipt_valid or not binding.original_grant_consumed:
|
||||
raise ContinuationExecutionError("ORIGINAL_GRANT_LINEAGE_INVALID")
|
||||
binding.permissions.validate()
|
||||
now = self.now()
|
||||
if now < binding.lease_not_before:
|
||||
raise ContinuationExecutionError("LEASE_NOT_ACTIVE")
|
||||
if now >= binding.lease_expires_at:
|
||||
raise ContinuationExecutionError("LEASE_EXPIRED")
|
||||
if request.generation <= self._max_generation(request.target_branch):
|
||||
raise ContinuationExecutionError("GENERATION_NOT_MONOTONIC")
|
||||
if not self.workspace.is_detached():
|
||||
raise ContinuationExecutionError("WORKTREE_NOT_DETACHED")
|
||||
if self.workspace.head_oid() != request.expected_branch_oid:
|
||||
raise ContinuationExecutionError("WORKTREE_HEAD_MISMATCH")
|
||||
if self.remote.repository_identity() != request.repository:
|
||||
raise ContinuationExecutionError("REMOTE_REPOSITORY_IDENTITY_MISMATCH")
|
||||
|
||||
def _job_request(self, binding: ContinuationRuntimeBinding, commit: ContinuationCommit, payload: bytes) -> dict[str, object]:
|
||||
request = binding.request
|
||||
return {
|
||||
"idempotency_key": commit.idempotency_key,
|
||||
"campaign_id": request.task_id,
|
||||
"task_id": request.task_id,
|
||||
"repository": request.repository,
|
||||
"operation": "CONTINUATION_COMMIT_FILES",
|
||||
"branch": request.target_branch,
|
||||
"expected_branch_oid": request.expected_branch_oid,
|
||||
"base_oid": request.base_oid,
|
||||
"branch_mode": request.branch_mode,
|
||||
"payload_digest": hashlib.sha256(payload).hexdigest(),
|
||||
"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]:
|
||||
payload = commit.payload()
|
||||
submitted = self.controller.submit(self._job_request(binding, commit, payload), payload=payload)
|
||||
current = self.controller.store.read(submitted.job_id)
|
||||
if JobState(current["state"]) == JobState.COMPLETED:
|
||||
return self.controller.store._receipt(current, duplicate=True).as_dict()
|
||||
self._validate_preflight(binding)
|
||||
request = binding.request
|
||||
projected = False
|
||||
try:
|
||||
self.credential.project()
|
||||
projected = True
|
||||
row = self.controller.store.read(submitted.job_id)
|
||||
if JobState(row["state"]) == JobState.REQUESTED:
|
||||
self.controller.advance(
|
||||
submitted.job_id,
|
||||
JobState.AUTHORIZED,
|
||||
side_effect_state="AUTHORIZED",
|
||||
receipt={"task_id": request.task_id, "original_grant_consumed": True},
|
||||
)
|
||||
row = self.controller.store.read(submitted.job_id)
|
||||
if JobState(row["state"]) == JobState.AUTHORIZED:
|
||||
self.controller.lease_job(
|
||||
submitted.job_id,
|
||||
lease_id=f"lease-{submitted.job_id}",
|
||||
lineage_anchor_oid=request.base_oid,
|
||||
current_head_oid=request.expected_branch_oid,
|
||||
generation=request.generation,
|
||||
lease_expires_at=binding.lease_expires_at.astimezone(timezone.utc).isoformat(),
|
||||
expected_revision=int(row["revision"]),
|
||||
)
|
||||
self.controller.advance(submitted.job_id, JobState.WORKTREE_READY, side_effect_state="WORKTREE_READY")
|
||||
self.controller.advance(submitted.job_id, JobState.EXECUTING, side_effect_state="IN_PROGRESS")
|
||||
new_oid = self.workspace.commit_files(commit.files, commit.message)
|
||||
if not OID_RE.fullmatch(new_oid) or not self.authority.is_ancestor(request.expected_branch_oid, new_oid):
|
||||
raise ContinuationExecutionError("NON_FAST_FORWARD_COMMIT")
|
||||
authority_oid = self.authority.compare_and_swap_branch(request.target_branch, request.expected_branch_oid, new_oid)
|
||||
if authority_oid != new_oid:
|
||||
raise ContinuationExecutionError("AUTHORITY_CAS_READBACK_MISMATCH")
|
||||
self.controller.advance(
|
||||
submitted.job_id,
|
||||
JobState.LOCAL_VERIFIED,
|
||||
side_effect_state="LOCAL_VERIFIED",
|
||||
receipt={"authority_oid": authority_oid, "force_push": False},
|
||||
)
|
||||
try:
|
||||
self.controller.advance(submitted.job_id, JobState.REMOTE_SYNCING, side_effect_state="IN_PROGRESS")
|
||||
self.remote.push_exact(request.target_branch, request.expected_branch_oid, new_oid, allow_absent=binding.remote_branch_create_allowed)
|
||||
except Exception:
|
||||
return self._remote_pending(submitted.job_id, authority_oid, new_oid)
|
||||
return self._finish_remote_verification(submitted.job_id, authority_oid, new_oid)
|
||||
finally:
|
||||
if projected:
|
||||
self.credential.revoke()
|
||||
|
||||
def _observe_remote(self, branch: str, oid: str) -> tuple[str | None, str | None, bool]:
|
||||
return self.remote.read_git_ref(branch), self.remote.read_api_branch(branch), self.remote.commit_visible(oid)
|
||||
|
||||
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,
|
||||
}
|
||||
result = self.controller.advance(
|
||||
job_id,
|
||||
JobState.RECONCILIATION_REQUIRED,
|
||||
side_effect_state="REMOTE_PUSH_PENDING",
|
||||
receipt=receipt,
|
||||
)
|
||||
return result.as_dict()
|
||||
|
||||
def _finish_remote_verification(self, job_id: str, authority_oid: str, new_oid: str) -> dict[str, object]:
|
||||
row = self.controller.store.read(job_id)
|
||||
branch = row["branch"]
|
||||
request = json.loads(row["request_json"])["binding"]
|
||||
git_oid, api_oid, visible = self._observe_remote(branch, new_oid)
|
||||
decision = decide_reconciliation(RemoteObservation("aios/aios", branch, new_oid, authority_oid, git_oid, api_oid, visible, datetime.now(timezone.utc).isoformat(), request.get("expected_branch_oid")))
|
||||
if decision.action is not ReconciliationAction.MARK_REMOTE_VERIFIED:
|
||||
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,
|
||||
"remote_verified": True,
|
||||
"reconciliation_decision": decision.action.value,
|
||||
"force_push": False,
|
||||
}
|
||||
self.controller.advance(job_id, JobState.REMOTE_VERIFIED, side_effect_state="REMOTE_VERIFIED", receipt=receipt)
|
||||
return self.controller.advance(job_id, JobState.COMPLETED, side_effect_state="REMOTE_VERIFIED", receipt=receipt).as_dict()
|
||||
|
||||
def reconcile(self, job_id: str) -> dict[str, object]:
|
||||
row = self.controller.store.read(job_id)
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.COMPLETED:
|
||||
return self.controller.store._receipt(row, duplicate=True).as_dict()
|
||||
if state != JobState.RECONCILIATION_REQUIRED:
|
||||
raise ContinuationExecutionError("JOB_NOT_RECONCILABLE")
|
||||
request = json.loads(row["request_json"])["binding"]
|
||||
receipt = json.loads(row["receipt_json"])
|
||||
new_oid = receipt["desired_remote_oid"]
|
||||
authority_oid = self.authority.branch_head(row["branch"])
|
||||
try:
|
||||
generation = int(request.get("generation") or 0)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ContinuationExecutionError("DURABLE_BINDING_GENERATION_INVALID") from exc
|
||||
if generation > 0:
|
||||
with self.controller.store._connect() as connection:
|
||||
earlier = connection.execute(
|
||||
"SELECT 1 FROM jobs WHERE branch=? AND state=? "
|
||||
"AND CAST(json_extract(request_json, '$.binding.generation') AS INTEGER) < ? LIMIT 1",
|
||||
(row["branch"], JobState.RECONCILIATION_REQUIRED.value, generation),
|
||||
).fetchone()
|
||||
if earlier is not None:
|
||||
raise ContinuationExecutionError("EARLIER_RECONCILIATION_REQUIRED")
|
||||
self.credential.project()
|
||||
try:
|
||||
git_oid, api_oid, visible = self._observe_remote(row["branch"], new_oid)
|
||||
if authority_oid == git_oid == api_oid == new_oid and visible:
|
||||
self.controller.advance(job_id, JobState.RECONCILING, side_effect_state="OBSERVATION_FIRST")
|
||||
return self._finish_remote_verification(job_id, new_oid, new_oid)
|
||||
expected_oid = request["expected_branch_oid"]
|
||||
if (
|
||||
self.authority.is_ancestor(new_oid, authority_oid)
|
||||
and git_oid is not None
|
||||
and git_oid == api_oid
|
||||
and self.authority.is_ancestor(git_oid, new_oid)
|
||||
):
|
||||
self.controller.advance(job_id, JobState.RECONCILING, side_effect_state="OBSERVATION_FIRST_CHAIN")
|
||||
self.remote.push_exact(row["branch"], git_oid, new_oid, allow_absent=bool(request.get("remote_branch_create_allowed")) or request.get("branch_mode") == "CREATE_NEW")
|
||||
return self._finish_remote_verification(job_id, new_oid, new_oid)
|
||||
allow_remote_create = bool(request.get("remote_branch_create_allowed")) or request.get("branch_mode") == "CREATE_NEW" or (git_oid is None and api_oid is None)
|
||||
remote_matches_expected = git_oid == api_oid == expected_oid
|
||||
remote_absent_for_create = git_oid is None and api_oid is None
|
||||
if authority_oid == new_oid and (remote_matches_expected or remote_absent_for_create):
|
||||
self.controller.advance(job_id, JobState.RECONCILING, side_effect_state="OBSERVATION_FIRST_RETRY")
|
||||
self.remote.push_exact(row["branch"], expected_oid, new_oid, allow_absent=allow_remote_create)
|
||||
return self._finish_remote_verification(job_id, new_oid, new_oid)
|
||||
return self.controller.store._receipt(row, duplicate=True).as_dict()
|
||||
finally:
|
||||
self.credential.revoke()
|
||||
@@ -74,6 +74,12 @@ class WriterLeaseRequestV2:
|
||||
raise ContinuationError("EXISTING_BRANCH_OID_CONFLICT")
|
||||
if not probe.is_ancestor(self.base_oid, actual):
|
||||
raise ContinuationError("BASE_NOT_ANCESTOR_OF_BRANCH")
|
||||
elif hasattr(probe, "commit_exists") and not probe.commit_exists(self.base_oid):
|
||||
raise ContinuationError("BASE_COMMIT_NOT_FOUND")
|
||||
elif self.branch_mode == "CREATE_NEW" and probe.branch_exists(self.target_branch):
|
||||
raise ContinuationError("TARGET_BRANCH_ALREADY_EXISTS")
|
||||
elif self.branch_mode == "CREATE_NEW" and self.base_oid != self.expected_branch_oid:
|
||||
raise ContinuationError("CREATE_NEW_BASE_EXPECTATION_MISMATCH")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Non-sensitive control-plane snapshot assembly."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
def _copy(value: Mapping[str, Any] | None, keys: tuple[str, ...]) -> dict[str, Any]:
|
||||
value = value or {}
|
||||
return {key: value.get(key) for key in keys}
|
||||
|
||||
|
||||
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,
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
def getControlPlaneSnapshot(**kwargs: Any) -> dict[str, Any]:
|
||||
return build_control_plane_snapshot(**kwargs)
|
||||
@@ -0,0 +1,478 @@
|
||||
"""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}"
|
||||
)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Metadata-only, server-side credential lease broker."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CredentialLease:
|
||||
lease_id: str
|
||||
task_id: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
generation: int
|
||||
issued_at: str
|
||||
expires_at: str
|
||||
scopes: tuple[str, ...]
|
||||
provider: str
|
||||
state: str = "ACTIVE"
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {"lease_id": self.lease_id, "task_id": self.task_id, "repository": self.repository, "target_branch": self.target_branch, "generation": self.generation, "issued_at": self.issued_at, "expires_at": self.expires_at, "scopes": list(self.scopes), "provider": self.provider, "state": self.state}
|
||||
|
||||
|
||||
class FileCredentialProvider:
|
||||
def __init__(self, token_path: str | os.PathLike[str], *, actor: str = "aios-autonomy-agent") -> None:
|
||||
self.token_path = Path(token_path)
|
||||
self.actor = actor
|
||||
|
||||
def environment(self, lease: CredentialLease) -> dict[str, str]:
|
||||
if not self.token_path.is_file():
|
||||
raise RuntimeError("CREDENTIAL_PROVIDER_UNAVAILABLE")
|
||||
return {"CREDENTIAL_FILE": str(self.token_path), "GITEA_MACHINE_ACTOR": self.actor}
|
||||
|
||||
|
||||
class CredentialBroker:
|
||||
def __init__(self, provider: Any, *, now: Callable[[], datetime] | None = None) -> None:
|
||||
self.provider = provider
|
||||
self.now = now or (lambda: datetime.now(timezone.utc))
|
||||
self._leases: dict[str, CredentialLease] = {}
|
||||
|
||||
def issue(self, *, task_id: str, repository: str, target_branch: str, generation: int, scopes: tuple[str, ...] = ("git:push", "gitea:read"), lease_seconds: int = 900) -> dict[str, Any]:
|
||||
if repository != "aios/aios" or not target_branch.startswith("agent/") or generation < 1 or not 1 <= lease_seconds <= 86400:
|
||||
raise ValueError("CREDENTIAL_LEASE_BINDING_INVALID")
|
||||
now = self.now().astimezone(timezone.utc)
|
||||
lease = CredentialLease("lease-" + secrets.token_hex(16), task_id, repository, target_branch, generation, now.isoformat(), (now + timedelta(seconds=lease_seconds)).isoformat(), tuple(scopes), type(self.provider).__name__)
|
||||
self._leases[lease.lease_id] = lease
|
||||
return lease.as_dict()
|
||||
|
||||
def _get(self, lease_id: str) -> CredentialLease:
|
||||
lease = self._leases.get(lease_id)
|
||||
if lease is None:
|
||||
raise KeyError("CREDENTIAL_LEASE_NOT_FOUND")
|
||||
if lease.state == "ACTIVE" and self.now().astimezone(timezone.utc) >= datetime.fromisoformat(lease.expires_at):
|
||||
lease = CredentialLease(**{**lease.as_dict(), "scopes": tuple(lease.scopes), "state": "EXPIRED"})
|
||||
self._leases[lease_id] = lease
|
||||
return lease
|
||||
|
||||
def environment(self, lease_id: str) -> Mapping[str, str]:
|
||||
lease = self._get(lease_id)
|
||||
if lease.state != "ACTIVE":
|
||||
raise RuntimeError("CREDENTIAL_LEASE_NOT_ACTIVE")
|
||||
return self.provider.environment(lease)
|
||||
|
||||
def status(self, lease_id: str) -> dict[str, Any]:
|
||||
return self._get(lease_id).as_dict()
|
||||
|
||||
def revoke(self, lease_id: str) -> dict[str, Any]:
|
||||
lease = self._get(lease_id)
|
||||
if lease.state == "REVOKED":
|
||||
return lease.as_dict()
|
||||
updated = CredentialLease(**{**lease.as_dict(), "scopes": tuple(lease.scopes), "state": "REVOKED"})
|
||||
self._leases[lease_id] = updated
|
||||
return updated.as_dict()
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Canonical Gitea transport and slash-branch verification."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Any, Callable, Mapping, Optional
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from aios_gitops_reconciliation_core import RemoteObservation
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GiteaPublisherConfig:
|
||||
repository: str = "aios/aios"
|
||||
api_base: str = "https://g.cnnetmkt.top/git/api/v1"
|
||||
remote_name: str = "gitea-http"
|
||||
|
||||
|
||||
class GiteaPublisher:
|
||||
def __init__(self, workspace: Path, *, config: GiteaPublisherConfig = GiteaPublisherConfig(), credential: Any = None, api_get: Callable[[str], Any] | None = None, git_ref: Callable[[str], Optional[str]] | None = None) -> None:
|
||||
self.workspace = Path(workspace)
|
||||
self.config = config
|
||||
self.credential = credential
|
||||
self._api_get = api_get
|
||||
self._git_ref = git_ref
|
||||
|
||||
@staticmethod
|
||||
def validate_repository(repository: str) -> None:
|
||||
if repository != "aios/aios":
|
||||
raise ValueError("REPOSITORY_IDENTITY_MISMATCH")
|
||||
|
||||
@staticmethod
|
||||
def validate_branch(branch: str) -> None:
|
||||
if not isinstance(branch, str) or not branch.startswith("agent/") or branch in {"main", "master"}:
|
||||
raise ValueError("BRANCH_SCOPE_DENIED")
|
||||
|
||||
def repository_identity(self) -> str:
|
||||
self.validate_repository(self.config.repository)
|
||||
return self.config.repository
|
||||
|
||||
def _environment(self) -> dict[str, str]:
|
||||
if self.credential is not None and hasattr(self.credential, "environment"):
|
||||
return dict(self.credential.environment())
|
||||
return {}
|
||||
|
||||
def read_git_ref(self, branch: str) -> Optional[str]:
|
||||
self.validate_branch(branch)
|
||||
if self._git_ref is not None:
|
||||
return self._git_ref(branch)
|
||||
result = subprocess.run(["git", "-C", str(self.workspace), "ls-remote", self.config.remote_name, f"refs/heads/{branch}"], text=True, capture_output=True, check=False, env={**os.environ, **self._environment(), "GIT_TERMINAL_PROMPT": "0"})
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
return None
|
||||
return result.stdout.split()[0]
|
||||
|
||||
def _api(self, path: str) -> Any:
|
||||
if self._api_get is not None:
|
||||
return self._api_get(path)
|
||||
headers = {"Accept": "application/json"}
|
||||
env = self._environment()
|
||||
if env.get("AIOS_GITEA_TOKEN"):
|
||||
headers["Authorization"] = "token " + env["AIOS_GITEA_TOKEN"]
|
||||
with urlopen(Request(self.config.api_base.rstrip("/") + path, headers=headers), timeout=20) as response:
|
||||
return json.loads(response.read() or b"{}")
|
||||
|
||||
def read_api_branch(self, branch: str) -> Optional[str]:
|
||||
self.validate_branch(branch)
|
||||
for page in range(1, 101):
|
||||
value = self._api(f"/repos/aios/aios/branches?page={page}&limit=50")
|
||||
if not isinstance(value, list):
|
||||
return None
|
||||
for item in value:
|
||||
if isinstance(item, Mapping) and item.get("name") == branch:
|
||||
commit = item.get("commit")
|
||||
return commit.get("id") if isinstance(commit, Mapping) else None
|
||||
if len(value) < 50:
|
||||
return None
|
||||
raise RuntimeError("GITEA_BRANCH_PAGINATION_LIMIT")
|
||||
|
||||
def commit_visible(self, oid: str) -> bool:
|
||||
if not isinstance(oid, str) or len(oid) != 40:
|
||||
return False
|
||||
try:
|
||||
value = self._api(f"/repos/aios/aios/git/commits/{quote(oid, safe='')}")
|
||||
return isinstance(value, Mapping) and value.get("sha", oid) == oid
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def observe(self, branch: str, desired_oid: str, authority_oid: Optional[str], *, expected_oid: Optional[str] = None, observed_at: str = "") -> RemoteObservation:
|
||||
return RemoteObservation(self.repository_identity(), branch, desired_oid, authority_oid, self.read_git_ref(branch), self.read_api_branch(branch), self.commit_visible(desired_oid), observed_at, expected_oid)
|
||||
@@ -41,6 +41,8 @@ class JobState(str, Enum):
|
||||
REMOTE_SYNCING = "REMOTE_SYNCING"
|
||||
REMOTE_VERIFIED = "REMOTE_VERIFIED"
|
||||
COMPLETED = "COMPLETED"
|
||||
FAILED = "FAILED"
|
||||
CANCELLED = "CANCELLED"
|
||||
RECOVERABLE_FAILURE = "RECOVERABLE_FAILURE"
|
||||
RECONCILING = "RECONCILING"
|
||||
RECONCILIATION_REQUIRED = "RECONCILIATION_REQUIRED"
|
||||
@@ -59,7 +61,12 @@ _ORDER = {
|
||||
JobState.COMPLETED: 8,
|
||||
}
|
||||
_RECOVERABLE = {JobState.RECOVERABLE_FAILURE, JobState.RECONCILIATION_REQUIRED}
|
||||
_TERMINAL = {JobState.COMPLETED, JobState.MANUAL_OWNER_REVIEW_REQUIRED}
|
||||
_TERMINAL = {
|
||||
JobState.COMPLETED,
|
||||
JobState.FAILED,
|
||||
JobState.CANCELLED,
|
||||
JobState.MANUAL_OWNER_REVIEW_REQUIRED,
|
||||
}
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
@@ -171,6 +178,63 @@ 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:
|
||||
@@ -209,9 +273,43 @@ 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
|
||||
|
||||
@@ -244,12 +342,234 @@ 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:
|
||||
status, side = 200, "COMPLETED"
|
||||
elif state in _RECOVERABLE:
|
||||
status, side = 202, "RESUME_SAME_JOB"
|
||||
status = 202
|
||||
side = row["side_effect_state"] if row["side_effect_state"] == "REMOTE_PUSH_PENDING" else "RESUME_SAME_JOB"
|
||||
elif row["side_effect_state"] == "UNKNOWN":
|
||||
status, side = 503, "UNKNOWN"
|
||||
else:
|
||||
@@ -280,10 +600,13 @@ class GitOpsJobController:
|
||||
# durable round trip and participate in idempotency. Arbitrary request
|
||||
# fields are intentionally discarded rather than persisted.
|
||||
optional = (
|
||||
"idempotency_key",
|
||||
"task_id",
|
||||
"new_branch_oid",
|
||||
"authorization_recovery_digest",
|
||||
"lineage_anchor_oid",
|
||||
"base_oid",
|
||||
"branch_mode",
|
||||
"generation",
|
||||
"expected_main_oid",
|
||||
"pr_number",
|
||||
@@ -291,6 +614,11 @@ 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)
|
||||
@@ -314,7 +642,15 @@ class GitOpsJobController:
|
||||
raise JobError("PAYLOAD_REFERENCE_MISMATCH")
|
||||
if payload_ref is not None and (not isinstance(payload_size, int) or payload_size < 0):
|
||||
raise JobError("PAYLOAD_SIZE_INVALID")
|
||||
key = sha256_json(binding)
|
||||
client_idempotency_key = binding.get("idempotency_key")
|
||||
if client_idempotency_key is not None:
|
||||
if len(client_idempotency_key) > 128 or not all(
|
||||
character.isalnum() or character in "._:-" for character in client_idempotency_key
|
||||
):
|
||||
raise JobError("IDEMPOTENCY_KEY_INVALID")
|
||||
key = sha256_json({"client_idempotency_key": client_idempotency_key})
|
||||
else:
|
||||
key = sha256_json(binding)
|
||||
job_id = "job-" + key[:32]
|
||||
durable_request = dict(binding)
|
||||
if payload_ref is not None:
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Collision-resistant storage keys for durable governed Task state."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
|
||||
class JobIdentityError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def legacy_task_storage_key(task_id: str) -> str:
|
||||
"""Return the historical lossy key solely to detect migration conflicts."""
|
||||
if not isinstance(task_id, str) or not task_id:
|
||||
raise JobIdentityError("TASK_ID_INVALID")
|
||||
return re.sub(r"[^A-Za-z0-9_.-]+", "-", task_id).strip("-")[:120] or "task"
|
||||
|
||||
|
||||
def task_storage_key(task_id: str) -> str:
|
||||
"""Return a canonical collision-resistant key for every Task state sink."""
|
||||
if (
|
||||
not isinstance(task_id, str)
|
||||
or not task_id
|
||||
or task_id != task_id.strip()
|
||||
or len(task_id) > 512
|
||||
):
|
||||
raise JobIdentityError("TASK_ID_INVALID")
|
||||
readable = re.sub(r"[^A-Za-z0-9_.-]+", "-", task_id).strip("-")[:48] or "task"
|
||||
digest = hashlib.sha256(
|
||||
("aios-task-storage-v1\0" + task_id).encode("utf-8")
|
||||
).hexdigest()
|
||||
return f"{readable}-{digest}"
|
||||
@@ -5,6 +5,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from release_version import validate_release_version
|
||||
|
||||
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
|
||||
WORKSPACE = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
|
||||
DB_PATH = Path(os.getenv('AIOS_GITOPS_DB', str(ROOT / 'runtime' / 'gitops_model.sqlite3')))
|
||||
@@ -218,7 +220,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
a = self.require_write(['owner']);
|
||||
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 ''): self.send_json({'error':'version must match VxRxCx'},400); return
|
||||
try:
|
||||
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
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Pure, observation-first decisions for governed GitOps reconciliation."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
class ReconciliationAction(str, Enum):
|
||||
NOOP = "NOOP"
|
||||
PUSH_EXACT = "PUSH_EXACT"
|
||||
VERIFY_REMOTE = "VERIFY_REMOTE"
|
||||
MARK_REMOTE_VERIFIED = "MARK_REMOTE_VERIFIED"
|
||||
MARK_RECONCILIATION_REQUIRED = "MARK_RECONCILIATION_REQUIRED"
|
||||
FAIL_CONFLICT = "FAIL_CONFLICT"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RemoteObservation:
|
||||
repository_identity: str
|
||||
branch: str
|
||||
desired_oid: str
|
||||
authority_oid: Optional[str]
|
||||
remote_git_oid: Optional[str]
|
||||
remote_api_oid: Optional[str]
|
||||
commit_visible: bool
|
||||
observed_at: str
|
||||
expected_oid: Optional[str] = None
|
||||
|
||||
@property
|
||||
def all_equal(self) -> bool:
|
||||
values = (self.authority_oid, self.remote_git_oid, self.remote_api_oid, self.desired_oid)
|
||||
return all(isinstance(value, str) for value in values) and len(set(values)) == 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReconciliationDecision:
|
||||
action: ReconciliationAction
|
||||
state: str
|
||||
reason: str
|
||||
retry_safe: bool = False
|
||||
|
||||
|
||||
def _valid_oid(value: Optional[str]) -> bool:
|
||||
return value is None or bool(OID_RE.fullmatch(value))
|
||||
|
||||
|
||||
def decide_reconciliation(observation: RemoteObservation) -> ReconciliationDecision:
|
||||
"""Decide from a fresh observation; never complete on a missing remote."""
|
||||
if observation.repository_identity != "aios/aios":
|
||||
return ReconciliationDecision(ReconciliationAction.FAIL_CONFLICT, "CONFLICT", "REPOSITORY_IDENTITY_MISMATCH")
|
||||
if not observation.branch.startswith("agent/"):
|
||||
return ReconciliationDecision(ReconciliationAction.FAIL_CONFLICT, "CONFLICT", "BRANCH_SCOPE_DENIED")
|
||||
if not _valid_oid(observation.desired_oid) or not _valid_oid(observation.expected_oid):
|
||||
return ReconciliationDecision(ReconciliationAction.FAIL_CONFLICT, "CONFLICT", "OID_INVALID")
|
||||
if observation.authority_oid is None:
|
||||
return ReconciliationDecision(ReconciliationAction.MARK_RECONCILIATION_REQUIRED, "RECONCILIATION_REQUIRED", "AUTHORITY_UNAVAILABLE")
|
||||
if observation.authority_oid != observation.desired_oid:
|
||||
return ReconciliationDecision(ReconciliationAction.MARK_RECONCILIATION_REQUIRED, "RECONCILIATION_REQUIRED", "AUTHORITY_CONFLICT")
|
||||
present = [oid for oid in (observation.remote_git_oid, observation.remote_api_oid) if oid is not None]
|
||||
if any(oid not in {observation.desired_oid, observation.expected_oid} for oid in present):
|
||||
return ReconciliationDecision(ReconciliationAction.FAIL_CONFLICT, "CONFLICT", "REMOTE_OID_CONFLICT")
|
||||
if observation.all_equal and observation.commit_visible:
|
||||
return ReconciliationDecision(ReconciliationAction.MARK_REMOTE_VERIFIED, "REMOTE_VERIFIED", "ALL_OIDS_EQUAL_AND_VISIBLE")
|
||||
if observation.remote_git_oid is None and observation.remote_api_oid is None:
|
||||
return ReconciliationDecision(ReconciliationAction.MARK_RECONCILIATION_REQUIRED, "REMOTE_PUSH_PENDING", "AUTHORITY_DESIRED_REMOTE_MISSING")
|
||||
if observation.remote_git_oid == observation.desired_oid or observation.remote_api_oid == observation.desired_oid:
|
||||
return ReconciliationDecision(ReconciliationAction.VERIFY_REMOTE, "REMOTE_VERIFY_PENDING", "REMOTE_PROOF_PARTIAL")
|
||||
if observation.expected_oid is not None and observation.remote_git_oid == observation.expected_oid and observation.remote_api_oid == observation.expected_oid:
|
||||
return ReconciliationDecision(ReconciliationAction.PUSH_EXACT, "REMOTE_PUSH_PENDING", "SAFE_EXACT_FAST_FORWARD_RETRY", retry_safe=True)
|
||||
if not observation.commit_visible:
|
||||
return ReconciliationDecision(ReconciliationAction.VERIFY_REMOTE, "COMMIT_VISIBILITY_PENDING", "COMMIT_VISIBILITY_LAG")
|
||||
return ReconciliationDecision(ReconciliationAction.MARK_RECONCILIATION_REQUIRED, "RECONCILIATION_REQUIRED", "PARTIAL_REMOTE_OBSERVATION")
|
||||
|
||||
|
||||
def reconcile_observation(**kwargs: object) -> ReconciliationDecision:
|
||||
return decide_reconciliation(RemoteObservation(**kwargs))
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Central fail-closed policy seam for governed task and continuation requests."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from typing import Any, Mapping
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovernedPolicyInput:
|
||||
identity: str
|
||||
task_id: str
|
||||
repository: str
|
||||
operation: str
|
||||
base_oid: str
|
||||
expected_branch_oid: str
|
||||
target_branch: str
|
||||
generation: int
|
||||
risk: str = "engineering"
|
||||
requested_capabilities: tuple[str, ...] = ()
|
||||
previous_generation: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PolicyDecision:
|
||||
decision: str
|
||||
reason: str
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
return {"decision": self.decision, "reason": self.reason}
|
||||
|
||||
|
||||
def evaluate_policy(value: GovernedPolicyInput | Mapping[str, Any]) -> PolicyDecision:
|
||||
if isinstance(value, Mapping):
|
||||
value = GovernedPolicyInput(**value)
|
||||
if value.repository != "aios/aios":
|
||||
return PolicyDecision("DENY", "REPOSITORY_IDENTITY_MISMATCH")
|
||||
if not OID_RE.fullmatch(value.base_oid) or not OID_RE.fullmatch(value.expected_branch_oid):
|
||||
return PolicyDecision("DENY", "OID_INVALID")
|
||||
if not value.target_branch.startswith("agent/") or value.target_branch in {"main", "master"}:
|
||||
return PolicyDecision("DENY", "BRANCH_SCOPE_DENIED")
|
||||
if "force_push" in value.requested_capabilities or "force" in value.operation.lower():
|
||||
return PolicyDecision("DENY", "FORCE_PUSH_DENIED")
|
||||
if {"main_write", "tag_write", "baseline_write", "production_write", "production_deploy", "tag_mutation", "baseline_mutation"}.intersection(value.requested_capabilities):
|
||||
return PolicyDecision("DENY", "MUTATION_SCOPE_DENIED")
|
||||
if not isinstance(value.generation, int) or isinstance(value.generation, bool) or value.generation < 1:
|
||||
return PolicyDecision("DENY", "GENERATION_INVALID")
|
||||
if value.previous_generation is not None and value.generation <= value.previous_generation:
|
||||
return PolicyDecision("MANUAL_OWNER_REVIEW_REQUIRED", "GENERATION_NOT_MONOTONIC")
|
||||
if not value.task_id:
|
||||
return PolicyDecision("DENY", "TASK_ID_INVALID")
|
||||
return PolicyDecision("ALLOW", "GOVERNED_ENGINEERING_SCOPE")
|
||||
|
||||
|
||||
class GovernedPolicy:
|
||||
def decide(self, request: GovernedPolicyInput | Mapping[str, Any]) -> PolicyDecision:
|
||||
return evaluate_policy(request)
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Durable governed task binding for CREATE_NEW and CONTINUE_EXISTING."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import tempfile
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobStore
|
||||
from aios_gitops_job_identity import legacy_task_storage_key, task_storage_key
|
||||
from aios_online_agent_execution import GovernedExecutionBinding, project_execution_envelope
|
||||
|
||||
try: # pragma: no cover - exercised on the Linux activation host
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover - non-POSIX execution stays fail closed
|
||||
fcntl = None # type: ignore[assignment]
|
||||
|
||||
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}$")
|
||||
|
||||
|
||||
class BootstrapError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _lexists(path: Path) -> bool:
|
||||
try:
|
||||
path.lstat()
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def _require_runtime_directory(path: Path) -> None:
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise BootstrapError("TASK_STORAGE_PARENT_INVALID") from exc
|
||||
expected_uid = getattr(os, "geteuid", lambda: metadata.st_uid)()
|
||||
if (
|
||||
stat.S_ISLNK(metadata.st_mode)
|
||||
or not stat.S_ISDIR(metadata.st_mode)
|
||||
or metadata.st_mode & 0o022
|
||||
or metadata.st_uid != expected_uid
|
||||
):
|
||||
raise BootstrapError("TASK_STORAGE_PARENT_INVALID")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovernedTaskBinding:
|
||||
task_id: str
|
||||
repository: str
|
||||
base_oid: str
|
||||
target_branch: str
|
||||
risk: str
|
||||
description: str
|
||||
idempotency_key: str
|
||||
expected_branch_oid: str
|
||||
branch_mode: str
|
||||
generation: int
|
||||
job_id: str | None = None
|
||||
|
||||
def response(self, *, idempotent: bool = False) -> dict[str, Any]:
|
||||
return {"task_id": self.task_id, "repository": self.repository, "base_oid": self.base_oid, "target_branch": self.target_branch, "expected_branch_oid": self.expected_branch_oid, "generation": self.generation, "branch_mode": self.branch_mode, "create_target_branch": self.branch_mode == "CREATE_NEW", "worktree_mode": "detached_at_base_oid" if self.branch_mode == "CREATE_NEW" else "detached_at_expected_head", "cas_ref_update_required": True, "idempotent": idempotent, "state": "BOUND", "job_id": self.job_id}
|
||||
|
||||
|
||||
|
||||
# ===== AIOS Formal Lineage producer-native reservation v12 =====
|
||||
import re as _fl_re
|
||||
|
||||
_FL_PRODUCER_KEY = _fl_re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
def _formal_lineage_reservation_receipt(
|
||||
value,
|
||||
*,
|
||||
request_idempotency_key: str,
|
||||
expected_side_effect_state: str,
|
||||
):
|
||||
if callable(getattr(value, "as_dict", None)):
|
||||
raw=value.as_dict()
|
||||
elif isinstance(value, dict):
|
||||
raw=dict(value)
|
||||
else:
|
||||
raise RuntimeError("GOVERNED_TASK_SUBMIT_RECEIPT_TYPE_INVALID")
|
||||
|
||||
producer_key=raw.get("idempotency_key")
|
||||
job_id=raw.get("job_id")
|
||||
if not isinstance(job_id,str) or not job_id:
|
||||
raise RuntimeError("GOVERNED_TASK_SUBMIT_RECEIPT_JOB_ID_INVALID")
|
||||
if raw.get("state")!="REQUESTED":
|
||||
raise RuntimeError("GOVERNED_TASK_SUBMIT_RECEIPT_STATE_INVALID")
|
||||
if raw.get("side_effect_state")!=expected_side_effect_state:
|
||||
raise RuntimeError("GOVERNED_TASK_SUBMIT_RECEIPT_SIDE_EFFECT_STATE_INVALID")
|
||||
if not isinstance(producer_key,str) or not _FL_PRODUCER_KEY.fullmatch(producer_key):
|
||||
raise RuntimeError("GOVERNED_TASK_SUBMIT_RECEIPT_PRODUCER_IDEMPOTENCY_INVALID")
|
||||
if job_id!="job-"+producer_key[:32]:
|
||||
raise RuntimeError("GOVERNED_TASK_SUBMIT_RECEIPT_JOB_PRODUCER_KEY_CONFLICT")
|
||||
if not isinstance(request_idempotency_key,str) or not request_idempotency_key:
|
||||
raise RuntimeError("GOVERNED_TASK_REQUEST_IDEMPOTENCY_INVALID")
|
||||
|
||||
return {
|
||||
"job_id":job_id,
|
||||
"state":"REQUESTED",
|
||||
"side_effect_state":raw["side_effect_state"],
|
||||
"producer_idempotency_key":producer_key,
|
||||
"request_idempotency_key":request_idempotency_key,
|
||||
}
|
||||
|
||||
def _formal_lineage_attach_receipt(response, receipt):
|
||||
if not isinstance(response, dict):
|
||||
raise RuntimeError("GOVERNED_TASK_BINDING_RESPONSE_INVALID")
|
||||
if response.get("state")!="BOUND":
|
||||
raise RuntimeError("GOVERNED_TASK_BINDING_STATE_INVALID")
|
||||
if response.get("job_id")!=receipt.get("job_id"):
|
||||
raise RuntimeError("GOVERNED_TASK_BINDING_RECEIPT_JOB_CONFLICT")
|
||||
result=dict(response)
|
||||
result["reservation_receipt"]=dict(receipt)
|
||||
return result
|
||||
|
||||
class GovernedTaskBootstrap:
|
||||
def __init__(self, authority: Any, root: Path, *, remote_name: str, token_path: Path, ssh_key_path: Path, api_base: str = "https://g.cnnetmkt.top/git/api/v1") -> None:
|
||||
self.authority = authority; self.root = Path(root); self.remote_name = remote_name; self.token_path = Path(token_path); self.ssh_key_path = Path(ssh_key_path); self.api_base = api_base; self.task_meta_root = self.root / "task-bindings"; self.job_root = self.root / "jobs"; self.lock_root = self.root / "task-binding-locks"; self.task_meta_root.mkdir(parents=True, exist_ok=True, mode=0o700); self.job_root.mkdir(parents=True, exist_ok=True, mode=0o700); self.lock_root.mkdir(parents=True, exist_ok=True, mode=0o700); _require_runtime_directory(self.task_meta_root); _require_runtime_directory(self.job_root); _require_runtime_directory(self.lock_root)
|
||||
|
||||
def _store(self, task_id: str) -> tuple[JobStore, GitOpsJobController]:
|
||||
root = self.job_root / task_storage_key(task_id)
|
||||
legacy = self.job_root / legacy_task_storage_key(task_id)
|
||||
if legacy != root and _lexists(legacy):
|
||||
if _lexists(root):
|
||||
raise BootstrapError("TASK_STORAGE_LEGACY_CONFLICT")
|
||||
raise BootstrapError("TASK_STORAGE_LEGACY_MIGRATION_REQUIRED")
|
||||
store = JobStore(root, payload_root=root / "payloads")
|
||||
return store, GitOpsJobController(store)
|
||||
|
||||
def _metadata(self, task_id: str) -> Path:
|
||||
return self.task_meta_root / (task_storage_key(task_id) + ".json")
|
||||
|
||||
def _legacy_metadata(self, task_id: str) -> Path:
|
||||
return self.task_meta_root / (legacy_task_storage_key(task_id) + ".json")
|
||||
|
||||
def _read_existing(self, task_id: str) -> GovernedTaskBinding | None:
|
||||
path = self._metadata(task_id)
|
||||
legacy = self._legacy_metadata(task_id)
|
||||
if legacy != path and _lexists(legacy):
|
||||
if _lexists(path):
|
||||
raise BootstrapError("TASK_BINDING_LEGACY_CONFLICT")
|
||||
raise BootstrapError("TASK_BINDING_LEGACY_MIGRATION_REQUIRED")
|
||||
if not _lexists(path):
|
||||
return None
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise BootstrapError("TASK_BINDING_PATH_INVALID")
|
||||
|
||||
binding = GovernedTaskBinding(**json.loads(path.read_text(encoding="utf-8")))
|
||||
if binding.task_id != task_id:
|
||||
raise BootstrapError("TASK_BINDING_IDENTITY_CONFLICT")
|
||||
return binding
|
||||
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)
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=".task-binding-", dir=self.task_meta_root,
|
||||
)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
os.fchmod(descriptor, 0o600)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
|
||||
stream.write(json.dumps(asdict(binding), sort_keys=True) + "\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
os.chmod(path, 0o600)
|
||||
parent = os.open(self.task_meta_root, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(parent)
|
||||
finally:
|
||||
os.close(parent)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
def _task_lock(self, task_id: str) -> int:
|
||||
if fcntl is None:
|
||||
raise BootstrapError("TASK_BINDING_LOCK_UNAVAILABLE")
|
||||
path = self.lock_root / (task_storage_key(task_id) + ".lock")
|
||||
descriptor = os.open(
|
||||
path,
|
||||
os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0),
|
||||
0o600,
|
||||
)
|
||||
metadata = os.fstat(descriptor)
|
||||
expected_uid = getattr(os, "geteuid", lambda: metadata.st_uid)()
|
||||
if (
|
||||
not stat.S_ISREG(metadata.st_mode)
|
||||
or metadata.st_nlink != 1
|
||||
or metadata.st_uid != expected_uid
|
||||
):
|
||||
os.close(descriptor)
|
||||
raise BootstrapError("TASK_BINDING_LOCK_INVALID")
|
||||
os.fchmod(descriptor, 0o600)
|
||||
try:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError as exc:
|
||||
os.close(descriptor)
|
||||
raise BootstrapError("TASK_BINDING_BUSY") from exc
|
||||
return descriptor
|
||||
|
||||
@staticmethod
|
||||
def _unlock_task(descriptor: int) -> None:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
os.close(descriptor)
|
||||
|
||||
def create(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
values = {key: str(request[key]) for key in ("task_id", "repository", "base_oid", "target_branch", "risk", "description", "idempotency_key")}
|
||||
if values["repository"] != "aios/aios" or not OID_RE.fullmatch(values["base_oid"]): raise BootstrapError("TASK_BINDING_INVALID")
|
||||
if not BRANCH_RE.fullmatch(values["target_branch"]): raise BootstrapError("TARGET_BRANCH_INVALID")
|
||||
descriptor = self._task_lock(values["task_id"])
|
||||
try:
|
||||
existing = self._read_existing(values["task_id"])
|
||||
if existing is not None:
|
||||
if any(getattr(existing, key) != values[key] for key in ("repository", "base_oid", "target_branch", "risk", "description", "idempotency_key")): raise BootstrapError("TASK_BINDING_CONFLICT")
|
||||
expected = str(request.get('expected_branch_oid') or values['base_oid'])
|
||||
payload = json.dumps(values, sort_keys=True, separators=(',', ':')).encode()
|
||||
_, controller = self._store(values['task_id'])
|
||||
_fl_submit_receipt = controller.submit({'idempotency_key': values['idempotency_key'], 'campaign_id': values['task_id'], 'task_id': values['task_id'], 'repository': values['repository'], 'operation': 'GOVERNED_TASK_CREATE', 'branch': values['target_branch'], 'expected_branch_oid': expected, 'payload_digest': hashlib.sha256(payload).hexdigest(), 'lineage_anchor_oid': values['base_oid']}, payload=payload)
|
||||
_fl_receipt = _formal_lineage_reservation_receipt(
|
||||
_fl_submit_receipt, request_idempotency_key=request["idempotency_key"],
|
||||
expected_side_effect_state='SAME_JOB'
|
||||
)
|
||||
_fl_response = existing.response(idempotent=True)
|
||||
return _formal_lineage_attach_receipt(_fl_response,_fl_receipt)
|
||||
if not self.authority.commit_exists(values["base_oid"]): raise BootstrapError("BASE_COMMIT_NOT_FOUND")
|
||||
current = self.authority.read_ref("refs/heads/" + values["target_branch"])
|
||||
expected = str(request.get("expected_branch_oid") or values["base_oid"])
|
||||
if current is None:
|
||||
mode, generation = "CREATE_NEW", 1
|
||||
if expected != values["base_oid"]: raise BootstrapError("EXPECTED_BASE_CONFLICT")
|
||||
else:
|
||||
if expected != current: raise BootstrapError("EXISTING_BRANCH_OID_CONFLICT")
|
||||
mode, generation = "CONTINUE_EXISTING", int(request.get("generation", 2))
|
||||
if generation < 1: raise BootstrapError("GENERATION_INVALID")
|
||||
payload = json.dumps(values, sort_keys=True, separators=(",", ":")).encode()
|
||||
_, controller = self._store(values["task_id"])
|
||||
expected_job_id = controller.internal_job_id_for_idempotency_key(values["idempotency_key"])
|
||||
with controller.store._connect() as connection:
|
||||
bootstrap_jobs = connection.execute(
|
||||
"SELECT job_id FROM jobs WHERE operation='GOVERNED_TASK_CREATE' ORDER BY job_id"
|
||||
).fetchall()
|
||||
if bootstrap_jobs and [str(row[0]) for row in bootstrap_jobs] != [expected_job_id]:
|
||||
raise BootstrapError("TASK_BINDING_RECOVERY_CONFLICT")
|
||||
receipt = controller.submit({"idempotency_key": values["idempotency_key"], "campaign_id": values["task_id"], "task_id": values["task_id"], "repository": values["repository"], "operation": "GOVERNED_TASK_CREATE", "branch": values["target_branch"], "expected_branch_oid": expected, "payload_digest": hashlib.sha256(payload).hexdigest(), "lineage_anchor_oid": values["base_oid"]}, payload=payload)
|
||||
binding = GovernedTaskBinding(**values, expected_branch_oid=expected, branch_mode=mode, generation=generation, job_id=receipt.job_id)
|
||||
self._write(binding)
|
||||
_fl_receipt = _formal_lineage_reservation_receipt(
|
||||
receipt, request_idempotency_key=request["idempotency_key"],
|
||||
expected_side_effect_state="QUEUED"
|
||||
)
|
||||
_fl_response = binding.response()
|
||||
return _formal_lineage_attach_receipt(_fl_response,_fl_receipt)
|
||||
finally:
|
||||
self._unlock_task(descriptor)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,528 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,527 @@
|
||||
"""Owner-final, structured authorization for one governed pull-request merge.
|
||||
|
||||
The Owner approval is the policy decision. Signing and public verification
|
||||
prove that decision but cannot change its repository, pull request, CAS base,
|
||||
technical-gate contract, or lifetime. This module deliberately exposes no
|
||||
generic sign-bytes API and does not publish or alter technical check results.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
from aios_single_host_authority import (
|
||||
OWNER_NAMESPACE,
|
||||
LocalFileSigner,
|
||||
SignedArtifact,
|
||||
canonical_json_bytes,
|
||||
sha256_json,
|
||||
)
|
||||
|
||||
|
||||
POLICY_SCHEMA = "aios.owner-merge-approval-policy.v2"
|
||||
APPROVAL_SCHEMA = "aios.owner-merge-approval.v2"
|
||||
RECEIPT_SCHEMA = "aios.owner-merge-admission-receipt.v2"
|
||||
OPERATION = "merge.pull-request"
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
REPOSITORY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
IDENTITY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+:-]{2,255}$")
|
||||
CHECK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
TOKEN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{11,255}$")
|
||||
|
||||
|
||||
class OwnerMergeApprovalError(RuntimeError):
|
||||
"""Stable fail-closed error for the Owner merge approval contract."""
|
||||
|
||||
def __init__(self, code: str):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _fail(code: str) -> None:
|
||||
raise OwnerMergeApprovalError(code)
|
||||
|
||||
|
||||
def _require_oid(value: object, code: str) -> str:
|
||||
if not isinstance(value, str) or OID_RE.fullmatch(value) is None:
|
||||
_fail(code)
|
||||
return value
|
||||
|
||||
|
||||
def _require_digest(value: object, code: str) -> str:
|
||||
if not isinstance(value, str) or DIGEST_RE.fullmatch(value) is None:
|
||||
_fail(code)
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnerMergeApprovalPolicy:
|
||||
"""Immutable policy input for one exact protected merge approval."""
|
||||
|
||||
repository: str
|
||||
pr_number: int
|
||||
pr_head_oid: str
|
||||
expected_main_oid: str
|
||||
operation: str
|
||||
required_check_contract_digest: str
|
||||
required_checks: tuple[str, ...]
|
||||
owner_identity: str
|
||||
audit_lineage_digest: str
|
||||
maximum_ttl_seconds: int
|
||||
policy_version: str = "v2"
|
||||
schema: str = POLICY_SCHEMA
|
||||
|
||||
def validate(self) -> None:
|
||||
if (
|
||||
self.schema != POLICY_SCHEMA
|
||||
or self.policy_version != "v2"
|
||||
or not isinstance(self.repository, str)
|
||||
or REPOSITORY_RE.fullmatch(self.repository) is None
|
||||
or type(self.pr_number) is not int
|
||||
or not 1 <= self.pr_number < 2**31
|
||||
or self.operation != OPERATION
|
||||
or not isinstance(self.owner_identity, str)
|
||||
or IDENTITY_RE.fullmatch(self.owner_identity) is None
|
||||
or type(self.maximum_ttl_seconds) is not int
|
||||
or not 60 <= self.maximum_ttl_seconds <= 900
|
||||
):
|
||||
_fail("OWNER_MERGE_POLICY_INVALID")
|
||||
_require_oid(self.pr_head_oid, "OWNER_MERGE_POLICY_HEAD_INVALID")
|
||||
_require_oid(self.expected_main_oid, "OWNER_MERGE_POLICY_BASE_INVALID")
|
||||
_require_digest(
|
||||
self.required_check_contract_digest,
|
||||
"OWNER_MERGE_POLICY_CHECK_CONTRACT_INVALID",
|
||||
)
|
||||
_require_digest(self.audit_lineage_digest, "OWNER_MERGE_POLICY_LINEAGE_INVALID")
|
||||
if (
|
||||
not isinstance(self.required_checks, tuple)
|
||||
or not self.required_checks
|
||||
or len(set(self.required_checks)) != len(self.required_checks)
|
||||
or any(not isinstance(item, str) or CHECK_RE.fullmatch(item) is None for item in self.required_checks)
|
||||
):
|
||||
_fail("OWNER_MERGE_POLICY_REQUIRED_CHECKS_INVALID")
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
self.validate()
|
||||
value = asdict(self)
|
||||
value["required_checks"] = list(self.required_checks)
|
||||
return value
|
||||
|
||||
@property
|
||||
def digest(self) -> str:
|
||||
return sha256_json(self.as_dict())
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnerMergeLiveState:
|
||||
"""Read-only live evidence; this object has no status publication method."""
|
||||
|
||||
repository: str
|
||||
pr_number: int
|
||||
pr_head_oid: str
|
||||
main_oid: str
|
||||
required_check_contract_digest: str
|
||||
check_statuses: Mapping[str, bool]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnerMergeOidReadback:
|
||||
authority_main_oid: str
|
||||
gitea_main_oid: str
|
||||
api_main_oid: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnerMergeAdmissionReceipt:
|
||||
approval_id: str
|
||||
nonce: str
|
||||
owner_identity: str
|
||||
policy_digest: str
|
||||
proof_path: str
|
||||
merge_oid: str
|
||||
schema: str = RECEIPT_SCHEMA
|
||||
|
||||
|
||||
class ApprovalProofVerifier(Protocol):
|
||||
"""Public proof verifier; it is intentionally unable to issue approvals."""
|
||||
|
||||
def verify(
|
||||
self,
|
||||
*,
|
||||
namespace: str,
|
||||
payload: bytes,
|
||||
signature: str,
|
||||
key_id: str,
|
||||
) -> bool:
|
||||
...
|
||||
|
||||
|
||||
class GovernedMergeExecutor(Protocol):
|
||||
def execute(self, approval: Mapping[str, object]) -> str:
|
||||
...
|
||||
|
||||
|
||||
class MergeOidReconciler(Protocol):
|
||||
def readback(self, merge_oid: str) -> OwnerMergeOidReadback:
|
||||
...
|
||||
|
||||
|
||||
APPROVAL_FIELDS = frozenset(
|
||||
{
|
||||
"schema",
|
||||
"approval_id",
|
||||
"nonce",
|
||||
"repository",
|
||||
"pr_number",
|
||||
"pr_head_oid",
|
||||
"expected_main_oid",
|
||||
"operation",
|
||||
"required_check_contract_digest",
|
||||
"required_checks",
|
||||
"owner_identity",
|
||||
"audit_lineage_digest",
|
||||
"policy_version",
|
||||
"policy_digest",
|
||||
"issued_at",
|
||||
"not_before",
|
||||
"expires_at",
|
||||
"signature_namespace",
|
||||
"signature_key_id",
|
||||
"canonical_payload_digest",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class OwnerMergeApprovalAuthority:
|
||||
"""Structured Owner signer for an immutable merge policy.
|
||||
|
||||
``issue`` has no caller-controlled payload parameter. The immutable policy
|
||||
selects the complete authorization scope; only approval IDs and nonces are
|
||||
generated inside the authority.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key_path: Path,
|
||||
policy: OwnerMergeApprovalPolicy,
|
||||
*,
|
||||
require_root_owner: bool = True,
|
||||
clock: Callable[[], float] = time.time,
|
||||
token_urlsafe: Callable[[int], str] = secrets.token_urlsafe,
|
||||
) -> None:
|
||||
policy.validate()
|
||||
self.policy = policy
|
||||
self.signer = LocalFileSigner(
|
||||
Path(key_path),
|
||||
signer_id="local-owner-authority",
|
||||
namespace=OWNER_NAMESPACE,
|
||||
require_root_owner=require_root_owner,
|
||||
)
|
||||
self.clock = clock
|
||||
self.token_urlsafe = token_urlsafe
|
||||
|
||||
def _token(self, prefix: str) -> str:
|
||||
value = prefix + self.token_urlsafe(24)
|
||||
if TOKEN_RE.fullmatch(value) is None:
|
||||
_fail("OWNER_MERGE_AUTHORITY_GENERATED_TOKEN_INVALID")
|
||||
return value
|
||||
|
||||
def issue(self) -> SignedArtifact:
|
||||
now = int(self.clock())
|
||||
approval_id = self._token("owner-merge-approval-")
|
||||
nonce = self._token("owner-merge-nonce-")
|
||||
policy = self.policy
|
||||
payload: dict[str, Any] = {
|
||||
"schema": APPROVAL_SCHEMA,
|
||||
"approval_id": approval_id,
|
||||
"nonce": nonce,
|
||||
"repository": policy.repository,
|
||||
"pr_number": policy.pr_number,
|
||||
"pr_head_oid": policy.pr_head_oid,
|
||||
"expected_main_oid": policy.expected_main_oid,
|
||||
"operation": policy.operation,
|
||||
"required_check_contract_digest": policy.required_check_contract_digest,
|
||||
"required_checks": list(policy.required_checks),
|
||||
"owner_identity": policy.owner_identity,
|
||||
"audit_lineage_digest": policy.audit_lineage_digest,
|
||||
"policy_version": policy.policy_version,
|
||||
"policy_digest": policy.digest,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 5),
|
||||
"expires_at": now + policy.maximum_ttl_seconds,
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_key_id": self.signer.fingerprint,
|
||||
}
|
||||
payload["canonical_payload_digest"] = sha256_json(payload)
|
||||
return self.signer._sign_canonical(payload)
|
||||
|
||||
|
||||
class OwnerMergeApprovalVerifier:
|
||||
"""Verify Owner proof, preferring normal verification then recovery proof.
|
||||
|
||||
The recovery verifier validates a pre-existing approval with the registered
|
||||
public Owner root. It cannot issue an approval and is therefore not a
|
||||
second policy principal.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
primary: ApprovalProofVerifier,
|
||||
recovery: ApprovalProofVerifier | None = None,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
self.primary = primary
|
||||
self.recovery = recovery
|
||||
self.clock = clock
|
||||
|
||||
@staticmethod
|
||||
def _artifact(artifact: SignedArtifact | Mapping[str, Any]) -> tuple[dict[str, Any], str, str, str]:
|
||||
if isinstance(artifact, SignedArtifact):
|
||||
return dict(artifact.payload), artifact.signature, artifact.key_id, artifact.namespace
|
||||
if not isinstance(artifact, Mapping) or set(artifact) != {"payload", "signature", "key_id", "namespace"}:
|
||||
_fail("OWNER_MERGE_APPROVAL_ARTIFACT_INVALID")
|
||||
payload = artifact.get("payload")
|
||||
signature = artifact.get("signature")
|
||||
key_id = artifact.get("key_id")
|
||||
namespace = artifact.get("namespace")
|
||||
if not isinstance(payload, Mapping) or not all(isinstance(item, str) for item in (signature, key_id, namespace)):
|
||||
_fail("OWNER_MERGE_APPROVAL_ARTIFACT_INVALID")
|
||||
return dict(payload), signature, key_id, namespace
|
||||
|
||||
@staticmethod
|
||||
def _validate_scope(payload: Mapping[str, Any], policy: OwnerMergeApprovalPolicy, *, now: int, key_id: str, namespace: str) -> None:
|
||||
if set(payload) != APPROVAL_FIELDS:
|
||||
_fail("OWNER_MERGE_APPROVAL_FIELDS_INVALID")
|
||||
expected = {
|
||||
"schema": APPROVAL_SCHEMA,
|
||||
"repository": policy.repository,
|
||||
"pr_number": policy.pr_number,
|
||||
"pr_head_oid": policy.pr_head_oid,
|
||||
"expected_main_oid": policy.expected_main_oid,
|
||||
"operation": policy.operation,
|
||||
"required_check_contract_digest": policy.required_check_contract_digest,
|
||||
"required_checks": list(policy.required_checks),
|
||||
"owner_identity": policy.owner_identity,
|
||||
"audit_lineage_digest": policy.audit_lineage_digest,
|
||||
"policy_version": policy.policy_version,
|
||||
"policy_digest": policy.digest,
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_key_id": key_id,
|
||||
}
|
||||
if payload.get("pr_head_oid") != policy.pr_head_oid:
|
||||
_fail("OWNER_MERGE_APPROVAL_HEAD_BINDING_INVALID")
|
||||
if payload.get("expected_main_oid") != policy.expected_main_oid:
|
||||
_fail("OWNER_MERGE_APPROVAL_BASE_BINDING_INVALID")
|
||||
if payload.get("policy_digest") != policy.digest:
|
||||
_fail("OWNER_MERGE_POLICY_DIGEST_DRIFT")
|
||||
if namespace != OWNER_NAMESPACE or any(payload.get(key) != value for key, value in expected.items()):
|
||||
_fail("OWNER_MERGE_APPROVAL_SCOPE_INVALID")
|
||||
for field in ("approval_id", "nonce"):
|
||||
if not isinstance(payload.get(field), str) or TOKEN_RE.fullmatch(payload[field]) is None:
|
||||
_fail("OWNER_MERGE_APPROVAL_TOKEN_INVALID")
|
||||
for field in ("issued_at", "not_before", "expires_at"):
|
||||
if type(payload.get(field)) is not int:
|
||||
_fail("OWNER_MERGE_APPROVAL_TIME_INVALID")
|
||||
if not payload["not_before"] <= now < payload["expires_at"]:
|
||||
_fail("OWNER_MERGE_APPROVAL_EXPIRED")
|
||||
if payload["issued_at"] > now + 5 or payload["expires_at"] - payload["issued_at"] != policy.maximum_ttl_seconds:
|
||||
_fail("OWNER_MERGE_APPROVAL_TIME_INVALID")
|
||||
supplied = payload.get("canonical_payload_digest")
|
||||
body = dict(payload)
|
||||
body.pop("canonical_payload_digest", None)
|
||||
if supplied != sha256_json(body):
|
||||
_fail("OWNER_MERGE_APPROVAL_CANONICAL_DIGEST_INVALID")
|
||||
|
||||
def verify(
|
||||
self,
|
||||
artifact: SignedArtifact | Mapping[str, Any],
|
||||
policy: OwnerMergeApprovalPolicy,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
policy.validate()
|
||||
payload, signature, key_id, namespace = self._artifact(artifact)
|
||||
canonical = canonical_json_bytes(payload)
|
||||
proof_path: str | None = None
|
||||
for path, verifier in (("primary", self.primary), ("recovery", self.recovery)):
|
||||
if verifier is None:
|
||||
continue
|
||||
try:
|
||||
verified = verifier.verify(
|
||||
namespace=namespace,
|
||||
payload=canonical,
|
||||
signature=signature,
|
||||
key_id=key_id,
|
||||
)
|
||||
except Exception:
|
||||
verified = False
|
||||
if verified is True:
|
||||
proof_path = path
|
||||
break
|
||||
if proof_path is None:
|
||||
_fail("OWNER_MERGE_APPROVAL_SIGNATURE_INVALID")
|
||||
self._validate_scope(payload, policy, now=int(self.clock()), key_id=key_id, namespace=namespace)
|
||||
return payload, proof_path
|
||||
|
||||
|
||||
class SQLiteOwnerMergeNonceLedger:
|
||||
"""Durable one-shot fence. Unknown effects are intentionally not retried."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
|
||||
def _connection(self) -> sqlite3.Connection:
|
||||
if self.path.is_symlink() or self.path.parent.is_symlink():
|
||||
_fail("OWNER_MERGE_NONCE_LEDGER_PATH_INVALID")
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
connection = sqlite3.connect(self.path, timeout=5, isolation_level=None)
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS owner_merge_nonce ("
|
||||
"nonce TEXT PRIMARY KEY, approval_digest TEXT NOT NULL, state TEXT NOT NULL, claimed_at INTEGER NOT NULL)"
|
||||
)
|
||||
return connection
|
||||
|
||||
def claim(self, *, nonce: str, approval_digest: str, now: int) -> None:
|
||||
connection = self._connection()
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute(
|
||||
"SELECT state FROM owner_merge_nonce WHERE nonce=?", (nonce,)
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_NONCE_REPLAY")
|
||||
connection.execute(
|
||||
"INSERT INTO owner_merge_nonce(nonce,approval_digest,state,claimed_at) VALUES(?,?,?,?)",
|
||||
(nonce, approval_digest, "PENDING", now),
|
||||
)
|
||||
connection.commit()
|
||||
except OwnerMergeApprovalError:
|
||||
raise
|
||||
except sqlite3.Error as exc:
|
||||
try:
|
||||
connection.rollback()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_NONCE_LEDGER_UNAVAILABLE") from exc
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def mark(self, *, nonce: str, state: str) -> None:
|
||||
if state not in {"COMPLETED", "UNKNOWN"}:
|
||||
_fail("OWNER_MERGE_NONCE_STATE_INVALID")
|
||||
connection = self._connection()
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
updated = connection.execute(
|
||||
"UPDATE owner_merge_nonce SET state=? WHERE nonce=? AND state='PENDING'",
|
||||
(state, nonce),
|
||||
).rowcount
|
||||
if updated != 1:
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_NONCE_STATE_CONFLICT")
|
||||
connection.commit()
|
||||
except OwnerMergeApprovalError:
|
||||
raise
|
||||
except sqlite3.Error as exc:
|
||||
try:
|
||||
connection.rollback()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_NONCE_LEDGER_UNAVAILABLE") from exc
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
class GovernedOwnerMergeAdmission:
|
||||
"""Admit one protected merge without owning any policy or technical gate."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
policy: OwnerMergeApprovalPolicy,
|
||||
verifier: OwnerMergeApprovalVerifier,
|
||||
live_state_reader: Callable[[], OwnerMergeLiveState],
|
||||
executor: GovernedMergeExecutor,
|
||||
reconciler: MergeOidReconciler,
|
||||
nonce_ledger: SQLiteOwnerMergeNonceLedger,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
policy.validate()
|
||||
self.policy = policy
|
||||
self.verifier = verifier
|
||||
self.live_state_reader = live_state_reader
|
||||
self.executor = executor
|
||||
self.reconciler = reconciler
|
||||
self.nonce_ledger = nonce_ledger
|
||||
self.clock = clock
|
||||
|
||||
def _validate_live_state(self, live: OwnerMergeLiveState) -> None:
|
||||
if not isinstance(live, OwnerMergeLiveState):
|
||||
_fail("OWNER_MERGE_LIVE_STATE_INVALID")
|
||||
policy = self.policy
|
||||
if live.repository != policy.repository:
|
||||
_fail("OWNER_MERGE_REPOSITORY_DRIFT")
|
||||
if live.pr_number != policy.pr_number:
|
||||
_fail("OWNER_MERGE_PR_BINDING_DRIFT")
|
||||
if live.pr_head_oid != policy.pr_head_oid:
|
||||
_fail("OWNER_MERGE_HEAD_DRIFT")
|
||||
if live.main_oid != policy.expected_main_oid:
|
||||
_fail("OWNER_MERGE_BASE_DRIFT")
|
||||
if live.required_check_contract_digest != policy.required_check_contract_digest:
|
||||
_fail("OWNER_MERGE_REQUIRED_CHECK_CONTRACT_DRIFT")
|
||||
if not isinstance(live.check_statuses, Mapping) or set(live.check_statuses) != set(policy.required_checks):
|
||||
_fail("OWNER_MERGE_REQUIRED_CHECK_CONTRACT_DRIFT")
|
||||
if any(value is not True for value in live.check_statuses.values()):
|
||||
_fail("OWNER_MERGE_REQUIRED_CHECKS_FAILED")
|
||||
|
||||
def admit(self, artifact: SignedArtifact | Mapping[str, Any]) -> OwnerMergeAdmissionReceipt:
|
||||
approval, proof_path = self.verifier.verify(artifact, self.policy)
|
||||
self._validate_live_state(self.live_state_reader())
|
||||
nonce = str(approval["nonce"])
|
||||
approval_digest = hashlib.sha256(canonical_json_bytes(approval)).hexdigest()
|
||||
self.nonce_ledger.claim(nonce=nonce, approval_digest=approval_digest, now=int(self.clock()))
|
||||
try:
|
||||
merge_oid = self.executor.execute(dict(approval))
|
||||
_require_oid(merge_oid, "OWNER_MERGE_EXECUTOR_RESULT_INVALID")
|
||||
readback = self.reconciler.readback(merge_oid)
|
||||
if (
|
||||
not isinstance(readback, OwnerMergeOidReadback)
|
||||
or readback.authority_main_oid != merge_oid
|
||||
or readback.gitea_main_oid != merge_oid
|
||||
or readback.api_main_oid != merge_oid
|
||||
):
|
||||
self.nonce_ledger.mark(nonce=nonce, state="UNKNOWN")
|
||||
_fail("OWNER_MERGE_OID_RECONCILIATION_REQUIRED")
|
||||
except OwnerMergeApprovalError:
|
||||
# Every failure after a durable claim represents an operation that
|
||||
# is either known failed or needs reconciliation. Preserve the
|
||||
# one-shot fence rather than exposing a retryable PENDING state.
|
||||
try:
|
||||
self.nonce_ledger.mark(nonce=nonce, state="UNKNOWN")
|
||||
except OwnerMergeApprovalError:
|
||||
pass
|
||||
raise
|
||||
except Exception as exc:
|
||||
try:
|
||||
self.nonce_ledger.mark(nonce=nonce, state="UNKNOWN")
|
||||
except OwnerMergeApprovalError:
|
||||
pass
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_EXECUTION_UNKNOWN") from exc
|
||||
self.nonce_ledger.mark(nonce=nonce, state="COMPLETED")
|
||||
return OwnerMergeAdmissionReceipt(
|
||||
approval_id=str(approval["approval_id"]),
|
||||
nonce=nonce,
|
||||
owner_identity=str(approval["owner_identity"]),
|
||||
policy_digest=str(approval["policy_digest"]),
|
||||
proof_path=proof_path,
|
||||
merge_oid=merge_oid,
|
||||
)
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Restricted issuance service for ``aios.owner-merge-approval.v2``.
|
||||
|
||||
The service has one Owner-only operation. It accepts an empty request payload,
|
||||
loads one immutable policy, generates approval identifiers and nonces itself,
|
||||
and stores one exact issuance response. It deliberately does not expose a
|
||||
generic signing, payload, or policy-selection API.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import stat
|
||||
import time
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_owner_merge_authorization import (
|
||||
OwnerMergeApprovalAuthority,
|
||||
OwnerMergeApprovalError,
|
||||
OwnerMergeApprovalPolicy,
|
||||
OwnerMergeApprovalVerifier,
|
||||
)
|
||||
from aios_single_host_authority import RegistryOwnerApprovalVerifier, SignedArtifact
|
||||
|
||||
|
||||
ISSUE_OPERATION = "issue_owner_merge_approval"
|
||||
|
||||
|
||||
def _fail(code: str) -> None:
|
||||
raise OwnerMergeApprovalError(code)
|
||||
|
||||
|
||||
def _secure_regular(path: Path, *, require_root_owner: bool) -> None:
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_FILE_MISSING") from exc
|
||||
if path.is_symlink() or not stat.S_ISREG(metadata.st_mode):
|
||||
_fail("OWNER_MERGE_ISSUER_FILE_INVALID")
|
||||
if stat.S_IMODE(metadata.st_mode) & 0o022:
|
||||
_fail("OWNER_MERGE_ISSUER_FILE_WRITABLE")
|
||||
if require_root_owner and metadata.st_uid != 0:
|
||||
_fail("OWNER_MERGE_ISSUER_FILE_NOT_ROOT_OWNED")
|
||||
|
||||
|
||||
def _read_policy(path: Path, *, require_root_owner: bool) -> OwnerMergeApprovalPolicy:
|
||||
_secure_regular(path, require_root_owner=require_root_owner)
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_POLICY_INVALID") from exc
|
||||
if not isinstance(value, Mapping):
|
||||
_fail("OWNER_MERGE_ISSUER_POLICY_INVALID")
|
||||
expected = set(OwnerMergeApprovalPolicy.__dataclass_fields__)
|
||||
if set(value) != expected or not isinstance(value.get("required_checks"), list):
|
||||
_fail("OWNER_MERGE_ISSUER_POLICY_INVALID")
|
||||
try:
|
||||
policy = OwnerMergeApprovalPolicy(
|
||||
repository=value["repository"],
|
||||
pr_number=value["pr_number"],
|
||||
pr_head_oid=value["pr_head_oid"],
|
||||
expected_main_oid=value["expected_main_oid"],
|
||||
operation=value["operation"],
|
||||
required_check_contract_digest=value["required_check_contract_digest"],
|
||||
required_checks=tuple(value["required_checks"]),
|
||||
owner_identity=value["owner_identity"],
|
||||
audit_lineage_digest=value["audit_lineage_digest"],
|
||||
maximum_ttl_seconds=value["maximum_ttl_seconds"],
|
||||
policy_version=value["policy_version"],
|
||||
schema=value["schema"],
|
||||
)
|
||||
policy.validate()
|
||||
except (KeyError, TypeError, OwnerMergeApprovalError) as exc:
|
||||
if isinstance(exc, OwnerMergeApprovalError):
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_POLICY_INVALID") from exc
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_POLICY_INVALID") from exc
|
||||
return policy
|
||||
|
||||
|
||||
def _artifact_dict(artifact: SignedArtifact) -> dict[str, Any]:
|
||||
return {
|
||||
"payload": dict(artifact.payload),
|
||||
"signature": artifact.signature,
|
||||
"key_id": artifact.key_id,
|
||||
"namespace": artifact.namespace,
|
||||
}
|
||||
|
||||
|
||||
class SQLiteOwnerMergeIssuanceLedger:
|
||||
"""Durably replay a current approval and retain expired issuance history."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
if self.path.is_symlink() or self.path.parent.is_symlink():
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_PATH_INVALID")
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
connection = sqlite3.connect(self.path, timeout=5, isolation_level=None)
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS owner_merge_issuance ("
|
||||
"nonce TEXT PRIMARY KEY, policy_digest TEXT NOT NULL, response_json TEXT NOT NULL, "
|
||||
"issued_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS owner_merge_issuance_policy_expiry "
|
||||
"ON owner_merge_issuance(policy_digest, expires_at)"
|
||||
)
|
||||
return connection
|
||||
|
||||
def issue_once(
|
||||
self,
|
||||
*,
|
||||
policy_digest: str,
|
||||
now: int,
|
||||
issuer: Callable[[], dict[str, Any]],
|
||||
validate_current: Callable[[dict[str, Any]], None],
|
||||
expiry_of: Callable[[dict[str, Any]], int],
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
connection = self._connect()
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
rows = connection.execute(
|
||||
"SELECT response_json, expires_at FROM owner_merge_issuance "
|
||||
"WHERE policy_digest=? AND expires_at>? ORDER BY issued_at DESC",
|
||||
(policy_digest, now),
|
||||
).fetchall()
|
||||
if len(rows) > 1:
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
if rows:
|
||||
try:
|
||||
response = json.loads(rows[0][0])
|
||||
except (TypeError, json.JSONDecodeError) as exc:
|
||||
connection.rollback()
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_LEDGER_INVALID") from exc
|
||||
if not isinstance(response, dict):
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
if type(rows[0][1]) is not int or expiry_of(response) != rows[0][1]:
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
validate_current(response)
|
||||
connection.rollback()
|
||||
return response, True
|
||||
response = issuer()
|
||||
expires_at = expiry_of(response)
|
||||
if expires_at <= now:
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_ISSUER_RESPONSE_EXPIRED")
|
||||
artifact = response.get("artifact")
|
||||
if not isinstance(artifact, Mapping):
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
payload = artifact.get("payload")
|
||||
if not isinstance(payload, Mapping) or not isinstance(payload.get("nonce"), str):
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
encoded = json.dumps(
|
||||
response,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO owner_merge_issuance(nonce,policy_digest,response_json,issued_at,expires_at) "
|
||||
"VALUES(?,?,?,?,?)",
|
||||
(payload["nonce"], policy_digest, encoded, now, expires_at),
|
||||
)
|
||||
connection.commit()
|
||||
return response, False
|
||||
except OwnerMergeApprovalError:
|
||||
raise
|
||||
except sqlite3.IntegrityError as exc:
|
||||
try:
|
||||
connection.rollback()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_NONCE_CONFLICT") from exc
|
||||
except sqlite3.Error as exc:
|
||||
try:
|
||||
connection.rollback()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_LEDGER_UNAVAILABLE") from exc
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
class OwnerMergeApprovalService:
|
||||
"""A formal structured adapter, not an arbitrary signing oracle."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
policy_path: Path,
|
||||
registry_path: Path,
|
||||
owner_key_path: Path,
|
||||
issuance_ledger_path: Path,
|
||||
allowed_uids: tuple[int, ...],
|
||||
require_root_owner: bool = True,
|
||||
clock: Callable[[], float] = time.time,
|
||||
token_urlsafe: Callable[[int], str] | None = None,
|
||||
) -> None:
|
||||
if not allowed_uids or any(type(uid) is not int or uid < 0 for uid in allowed_uids):
|
||||
_fail("OWNER_MERGE_ISSUER_ALLOWED_UIDS_INVALID")
|
||||
self.policy_path = Path(policy_path)
|
||||
self.registry_path = Path(registry_path)
|
||||
self.owner_key_path = Path(owner_key_path)
|
||||
self.allowed_uids = tuple(allowed_uids)
|
||||
self.require_root_owner = require_root_owner
|
||||
self.clock = clock
|
||||
self.token_urlsafe = token_urlsafe
|
||||
self.ledger = SQLiteOwnerMergeIssuanceLedger(issuance_ledger_path)
|
||||
|
||||
def handle(self, request: Mapping[str, Any], *, peer_uid: int) -> dict[str, Any]:
|
||||
if peer_uid not in self.allowed_uids:
|
||||
_fail("OWNER_MERGE_ISSUER_PEER_UID_FORBIDDEN")
|
||||
if not isinstance(request, Mapping) or set(request) != {"operation", "payload"}:
|
||||
_fail("OWNER_MERGE_ISSUER_REQUEST_INVALID")
|
||||
if request.get("operation") != ISSUE_OPERATION:
|
||||
_fail("OWNER_MERGE_ISSUER_OPERATION_FORBIDDEN")
|
||||
if request.get("payload") != {}:
|
||||
_fail("OWNER_MERGE_ISSUER_REQUEST_FIELDS_INVALID")
|
||||
|
||||
policy = _read_policy(
|
||||
self.policy_path,
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
_secure_regular(
|
||||
self.owner_key_path,
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
primary = RegistryOwnerApprovalVerifier(
|
||||
self.registry_path,
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
verifier = OwnerMergeApprovalVerifier(primary=primary, clock=self.clock)
|
||||
|
||||
def expiry_of(response: dict[str, Any]) -> int:
|
||||
if set(response) != {"status", "artifact"} or response.get("status") != "PASS":
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
artifact = response.get("artifact")
|
||||
if not isinstance(artifact, Mapping):
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
payload = artifact.get("payload")
|
||||
if not isinstance(payload, Mapping) or type(payload.get("expires_at")) is not int:
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
return payload["expires_at"]
|
||||
|
||||
def validate_current(response: dict[str, Any]) -> None:
|
||||
expiry_of(response)
|
||||
artifact = response["artifact"]
|
||||
if not isinstance(artifact, Mapping):
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
try:
|
||||
verifier.verify(artifact, policy)
|
||||
except OwnerMergeApprovalError as exc:
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_LEDGER_INVALID") from exc
|
||||
|
||||
def issue() -> dict[str, Any]:
|
||||
kwargs: dict[str, Any] = {}
|
||||
if self.token_urlsafe is not None:
|
||||
kwargs["token_urlsafe"] = self.token_urlsafe
|
||||
artifact = OwnerMergeApprovalAuthority(
|
||||
self.owner_key_path,
|
||||
policy,
|
||||
require_root_owner=self.require_root_owner,
|
||||
clock=self.clock,
|
||||
**kwargs,
|
||||
).issue()
|
||||
value = _artifact_dict(artifact)
|
||||
verifier.verify(value, policy)
|
||||
return {"status": "PASS", "artifact": value}
|
||||
|
||||
response, idempotent = self.ledger.issue_once(
|
||||
policy_digest=policy.digest,
|
||||
now=int(self.clock()),
|
||||
issuer=issue,
|
||||
validate_current=validate_current,
|
||||
expiry_of=expiry_of,
|
||||
)
|
||||
if set(response) != {"status", "artifact"} or response.get("status") != "PASS":
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
return {**response, "idempotent": idempotent}
|
||||
@@ -0,0 +1,967 @@
|
||||
"""Bounded, read-only discovery over a verified immutable Git repository.
|
||||
|
||||
The module is deliberately independent from Task, lease, credential, worktree,
|
||||
and write code. Every public operation verifies the exact repository identity,
|
||||
resolves a floating ref once to a full commit OID, and then reads only that
|
||||
immutable snapshot.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from collections.abc import Iterable, Iterator, Mapping
|
||||
import fnmatch
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from authoritative_git import AuthoritativeGitError, verify_repository
|
||||
|
||||
|
||||
_OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_REF_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,239}$")
|
||||
_IDENTITY_RE = re.compile(
|
||||
r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
|
||||
)
|
||||
_READ_ONLY_COMMANDS = frozenset({"cat-file", "diff", "ls-tree", "rev-parse"})
|
||||
_MAX_COMMAND_OUTPUT = 1_048_576
|
||||
_MAX_RECORD_BYTES = 1_048_576
|
||||
_MAX_LIMIT = 1_000
|
||||
_MAX_DEPTH = 32
|
||||
_MAX_BYTES = 1_048_576
|
||||
_MAX_GLOBS = 16
|
||||
_MAX_QUERY_LENGTH = 512
|
||||
_MAX_CURSOR_LENGTH = 4_096
|
||||
_GIT_TIMEOUT_SECONDS = 8.0
|
||||
|
||||
|
||||
class RepositoryDiscoveryError(RuntimeError):
|
||||
"""A fail-closed repository discovery request error."""
|
||||
|
||||
def __init__(self, code: str, *, status_code: int | None = None) -> None:
|
||||
self.code = str(code)
|
||||
self.status_code = (
|
||||
status_code if status_code is not None else self._status_for(self.code)
|
||||
)
|
||||
super().__init__(self.code)
|
||||
|
||||
@staticmethod
|
||||
def _status_for(code: str) -> int:
|
||||
if "OUTPUT_LIMIT" in code:
|
||||
return 413
|
||||
if code.endswith("_NOT_FOUND"):
|
||||
return 404
|
||||
if code in {"REPOSITORY_NOT_ALLOWED", "REPOSITORY_IDENTITY_MISMATCH"}:
|
||||
return 403
|
||||
if code in {
|
||||
"REPOSITORY_DISCOVERY_UNAVAILABLE",
|
||||
"REPOSITORY_DISCOVERY_GIT_FAILED",
|
||||
"REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID",
|
||||
"REPOSITORY_DISCOVERY_CURSOR_SECRET_REQUIRED",
|
||||
}:
|
||||
return 503
|
||||
return 400
|
||||
|
||||
|
||||
class RepositoryDiscoveryNotFound(RepositoryDiscoveryError):
|
||||
"""A validated immutable object or path was not found."""
|
||||
|
||||
|
||||
class RepositoryDiscoveryOutputTooLarge(RepositoryDiscoveryError):
|
||||
"""A fixed read-only Git query exceeded its collection boundary."""
|
||||
|
||||
|
||||
def _error(code: str) -> RepositoryDiscoveryError:
|
||||
return RepositoryDiscoveryError(code)
|
||||
|
||||
|
||||
def _request_mapping(request: Mapping[str, object]) -> Mapping[str, object]:
|
||||
if not isinstance(request, Mapping):
|
||||
raise _error("REPOSITORY_DISCOVERY_REQUEST_INVALID")
|
||||
return request
|
||||
|
||||
|
||||
def _required_request_value(request: Mapping[str, object], field: str) -> object:
|
||||
value = request.get(field)
|
||||
if value is None:
|
||||
raise _error("REPOSITORY_DISCOVERY_REQUEST_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _canonical(value: Mapping[str, object]) -> bytes:
|
||||
return json.dumps(
|
||||
value, sort_keys=True, separators=(",", ":"), ensure_ascii=True
|
||||
).encode("ascii")
|
||||
|
||||
|
||||
def _positive_int(value: object, field: str, maximum: int) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= maximum:
|
||||
raise _error(f"REPOSITORY_DISCOVERY_INVALID_{field.upper()}")
|
||||
return value
|
||||
|
||||
|
||||
def _safe_relative_path(
|
||||
value: object,
|
||||
field: str,
|
||||
*,
|
||||
allow_empty: bool,
|
||||
allow_glob: bool = False,
|
||||
) -> str:
|
||||
if not isinstance(value, str) or value != value.strip() or "\x00" in value:
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
if "\\" in value:
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
if value in ("", "."):
|
||||
if allow_empty:
|
||||
return ""
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
if value.startswith(("/", "-")) or "//" in value or ":" in value:
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
if not allow_glob and any(character in value for character in "*?["):
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
parts = value.split("/")
|
||||
if any(part in ("", ".", "..") for part in parts):
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
if len(value) > 512:
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _safe_glob(value: object) -> str:
|
||||
return _safe_relative_path(value, "path_glob", allow_empty=False, allow_glob=True)
|
||||
|
||||
|
||||
def _safe_ref(value: str) -> bool:
|
||||
return bool(
|
||||
_REF_RE.fullmatch(value)
|
||||
and ".." not in value
|
||||
and "//" not in value
|
||||
and "@{" not in value
|
||||
and not value.endswith(("/", ".", ".lock"))
|
||||
and not any(part in ("", ".", "..") for part in value.split("/"))
|
||||
)
|
||||
|
||||
|
||||
def _parse_tree_record(record: bytes) -> dict[str, str] | None:
|
||||
try:
|
||||
header, raw_path = record.split(b"\t", 1)
|
||||
mode, kind, oid = header.decode("ascii").split(" ", 2)
|
||||
path = raw_path.decode("utf-8")
|
||||
except (UnicodeDecodeError, ValueError) as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
if kind not in {"blob", "tree", "commit"} or not _OID_RE.fullmatch(oid.lower()):
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
try:
|
||||
_safe_relative_path(path, "tree_path", allow_empty=False)
|
||||
except RepositoryDiscoveryError:
|
||||
# Git permits filenames outside the public POSIX-relative contract.
|
||||
# They are not exposed as usable discovery paths.
|
||||
return None
|
||||
return {"mode": mode, "type": kind, "object_oid": oid.lower(), "path": path}
|
||||
|
||||
|
||||
def _decode_output_path(raw: bytes) -> str:
|
||||
try:
|
||||
value = raw.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
return _safe_relative_path(value, "git_output_path", allow_empty=False)
|
||||
|
||||
|
||||
class RepositoryDiscovery:
|
||||
"""Repository-scoped immutable Git discovery with signed cursors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
allowed_repositories: Iterable[str] | None = None,
|
||||
git_dir: str | Path | None = None,
|
||||
cursor_secret: str | bytes | None = None,
|
||||
) -> None:
|
||||
configured = allowed_repositories
|
||||
if configured is None:
|
||||
configured = (os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY", ""),)
|
||||
identities = tuple(configured)
|
||||
if not identities or any(
|
||||
not isinstance(item, str) or not _IDENTITY_RE.fullmatch(item)
|
||||
for item in identities
|
||||
):
|
||||
raise _error("REPOSITORY_DISCOVERY_ALLOWLIST_INVALID")
|
||||
self._allowed_repositories = frozenset(identities)
|
||||
self._git_dir = git_dir
|
||||
secret = (
|
||||
cursor_secret
|
||||
if cursor_secret is not None
|
||||
else os.getenv("AIOS_REPOSITORY_DISCOVERY_CURSOR_SECRET", "")
|
||||
)
|
||||
if isinstance(secret, str):
|
||||
secret = secret.encode("utf-8")
|
||||
if not isinstance(secret, bytes) or len(secret) < 16:
|
||||
raise _error("REPOSITORY_DISCOVERY_CURSOR_SECRET_REQUIRED")
|
||||
self._cursor_secret = secret
|
||||
|
||||
def _repository_path(self, repository: object) -> Path:
|
||||
if not isinstance(repository, str) or repository not in self._allowed_repositories:
|
||||
raise _error("REPOSITORY_NOT_ALLOWED")
|
||||
try:
|
||||
path, actual_identity = verify_repository(
|
||||
git_dir=self._git_dir,
|
||||
repository_identity=repository,
|
||||
)
|
||||
except AuthoritativeGitError as exc:
|
||||
code = (
|
||||
"REPOSITORY_IDENTITY_MISMATCH"
|
||||
if "REPOSITORY_IDENTITY_MISMATCH" in str(exc)
|
||||
else "REPOSITORY_DISCOVERY_UNAVAILABLE"
|
||||
)
|
||||
raise _error(code) from exc
|
||||
if actual_identity != repository:
|
||||
raise _error("REPOSITORY_IDENTITY_MISMATCH")
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def _git_env() -> dict[str, str]:
|
||||
return {
|
||||
**os.environ,
|
||||
"GIT_OPTIONAL_LOCKS": "0",
|
||||
"GIT_CONFIG_NOSYSTEM": "1",
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _run(
|
||||
path: Path,
|
||||
args: list[str],
|
||||
*,
|
||||
max_output: int = _MAX_COMMAND_OUTPUT,
|
||||
) -> bytes:
|
||||
if not args or args[0] not in _READ_ONLY_COMMANDS:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_COMMAND_REJECTED")
|
||||
output_limit = min(max_output, _MAX_COMMAND_OUTPUT)
|
||||
process: subprocess.Popen[bytes] | None = None
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
["git", "--git-dir", str(path), *args],
|
||||
shell=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=RepositoryDiscovery._git_env(),
|
||||
)
|
||||
assert process.stdout is not None
|
||||
output = process.stdout.read(output_limit + 1)
|
||||
if len(output) > output_limit:
|
||||
process.kill()
|
||||
process.wait(timeout=1)
|
||||
raise RepositoryDiscoveryOutputTooLarge(
|
||||
"REPOSITORY_DISCOVERY_OUTPUT_LIMIT"
|
||||
)
|
||||
returncode = process.wait(timeout=_GIT_TIMEOUT_SECONDS)
|
||||
except RepositoryDiscoveryError:
|
||||
raise
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
if process is not None:
|
||||
try:
|
||||
process.kill()
|
||||
process.wait(timeout=1)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED") from exc
|
||||
if returncode != 0:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED")
|
||||
return output
|
||||
|
||||
@staticmethod
|
||||
def _read_blob_prefix(path: Path, oid: str, maximum: int) -> tuple[bytes, bool]:
|
||||
"""Read at most maximum bytes plus one sentinel byte."""
|
||||
process: subprocess.Popen[bytes] | None = None
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
["git", "--git-dir", str(path), "cat-file", "blob", oid],
|
||||
shell=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=RepositoryDiscovery._git_env(),
|
||||
)
|
||||
assert process.stdout is not None
|
||||
content = process.stdout.read(maximum + 1)
|
||||
truncated = len(content) > maximum
|
||||
if truncated:
|
||||
process.kill()
|
||||
process.wait(timeout=1)
|
||||
return content[:maximum], True
|
||||
if process.wait(timeout=_GIT_TIMEOUT_SECONDS) != 0:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED")
|
||||
return content, False
|
||||
except RepositoryDiscoveryError:
|
||||
raise
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
if process is not None:
|
||||
try:
|
||||
process.kill()
|
||||
process.wait(timeout=1)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED") from exc
|
||||
|
||||
def _iter_nul_records(self, path: Path, args: list[str]) -> Iterator[bytes]:
|
||||
if not args or args[0] not in _READ_ONLY_COMMANDS:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_COMMAND_REJECTED")
|
||||
process: subprocess.Popen[bytes] | None = None
|
||||
buffer = b""
|
||||
started = time.monotonic()
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
["git", "--git-dir", str(path), *args],
|
||||
shell=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=self._git_env(),
|
||||
)
|
||||
assert process.stdout is not None
|
||||
while True:
|
||||
if time.monotonic() - started > _GIT_TIMEOUT_SECONDS:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED")
|
||||
chunk = process.stdout.read(64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
buffer += chunk
|
||||
if len(buffer) > _MAX_RECORD_BYTES and b"\0" not in buffer:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
while b"\0" in buffer:
|
||||
record, buffer = buffer.split(b"\0", 1)
|
||||
if len(record) > _MAX_RECORD_BYTES:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
yield record
|
||||
if buffer:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
if process.wait(
|
||||
timeout=max(0.1, _GIT_TIMEOUT_SECONDS - (time.monotonic() - started))
|
||||
) != 0:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED")
|
||||
except RepositoryDiscoveryError:
|
||||
raise
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED") from exc
|
||||
finally:
|
||||
if process is not None and process.poll() is None:
|
||||
try:
|
||||
process.kill()
|
||||
process.wait(timeout=1)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
def _resolve_at(self, path: Path, revision: object, field: str) -> str:
|
||||
if (
|
||||
not isinstance(revision, str)
|
||||
or revision != revision.strip()
|
||||
or not revision
|
||||
or revision.startswith("-")
|
||||
):
|
||||
raise _error("REPOSITORY_DISCOVERY_INVALID_REVISION")
|
||||
if _OID_RE.fullmatch(revision.lower()):
|
||||
candidates = [revision.lower()]
|
||||
elif revision == "HEAD":
|
||||
candidates = ["HEAD"]
|
||||
elif _safe_ref(revision) and not any(
|
||||
token in revision for token in ("^", "~", ":", "\\")
|
||||
):
|
||||
if revision.startswith("refs/"):
|
||||
if not revision.startswith(("refs/heads/", "refs/tags/")):
|
||||
raise _error("REPOSITORY_DISCOVERY_INVALID_REVISION")
|
||||
candidates = [revision]
|
||||
else:
|
||||
candidates = [f"refs/heads/{revision}", f"refs/tags/{revision}"]
|
||||
else:
|
||||
raise _error("REPOSITORY_DISCOVERY_INVALID_REVISION")
|
||||
resolved: list[str] = []
|
||||
for candidate in candidates:
|
||||
try:
|
||||
output = self._run(
|
||||
path,
|
||||
["rev-parse", "--verify", f"{candidate}^{{commit}}"],
|
||||
max_output=256,
|
||||
)
|
||||
except RepositoryDiscoveryError:
|
||||
continue
|
||||
oid = output.decode("ascii", errors="ignore").strip().lower()
|
||||
if _OID_RE.fullmatch(oid):
|
||||
resolved.append(oid)
|
||||
if not resolved:
|
||||
raise RepositoryDiscoveryNotFound(
|
||||
f"REPOSITORY_DISCOVERY_{field.upper()}_NOT_FOUND"
|
||||
)
|
||||
if len(set(resolved)) != 1:
|
||||
raise _error("REPOSITORY_DISCOVERY_AMBIGUOUS_REVISION")
|
||||
return resolved[0]
|
||||
|
||||
def resolve_revision(self, repository: str, revision: str) -> str:
|
||||
return self._resolve_at(self._repository_path(repository), revision, "revision")
|
||||
|
||||
def _cursor_offset(
|
||||
self,
|
||||
cursor: str | None,
|
||||
*,
|
||||
operation: str,
|
||||
repository: str,
|
||||
resolved_oid: str,
|
||||
query: Mapping[str, object],
|
||||
) -> int:
|
||||
if cursor is None:
|
||||
return 0
|
||||
if (
|
||||
not isinstance(cursor, str)
|
||||
or not cursor
|
||||
or len(cursor) > _MAX_CURSOR_LENGTH
|
||||
or "." not in cursor
|
||||
):
|
||||
raise _error("REPOSITORY_DISCOVERY_CURSOR_INVALID")
|
||||
encoded, supplied_signature = cursor.split(".", 1)
|
||||
try:
|
||||
padded = encoded + "=" * (-len(encoded) % 4)
|
||||
payload = json.loads(
|
||||
base64.urlsafe_b64decode(padded.encode("ascii")).decode("ascii")
|
||||
)
|
||||
except (UnicodeError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_CURSOR_INVALID") from exc
|
||||
expected_signature = hmac.new(
|
||||
self._cursor_secret, encoded.encode("ascii"), hashlib.sha256
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(supplied_signature, expected_signature):
|
||||
raise _error("REPOSITORY_DISCOVERY_CURSOR_INVALID")
|
||||
expected = {
|
||||
"operation": operation,
|
||||
"repository": repository,
|
||||
"resolved_oid": resolved_oid,
|
||||
"query": dict(query),
|
||||
}
|
||||
if (
|
||||
not isinstance(payload, dict)
|
||||
or payload.get("v") != 1
|
||||
or any(payload.get(key) != value for key, value in expected.items())
|
||||
):
|
||||
raise _error("REPOSITORY_DISCOVERY_CURSOR_INVALID")
|
||||
offset = payload.get("offset")
|
||||
if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
|
||||
raise _error("REPOSITORY_DISCOVERY_CURSOR_INVALID")
|
||||
return offset
|
||||
|
||||
def _next_cursor(
|
||||
self,
|
||||
*,
|
||||
operation: str,
|
||||
repository: str,
|
||||
resolved_oid: str,
|
||||
query: Mapping[str, object],
|
||||
offset: int,
|
||||
) -> str:
|
||||
payload = {
|
||||
"v": 1,
|
||||
"operation": operation,
|
||||
"repository": repository,
|
||||
"resolved_oid": resolved_oid,
|
||||
"query": dict(query),
|
||||
"offset": offset,
|
||||
}
|
||||
encoded = base64.urlsafe_b64encode(_canonical(payload)).decode("ascii").rstrip("=")
|
||||
signature = hmac.new(
|
||||
self._cursor_secret, encoded.encode("ascii"), hashlib.sha256
|
||||
).hexdigest()
|
||||
return f"{encoded}.{signature}"
|
||||
|
||||
def _iter_tree_entries(
|
||||
self,
|
||||
path: Path,
|
||||
oid: str,
|
||||
prefix: str,
|
||||
*,
|
||||
include_trees: bool,
|
||||
) -> Iterator[dict[str, str]]:
|
||||
args = ["ls-tree", "-z", "-r", "--full-tree"]
|
||||
if include_trees:
|
||||
args.append("-t")
|
||||
args.append(oid)
|
||||
if prefix:
|
||||
args.extend(["--", prefix])
|
||||
for record in self._iter_nul_records(path, args):
|
||||
entry = _parse_tree_record(record)
|
||||
if entry is None:
|
||||
continue
|
||||
if not prefix or entry["path"] == prefix or entry["path"].startswith(prefix + "/"):
|
||||
yield entry
|
||||
|
||||
def list_tree(
|
||||
self,
|
||||
*,
|
||||
repository: str,
|
||||
revision: str,
|
||||
prefix: str = "",
|
||||
depth: int = 8,
|
||||
limit: int = 100,
|
||||
cursor: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
prefix = _safe_relative_path(prefix, "prefix", allow_empty=True)
|
||||
depth = _positive_int(depth, "depth", _MAX_DEPTH)
|
||||
limit = _positive_int(limit, "limit", _MAX_LIMIT)
|
||||
path = self._repository_path(repository)
|
||||
resolved_oid = self._resolve_at(path, revision, "revision")
|
||||
query = {"prefix": prefix, "depth": depth, "limit": limit}
|
||||
offset = self._cursor_offset(
|
||||
cursor,
|
||||
operation="list_tree",
|
||||
repository=repository,
|
||||
resolved_oid=resolved_oid,
|
||||
query=query,
|
||||
)
|
||||
page: list[dict[str, str]] = []
|
||||
matched = 0
|
||||
for entry in self._iter_tree_entries(path, resolved_oid, prefix, include_trees=True):
|
||||
relative = entry["path"][len(prefix):].lstrip("/") if prefix else entry["path"]
|
||||
if relative.count("/") + 1 > depth:
|
||||
continue
|
||||
if matched >= offset and len(page) < limit:
|
||||
page.append(entry)
|
||||
matched += 1
|
||||
has_more = offset + len(page) < matched
|
||||
return {
|
||||
"repository": repository,
|
||||
"resolved_oid": resolved_oid,
|
||||
"entries": page,
|
||||
"truncated": has_more,
|
||||
"next_cursor": (
|
||||
self._next_cursor(
|
||||
operation="list_tree",
|
||||
repository=repository,
|
||||
resolved_oid=resolved_oid,
|
||||
query=query,
|
||||
offset=offset + len(page),
|
||||
)
|
||||
if has_more
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
def list_repository_tree(self, request: Mapping[str, object]) -> dict[str, object]:
|
||||
request = _request_mapping(request)
|
||||
return self.list_tree(
|
||||
repository=_required_request_value(request, "repository"), # type: ignore[arg-type]
|
||||
revision=request.get("revision", request.get("ref", "HEAD")), # type: ignore[arg-type]
|
||||
prefix=request.get("prefix", ""), # type: ignore[arg-type]
|
||||
depth=request.get("depth", 8), # type: ignore[arg-type]
|
||||
limit=request.get("limit", 100), # type: ignore[arg-type]
|
||||
cursor=request.get("cursor"), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def _blob_size(self, path: Path, oid: str) -> int:
|
||||
output = self._run(path, ["cat-file", "-s", oid], max_output=128)
|
||||
try:
|
||||
size = int(output.decode("ascii").strip())
|
||||
except (UnicodeDecodeError, ValueError) as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
if size < 0:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
return size
|
||||
|
||||
def search_source(
|
||||
self,
|
||||
*,
|
||||
repository: str,
|
||||
revision: str,
|
||||
query: str,
|
||||
path_globs: Iterable[str] | None = None,
|
||||
max_matches: int = 100,
|
||||
max_bytes: int = 262_144,
|
||||
cursor: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
if (
|
||||
not isinstance(query, str)
|
||||
or not query
|
||||
or "\x00" in query
|
||||
or len(query) > _MAX_QUERY_LENGTH
|
||||
):
|
||||
raise _error("REPOSITORY_DISCOVERY_QUERY_INVALID")
|
||||
max_matches = _positive_int(max_matches, "max_matches", _MAX_LIMIT)
|
||||
max_bytes = _positive_int(max_bytes, "max_bytes", _MAX_BYTES)
|
||||
if path_globs is None:
|
||||
globs = ("*",)
|
||||
else:
|
||||
if isinstance(path_globs, (str, bytes)) or not isinstance(path_globs, Iterable):
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
globs = tuple(_safe_glob(item) for item in path_globs)
|
||||
if not globs or len(globs) > _MAX_GLOBS:
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
path = self._repository_path(repository)
|
||||
resolved_oid = self._resolve_at(path, revision, "revision")
|
||||
query_binding = {
|
||||
"query": query,
|
||||
"path_globs": list(globs),
|
||||
"max_matches": max_matches,
|
||||
"max_bytes": max_bytes,
|
||||
}
|
||||
offset = self._cursor_offset(
|
||||
cursor,
|
||||
operation="search_source",
|
||||
repository=repository,
|
||||
resolved_oid=resolved_oid,
|
||||
query=query_binding,
|
||||
)
|
||||
scanned_bytes = 0
|
||||
skipped_for_bound = False
|
||||
binary_blobs = 0
|
||||
matched = 0
|
||||
matches: list[dict[str, object]] = []
|
||||
has_more = False
|
||||
for entry in self._iter_tree_entries(path, resolved_oid, "", include_trees=False):
|
||||
if entry["type"] != "blob" or not any(
|
||||
fnmatch.fnmatchcase(entry["path"], pattern) for pattern in globs
|
||||
):
|
||||
continue
|
||||
blob_size = self._blob_size(path, entry["object_oid"])
|
||||
remaining = max_bytes - scanned_bytes
|
||||
if blob_size > remaining:
|
||||
skipped_for_bound = True
|
||||
continue
|
||||
raw, clipped = self._read_blob_prefix(path, entry["object_oid"], blob_size)
|
||||
if clipped:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED")
|
||||
scanned_bytes += len(raw)
|
||||
if b"\0" in raw:
|
||||
binary_blobs += 1
|
||||
continue
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
for number, line in enumerate(text.splitlines(), start=1):
|
||||
if query not in line:
|
||||
continue
|
||||
if matched < offset:
|
||||
matched += 1
|
||||
continue
|
||||
if len(matches) >= max_matches:
|
||||
has_more = True
|
||||
break
|
||||
matches.append(
|
||||
{
|
||||
"path": entry["path"],
|
||||
"line_start": number,
|
||||
"line_end": number,
|
||||
"blob_oid": entry["object_oid"],
|
||||
"snippet": line[:512],
|
||||
}
|
||||
)
|
||||
matched += 1
|
||||
if has_more:
|
||||
break
|
||||
return {
|
||||
"repository": repository,
|
||||
"resolved_oid": resolved_oid,
|
||||
"matches": matches,
|
||||
"scanned_bytes": scanned_bytes,
|
||||
"max_bytes": max_bytes,
|
||||
"binary_blobs_skipped": binary_blobs,
|
||||
"truncated": bool(has_more or skipped_for_bound),
|
||||
"next_cursor": (
|
||||
self._next_cursor(
|
||||
operation="search_source",
|
||||
repository=repository,
|
||||
resolved_oid=resolved_oid,
|
||||
query=query_binding,
|
||||
offset=offset + len(matches),
|
||||
)
|
||||
if has_more
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
def search_repository_source(self, request: Mapping[str, object]) -> dict[str, object]:
|
||||
request = _request_mapping(request)
|
||||
path_globs = request.get("path_globs")
|
||||
if path_globs is not None and (
|
||||
isinstance(path_globs, (str, bytes)) or not isinstance(path_globs, Iterable)
|
||||
):
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
return self.search_source(
|
||||
repository=_required_request_value(request, "repository"), # type: ignore[arg-type]
|
||||
revision=request.get("revision", request.get("ref", "HEAD")), # type: ignore[arg-type]
|
||||
query=_required_request_value(request, "query"), # type: ignore[arg-type]
|
||||
path_globs=path_globs, # type: ignore[arg-type]
|
||||
max_matches=request.get("max_matches", 100), # type: ignore[arg-type]
|
||||
max_bytes=request.get("max_bytes", 262_144), # type: ignore[arg-type]
|
||||
cursor=request.get("cursor"), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def _iter_diff_name_entries(
|
||||
self,
|
||||
path: Path,
|
||||
from_oid: str,
|
||||
to_oid: str,
|
||||
prefix: str,
|
||||
) -> Iterator[dict[str, object]]:
|
||||
suffix = ["--", prefix] if prefix else []
|
||||
records = self._iter_nul_records(
|
||||
path,
|
||||
[
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--find-renames=50%",
|
||||
"--name-status",
|
||||
"-z",
|
||||
from_oid,
|
||||
to_oid,
|
||||
*suffix,
|
||||
],
|
||||
)
|
||||
iterator = iter(records)
|
||||
while True:
|
||||
try:
|
||||
status_raw = next(iterator)
|
||||
except StopIteration:
|
||||
return
|
||||
try:
|
||||
status = status_raw.decode("ascii")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
if not status or status[0] not in {"A", "C", "D", "M", "R", "T", "U", "X"}:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
try:
|
||||
if status[0] in {"R", "C"}:
|
||||
old_path = _decode_output_path(next(iterator))
|
||||
new_path = _decode_output_path(next(iterator))
|
||||
yield {
|
||||
"status": status,
|
||||
"path": new_path,
|
||||
"old_path": old_path,
|
||||
}
|
||||
else:
|
||||
yield {
|
||||
"status": status,
|
||||
"path": _decode_output_path(next(iterator)),
|
||||
"old_path": None,
|
||||
}
|
||||
except StopIteration as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
|
||||
@staticmethod
|
||||
def _parse_numstat_count(value: bytes) -> int | None:
|
||||
if value == b"-":
|
||||
return None
|
||||
try:
|
||||
parsed = int(value.decode("ascii"), 10)
|
||||
except (UnicodeDecodeError, ValueError) as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
if parsed < 0:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
return parsed
|
||||
|
||||
def _diff_stats(
|
||||
self,
|
||||
path: Path,
|
||||
from_oid: str,
|
||||
to_oid: str,
|
||||
prefix: str,
|
||||
wanted_paths: set[str],
|
||||
) -> dict[str, tuple[int | None, int | None]]:
|
||||
suffix = ["--", prefix] if prefix else []
|
||||
records = self._iter_nul_records(
|
||||
path,
|
||||
[
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--find-renames=50%",
|
||||
"--numstat",
|
||||
"-z",
|
||||
from_oid,
|
||||
to_oid,
|
||||
*suffix,
|
||||
],
|
||||
)
|
||||
iterator = iter(records)
|
||||
stats: dict[str, tuple[int | None, int | None]] = {}
|
||||
for row in iterator:
|
||||
if not row:
|
||||
continue
|
||||
fields = row.split(b"\t", 2)
|
||||
if len(fields) != 3:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
additions = self._parse_numstat_count(fields[0])
|
||||
deletions = self._parse_numstat_count(fields[1])
|
||||
if fields[2]:
|
||||
stat_path = _decode_output_path(fields[2])
|
||||
else:
|
||||
try:
|
||||
next(iterator) # old path
|
||||
stat_path = _decode_output_path(next(iterator))
|
||||
except StopIteration as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
if stat_path in wanted_paths:
|
||||
stats[stat_path] = (additions, deletions)
|
||||
if wanted_paths <= set(stats):
|
||||
break
|
||||
return stats
|
||||
|
||||
def diff_index(
|
||||
self,
|
||||
*,
|
||||
repository: str,
|
||||
from_revision: str,
|
||||
to_revision: str,
|
||||
prefix: str = "",
|
||||
limit: int = 100,
|
||||
cursor: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
prefix = _safe_relative_path(prefix, "prefix", allow_empty=True)
|
||||
limit = _positive_int(limit, "limit", _MAX_LIMIT)
|
||||
path = self._repository_path(repository)
|
||||
from_oid = self._resolve_at(path, from_revision, "from")
|
||||
to_oid = self._resolve_at(path, to_revision, "to")
|
||||
query = {
|
||||
"from_oid": from_oid,
|
||||
"to_oid": to_oid,
|
||||
"prefix": prefix,
|
||||
"limit": limit,
|
||||
}
|
||||
offset = self._cursor_offset(
|
||||
cursor,
|
||||
operation="diff_index",
|
||||
repository=repository,
|
||||
resolved_oid=to_oid,
|
||||
query=query,
|
||||
)
|
||||
page: list[dict[str, object]] = []
|
||||
total = 0
|
||||
for entry in self._iter_diff_name_entries(path, from_oid, to_oid, prefix):
|
||||
if total >= offset and len(page) < limit:
|
||||
page.append(entry)
|
||||
total += 1
|
||||
wanted_paths = {str(entry["path"]) for entry in page}
|
||||
stat_by_path = (
|
||||
self._diff_stats(path, from_oid, to_oid, prefix, wanted_paths)
|
||||
if page
|
||||
else {}
|
||||
)
|
||||
for entry in page:
|
||||
additions, deletions = stat_by_path.get(
|
||||
str(entry["path"]), (None, None)
|
||||
)
|
||||
entry["additions"] = additions
|
||||
entry["deletions"] = deletions
|
||||
has_more = offset + len(page) < total
|
||||
return {
|
||||
"repository": repository,
|
||||
"resolved_from_oid": from_oid,
|
||||
"resolved_to_oid": to_oid,
|
||||
"entries": page,
|
||||
"truncated": has_more,
|
||||
"next_cursor": (
|
||||
self._next_cursor(
|
||||
operation="diff_index",
|
||||
repository=repository,
|
||||
resolved_oid=to_oid,
|
||||
query=query,
|
||||
offset=offset + len(page),
|
||||
)
|
||||
if has_more
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
def get_diff_index(self, request: Mapping[str, object]) -> dict[str, object]:
|
||||
request = _request_mapping(request)
|
||||
from_revision = request.get("from_revision", request.get("from"))
|
||||
to_revision = request.get("to_revision", request.get("to"))
|
||||
prefix = request.get("prefix", request.get("path_prefix", ""))
|
||||
if from_revision is None or to_revision is None:
|
||||
raise _error("REPOSITORY_DISCOVERY_REQUEST_INVALID")
|
||||
return self.diff_index(
|
||||
repository=_required_request_value(request, "repository"), # type: ignore[arg-type]
|
||||
from_revision=from_revision, # type: ignore[arg-type]
|
||||
to_revision=to_revision, # type: ignore[arg-type]
|
||||
prefix=prefix, # type: ignore[arg-type]
|
||||
limit=request.get("limit", 100), # type: ignore[arg-type]
|
||||
cursor=request.get("cursor"), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def read_file(
|
||||
self,
|
||||
*,
|
||||
repository: str,
|
||||
revision: str,
|
||||
file_path: str,
|
||||
max_bytes: int = 262_144,
|
||||
) -> dict[str, object]:
|
||||
file_path = _safe_relative_path(file_path, "file_path", allow_empty=False)
|
||||
max_bytes = _positive_int(max_bytes, "max_bytes", _MAX_BYTES)
|
||||
path = self._repository_path(repository)
|
||||
resolved_oid = self._resolve_at(path, revision, "revision")
|
||||
exact: list[dict[str, str]] = []
|
||||
for entry in self._iter_tree_entries(
|
||||
path, resolved_oid, file_path, include_trees=False
|
||||
):
|
||||
if entry["path"] == file_path and entry["type"] == "blob":
|
||||
exact.append(entry)
|
||||
if len(exact) != 1:
|
||||
raise RepositoryDiscoveryNotFound("REPOSITORY_DISCOVERY_FILE_NOT_FOUND")
|
||||
blob_oid = exact[0]["object_oid"]
|
||||
content, truncated = self._read_blob_prefix(path, blob_oid, max_bytes)
|
||||
return {
|
||||
"repository": repository,
|
||||
"resolved_oid": resolved_oid,
|
||||
"blob_oid": blob_oid,
|
||||
"path": file_path,
|
||||
"content": content.decode("utf-8", errors="replace"),
|
||||
"content_base64": base64.b64encode(content).decode("ascii"),
|
||||
"content_bytes": len(content),
|
||||
"truncated": truncated,
|
||||
}
|
||||
|
||||
def read_repository_file(self, request: Mapping[str, object]) -> dict[str, object]:
|
||||
request = _request_mapping(request)
|
||||
file_path = request.get("file_path", request.get("path"))
|
||||
if file_path is None:
|
||||
raise _error("REPOSITORY_DISCOVERY_REQUEST_INVALID")
|
||||
return self.read_file(
|
||||
repository=_required_request_value(request, "repository"), # type: ignore[arg-type]
|
||||
revision=request.get("revision", request.get("ref", "HEAD")), # type: ignore[arg-type]
|
||||
file_path=file_path, # type: ignore[arg-type]
|
||||
max_bytes=request.get("max_bytes", 262_144), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def create_repository_discovery(
|
||||
*,
|
||||
allowed_repositories: Iterable[str] | None = None,
|
||||
git_dir: str | Path | None = None,
|
||||
cursor_secret: str | bytes | None = None,
|
||||
) -> RepositoryDiscovery:
|
||||
return RepositoryDiscovery(
|
||||
allowed_repositories=allowed_repositories,
|
||||
git_dir=git_dir,
|
||||
cursor_secret=cursor_secret,
|
||||
)
|
||||
|
||||
|
||||
def list_repository_tree(
|
||||
request: Mapping[str, object], *, discovery: RepositoryDiscovery | None = None
|
||||
) -> dict[str, object]:
|
||||
return (discovery or create_repository_discovery()).list_repository_tree(request)
|
||||
|
||||
|
||||
def search_repository_source(
|
||||
request: Mapping[str, object], *, discovery: RepositoryDiscovery | None = None
|
||||
) -> dict[str, object]:
|
||||
return (discovery or create_repository_discovery()).search_repository_source(request)
|
||||
|
||||
|
||||
def get_diff_index(
|
||||
request: Mapping[str, object], *, discovery: RepositoryDiscovery | None = None
|
||||
) -> dict[str, object]:
|
||||
return (discovery or create_repository_discovery()).get_diff_index(request)
|
||||
|
||||
|
||||
def read_repository_file(
|
||||
request: Mapping[str, object], *, discovery: RepositoryDiscovery | None = None
|
||||
) -> dict[str, object]:
|
||||
return (discovery or create_repository_discovery()).read_repository_file(request)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,707 @@
|
||||
"""Single-host operational delegation for AIOS migration.
|
||||
|
||||
This module is deliberately narrow:
|
||||
* Owner signs only OperationalDelegation and ExpiredTaskRecoveryDecision.
|
||||
* Machine signer signs only WorkerRuntimeBinding and WorkerRuntimeProof.
|
||||
* Every object is bound to one immutable Task identity and a narrow capability set.
|
||||
* There is no arbitrary sign-bytes API.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from aios_single_host_authority import (
|
||||
DEFAULT_MACHINE_SIGNER_ID,
|
||||
DEFAULT_OWNER_SIGNER_ID,
|
||||
MACHINE_NAMESPACE,
|
||||
OWNER_IDENTITY,
|
||||
OWNER_NAMESPACE,
|
||||
LocalFileSigner,
|
||||
RegistryOwnerApprovalVerifier,
|
||||
SignerRegistry,
|
||||
SingleHostAuthorityError,
|
||||
canonical_json_bytes,
|
||||
sha256_json,
|
||||
verify_sshsig,
|
||||
)
|
||||
|
||||
OPERATIONAL_POLICY_SCHEMA = "aios.single-host-operational-policy.v1"
|
||||
OPERATIONAL_DELEGATION_SCHEMA = "aios.owner-operational-delegation.v1"
|
||||
RECOVERY_DECISION_SCHEMA = "aios.expired-task-recovery-decision.v1"
|
||||
WORKER_BINDING_SCHEMA = "aios.worker-runtime-binding.v1"
|
||||
WORKER_PROOF_SCHEMA = "aios.worker-runtime-proof.v1"
|
||||
REVOCATION_SCHEMA = "aios.operational-revocations.v1"
|
||||
|
||||
ALLOWED_OPERATIONAL_CAPABILITIES = frozenset({
|
||||
"task.recovery",
|
||||
"worker.runtime.bind",
|
||||
"worker.runtime.proof",
|
||||
"task.claim.guard",
|
||||
})
|
||||
ALLOWED_EXPECTED_STATE = "EXPIRED"
|
||||
HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+:-]{2,511}$")
|
||||
CAP = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
|
||||
|
||||
class OperationalAuthorityError(RuntimeError):
|
||||
def __init__(self, code: str):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def fail(code: str) -> None:
|
||||
raise OperationalAuthorityError(code)
|
||||
|
||||
|
||||
def _exact_fields(value: Mapping[str, Any], fields: set[str], code: str) -> None:
|
||||
if not isinstance(value, Mapping) or set(value) != fields:
|
||||
fail(code)
|
||||
|
||||
|
||||
def _safe_id(value: str, code: str) -> str:
|
||||
if not isinstance(value, str) or not SAFE_ID.fullmatch(value):
|
||||
fail(code)
|
||||
return value
|
||||
|
||||
|
||||
def _hex(value: str, n: int, code: str) -> str:
|
||||
regex = HEX40 if n == 40 else HEX64
|
||||
if not isinstance(value, str) or not regex.fullmatch(value):
|
||||
fail(code)
|
||||
return value
|
||||
|
||||
|
||||
def _caps(value: Sequence[str], code: str) -> tuple[str, ...]:
|
||||
if not isinstance(value, (list, tuple)) or not value:
|
||||
fail(code)
|
||||
out = tuple(sorted(set(value)))
|
||||
if len(out) != len(value) or any(not isinstance(x, str) or not CAP.fullmatch(x) for x in out):
|
||||
fail(code)
|
||||
return out
|
||||
|
||||
|
||||
def task_identity_sha256(task: Mapping[str, Any]) -> str:
|
||||
"""Digest only immutable recovery/claim identity fields.
|
||||
|
||||
Revision, state, attempt and deadline are intentionally excluded.
|
||||
"""
|
||||
fields = (
|
||||
"task_id", "repository", "target_branch", "base_oid", "generation",
|
||||
"execution_plan_sha256", "external_job_identity",
|
||||
)
|
||||
try:
|
||||
value = {name: task[name] for name in fields}
|
||||
except KeyError as exc:
|
||||
raise OperationalAuthorityError("TASK_IDENTITY_FIELD_MISSING") from exc
|
||||
_safe_id(str(value["task_id"]), "TASK_ID_INVALID")
|
||||
_safe_id(str(value["repository"]), "REPOSITORY_INVALID")
|
||||
_safe_id(str(value["target_branch"]), "TARGET_BRANCH_INVALID")
|
||||
_hex(str(value["base_oid"]), 40, "BASE_OID_INVALID")
|
||||
if type(value["generation"]) is not int or value["generation"] < 1:
|
||||
fail("GENERATION_INVALID")
|
||||
_hex(str(value["execution_plan_sha256"]), 64, "EXECUTION_PLAN_DIGEST_INVALID")
|
||||
_safe_id(str(value["external_job_identity"]), "EXTERNAL_JOB_IDENTITY_INVALID")
|
||||
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OperationalPolicy:
|
||||
repository: str
|
||||
task_id: str
|
||||
task_identity_sha256: str
|
||||
target_branch: str
|
||||
base_oid: str
|
||||
generation: int
|
||||
execution_plan_sha256: str
|
||||
external_job_identity: str
|
||||
allowed_capabilities: tuple[str, ...]
|
||||
allowed_worker_ids: tuple[str, ...]
|
||||
maximum_delegation_ttl_seconds: int
|
||||
maximum_recovery_decision_ttl_seconds: int
|
||||
maximum_binding_ttl_seconds: int
|
||||
maximum_proof_ttl_seconds: int
|
||||
maximum_heartbeat_age_seconds: int
|
||||
maximum_task_deadline_extension_seconds: int
|
||||
schema: str = OPERATIONAL_POLICY_SCHEMA
|
||||
|
||||
def validate(self) -> None:
|
||||
if self.schema != OPERATIONAL_POLICY_SCHEMA:
|
||||
fail("OPERATIONAL_POLICY_SCHEMA_INVALID")
|
||||
_safe_id(self.repository, "OPERATIONAL_POLICY_REPOSITORY_INVALID")
|
||||
_safe_id(self.task_id, "OPERATIONAL_POLICY_TASK_ID_INVALID")
|
||||
_hex(self.task_identity_sha256, 64, "OPERATIONAL_POLICY_TASK_IDENTITY_INVALID")
|
||||
_safe_id(self.target_branch, "OPERATIONAL_POLICY_BRANCH_INVALID")
|
||||
_hex(self.base_oid, 40, "OPERATIONAL_POLICY_BASE_OID_INVALID")
|
||||
if type(self.generation) is not int or self.generation < 1:
|
||||
fail("OPERATIONAL_POLICY_GENERATION_INVALID")
|
||||
_hex(self.execution_plan_sha256, 64, "OPERATIONAL_POLICY_PLAN_INVALID")
|
||||
_safe_id(self.external_job_identity, "OPERATIONAL_POLICY_JOB_INVALID")
|
||||
caps = _caps(self.allowed_capabilities, "OPERATIONAL_POLICY_CAPABILITIES_INVALID")
|
||||
if not set(caps).issubset(ALLOWED_OPERATIONAL_CAPABILITIES):
|
||||
fail("OPERATIONAL_POLICY_CAPABILITY_WIDENING")
|
||||
if not self.allowed_worker_ids or any(not SAFE_ID.fullmatch(x) for x in self.allowed_worker_ids):
|
||||
fail("OPERATIONAL_POLICY_WORKERS_INVALID")
|
||||
bounds = (
|
||||
(self.maximum_delegation_ttl_seconds, 300, 7 * 24 * 3600),
|
||||
(self.maximum_recovery_decision_ttl_seconds, 30, 900),
|
||||
(self.maximum_binding_ttl_seconds, 60, 24 * 3600),
|
||||
(self.maximum_proof_ttl_seconds, 15, 900),
|
||||
(self.maximum_heartbeat_age_seconds, 5, 300),
|
||||
(self.maximum_task_deadline_extension_seconds, 300, 24 * 3600),
|
||||
)
|
||||
if any(type(v) is not int or not lo <= v <= hi for v, lo, hi in bounds):
|
||||
fail("OPERATIONAL_POLICY_TTL_INVALID")
|
||||
|
||||
@property
|
||||
def digest(self) -> str:
|
||||
self.validate()
|
||||
return sha256_json(asdict(self))
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "OperationalPolicy":
|
||||
fields = set(cls.__dataclass_fields__)
|
||||
_exact_fields(value, fields, "OPERATIONAL_POLICY_FIELDS_INVALID")
|
||||
try:
|
||||
policy = cls(
|
||||
repository=value["repository"],
|
||||
task_id=value["task_id"],
|
||||
task_identity_sha256=value["task_identity_sha256"],
|
||||
target_branch=value["target_branch"],
|
||||
base_oid=value["base_oid"],
|
||||
generation=value["generation"],
|
||||
execution_plan_sha256=value["execution_plan_sha256"],
|
||||
external_job_identity=value["external_job_identity"],
|
||||
allowed_capabilities=tuple(value["allowed_capabilities"]),
|
||||
allowed_worker_ids=tuple(value["allowed_worker_ids"]),
|
||||
maximum_delegation_ttl_seconds=value["maximum_delegation_ttl_seconds"],
|
||||
maximum_recovery_decision_ttl_seconds=value["maximum_recovery_decision_ttl_seconds"],
|
||||
maximum_binding_ttl_seconds=value["maximum_binding_ttl_seconds"],
|
||||
maximum_proof_ttl_seconds=value["maximum_proof_ttl_seconds"],
|
||||
maximum_heartbeat_age_seconds=value["maximum_heartbeat_age_seconds"],
|
||||
maximum_task_deadline_extension_seconds=value["maximum_task_deadline_extension_seconds"],
|
||||
schema=value["schema"],
|
||||
)
|
||||
except Exception as exc:
|
||||
raise OperationalAuthorityError("OPERATIONAL_POLICY_FIELDS_INVALID") from exc
|
||||
policy.validate()
|
||||
return policy
|
||||
|
||||
|
||||
def build_exact_policy(
|
||||
task: Mapping[str, Any],
|
||||
*,
|
||||
worker_id: str,
|
||||
allowed_capabilities: Sequence[str] = tuple(sorted(ALLOWED_OPERATIONAL_CAPABILITIES)),
|
||||
) -> OperationalPolicy:
|
||||
identity = task_identity_sha256(task)
|
||||
policy = OperationalPolicy(
|
||||
repository=str(task["repository"]),
|
||||
task_id=str(task["task_id"]),
|
||||
task_identity_sha256=identity,
|
||||
target_branch=str(task["target_branch"]),
|
||||
base_oid=str(task["base_oid"]),
|
||||
generation=int(task["generation"]),
|
||||
execution_plan_sha256=str(task["execution_plan_sha256"]),
|
||||
external_job_identity=str(task["external_job_identity"]),
|
||||
allowed_capabilities=tuple(sorted(allowed_capabilities)),
|
||||
allowed_worker_ids=(worker_id,),
|
||||
maximum_delegation_ttl_seconds=24 * 3600,
|
||||
maximum_recovery_decision_ttl_seconds=300,
|
||||
maximum_binding_ttl_seconds=4 * 3600,
|
||||
maximum_proof_ttl_seconds=300,
|
||||
maximum_heartbeat_age_seconds=30,
|
||||
maximum_task_deadline_extension_seconds=4 * 3600,
|
||||
)
|
||||
policy.validate()
|
||||
return policy
|
||||
|
||||
|
||||
def _signed_payload_dict(payload: Mapping[str, Any], *, key_id: str, namespace: str) -> dict[str, Any]:
|
||||
value = dict(payload)
|
||||
value["signature_namespace"] = namespace
|
||||
value["signature_key_id"] = key_id
|
||||
value["canonical_payload_digest"] = sha256_json(value)
|
||||
return value
|
||||
|
||||
|
||||
def _verify_digest_field(payload: Mapping[str, Any]) -> bool:
|
||||
if not isinstance(payload, Mapping) or "canonical_payload_digest" not in payload:
|
||||
return False
|
||||
value = dict(payload)
|
||||
supplied = value.pop("canonical_payload_digest", None)
|
||||
return isinstance(supplied, str) and supplied == sha256_json(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SignedOperationalArtifact:
|
||||
payload: dict[str, Any]
|
||||
signature: str
|
||||
key_id: str
|
||||
namespace: str
|
||||
|
||||
@property
|
||||
def canonical_payload(self) -> bytes:
|
||||
return canonical_json_bytes(self.payload)
|
||||
|
||||
@property
|
||||
def payload_sha256(self) -> str:
|
||||
return hashlib.sha256(self.canonical_payload).hexdigest()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"payload": dict(self.payload),
|
||||
"signature": self.signature,
|
||||
"key_id": self.key_id,
|
||||
"namespace": self.namespace,
|
||||
}
|
||||
|
||||
|
||||
class OperationalOwnerAuthority:
|
||||
def __init__(self, key_path: Path, policy: OperationalPolicy, *, require_root_owner: bool = True, clock: Any = time.time):
|
||||
policy.validate()
|
||||
self.policy = policy
|
||||
self.signer = LocalFileSigner(
|
||||
Path(key_path),
|
||||
signer_id=DEFAULT_OWNER_SIGNER_ID,
|
||||
namespace=OWNER_NAMESPACE,
|
||||
require_root_owner=require_root_owner,
|
||||
)
|
||||
self.clock = clock
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
return self.signer.fingerprint
|
||||
|
||||
def _sign(self, payload: Mapping[str, Any]) -> SignedOperationalArtifact:
|
||||
full = _signed_payload_dict(payload, key_id=self.fingerprint, namespace=OWNER_NAMESPACE)
|
||||
art = self.signer._sign_canonical(full)
|
||||
return SignedOperationalArtifact(dict(art.payload), art.signature, art.key_id, art.namespace)
|
||||
|
||||
def issue_operational_delegation(self, *, delegation_id: str, nonce: str, ttl_seconds: int) -> SignedOperationalArtifact:
|
||||
now = int(self.clock())
|
||||
p = self.policy
|
||||
if not SAFE_ID.fullmatch(delegation_id) or not SAFE_ID.fullmatch(nonce):
|
||||
fail("OPERATIONAL_DELEGATION_ID_INVALID")
|
||||
if type(ttl_seconds) is not int or not 60 <= ttl_seconds <= p.maximum_delegation_ttl_seconds:
|
||||
fail("OPERATIONAL_DELEGATION_TTL_INVALID")
|
||||
payload = {
|
||||
"schema": OPERATIONAL_DELEGATION_SCHEMA,
|
||||
"delegation_id": delegation_id,
|
||||
"nonce": nonce,
|
||||
"owner_identity": OWNER_IDENTITY,
|
||||
"repository": p.repository,
|
||||
"task_id": p.task_id,
|
||||
"task_identity_sha256": p.task_identity_sha256,
|
||||
"target_branch": p.target_branch,
|
||||
"base_oid": p.base_oid,
|
||||
"generation": p.generation,
|
||||
"execution_plan_sha256": p.execution_plan_sha256,
|
||||
"external_job_identity": p.external_job_identity,
|
||||
"allowed_capabilities": list(p.allowed_capabilities),
|
||||
"allowed_worker_ids": list(p.allowed_worker_ids),
|
||||
"policy_digest": p.digest,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 5),
|
||||
"expires_at": now + ttl_seconds,
|
||||
"revocation_epoch": 0,
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
return self._sign(payload)
|
||||
|
||||
def issue_recovery_decision(
|
||||
self,
|
||||
delegation: Mapping[str, Any],
|
||||
*,
|
||||
decision_id: str,
|
||||
nonce: str,
|
||||
expected_revision: int,
|
||||
expected_attempt: int,
|
||||
original_deadline: int,
|
||||
new_deadline: int,
|
||||
ttl_seconds: int,
|
||||
) -> SignedOperationalArtifact:
|
||||
now = int(self.clock())
|
||||
verify_delegation_scope(delegation, self.policy, now=now)
|
||||
if "task.recovery" not in delegation["allowed_capabilities"]:
|
||||
fail("RECOVERY_CAPABILITY_NOT_DELEGATED")
|
||||
if not SAFE_ID.fullmatch(decision_id) or not SAFE_ID.fullmatch(nonce):
|
||||
fail("RECOVERY_DECISION_ID_INVALID")
|
||||
if type(expected_revision) is not int or expected_revision < 0 or type(expected_attempt) is not int or expected_attempt < 0:
|
||||
fail("RECOVERY_CAS_INVALID")
|
||||
if type(original_deadline) is not int or type(new_deadline) is not int:
|
||||
fail("RECOVERY_DEADLINE_INVALID")
|
||||
if new_deadline <= now or new_deadline <= original_deadline:
|
||||
fail("RECOVERY_DEADLINE_INVALID")
|
||||
if new_deadline - now > self.policy.maximum_task_deadline_extension_seconds:
|
||||
fail("RECOVERY_DEADLINE_WIDENING")
|
||||
if type(ttl_seconds) is not int or not 15 <= ttl_seconds <= self.policy.maximum_recovery_decision_ttl_seconds:
|
||||
fail("RECOVERY_DECISION_TTL_INVALID")
|
||||
payload = {
|
||||
"schema": RECOVERY_DECISION_SCHEMA,
|
||||
"decision_id": decision_id,
|
||||
"nonce": nonce,
|
||||
"parent_delegation_id": delegation["delegation_id"],
|
||||
"parent_delegation_digest": hashlib.sha256(canonical_json_bytes(dict(delegation))).hexdigest(),
|
||||
"task_id": self.policy.task_id,
|
||||
"task_identity_sha256": self.policy.task_identity_sha256,
|
||||
"expected_state": ALLOWED_EXPECTED_STATE,
|
||||
"expected_revision": expected_revision,
|
||||
"expected_attempt": expected_attempt,
|
||||
"original_deadline": original_deadline,
|
||||
"new_deadline": new_deadline,
|
||||
"repository": self.policy.repository,
|
||||
"target_branch": self.policy.target_branch,
|
||||
"base_oid": self.policy.base_oid,
|
||||
"generation": self.policy.generation,
|
||||
"execution_plan_sha256": self.policy.execution_plan_sha256,
|
||||
"external_job_identity": self.policy.external_job_identity,
|
||||
"policy_digest": self.policy.digest,
|
||||
"one_time_use": True,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 5),
|
||||
"expires_at": now + ttl_seconds,
|
||||
}
|
||||
return self._sign(payload)
|
||||
|
||||
|
||||
class OperationalMachineAuthority:
|
||||
def __init__(self, key_path: Path, policy: OperationalPolicy, *, require_root_owner: bool = True, clock: Any = time.time):
|
||||
policy.validate()
|
||||
self.policy = policy
|
||||
self.signer = LocalFileSigner(
|
||||
Path(key_path),
|
||||
signer_id=DEFAULT_MACHINE_SIGNER_ID,
|
||||
namespace=MACHINE_NAMESPACE,
|
||||
require_root_owner=require_root_owner,
|
||||
)
|
||||
self.clock = clock
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
return self.signer.fingerprint
|
||||
|
||||
def _sign(self, payload: Mapping[str, Any]) -> SignedOperationalArtifact:
|
||||
full = _signed_payload_dict(payload, key_id=self.fingerprint, namespace=MACHINE_NAMESPACE)
|
||||
art = self.signer._sign_canonical(full)
|
||||
return SignedOperationalArtifact(dict(art.payload), art.signature, art.key_id, art.namespace)
|
||||
|
||||
def issue_runtime_binding(
|
||||
self,
|
||||
delegation: Mapping[str, Any],
|
||||
*,
|
||||
binding_id: str,
|
||||
nonce: str,
|
||||
worker_id: str,
|
||||
runtime_id: str,
|
||||
runtime_sha256: str,
|
||||
task_contract_version: str,
|
||||
supported_contract_versions: Sequence[str],
|
||||
required_capabilities: Sequence[str],
|
||||
worker_capabilities: Sequence[str],
|
||||
max_concurrency: int,
|
||||
ttl_seconds: int,
|
||||
) -> SignedOperationalArtifact:
|
||||
now = int(self.clock())
|
||||
p = self.policy
|
||||
verify_delegation_scope(delegation, p, now=now)
|
||||
if "worker.runtime.bind" not in delegation["allowed_capabilities"]:
|
||||
fail("RUNTIME_BIND_CAPABILITY_NOT_DELEGATED")
|
||||
if worker_id not in p.allowed_worker_ids:
|
||||
fail("WORKER_ID_NOT_DELEGATED")
|
||||
for val, code in [(binding_id, "BINDING_ID_INVALID"), (nonce, "BINDING_NONCE_INVALID"), (worker_id, "WORKER_ID_INVALID"), (runtime_id, "RUNTIME_ID_INVALID"), (task_contract_version, "CONTRACT_VERSION_INVALID")]:
|
||||
_safe_id(val, code)
|
||||
_hex(runtime_sha256, 64, "RUNTIME_SHA256_INVALID")
|
||||
supported = tuple(sorted(set(supported_contract_versions)))
|
||||
if task_contract_version not in supported or not supported or any(not SAFE_ID.fullmatch(x) for x in supported):
|
||||
fail("CONTRACT_INCOMPATIBLE")
|
||||
required = _caps(required_capabilities, "REQUIRED_CAPABILITIES_INVALID")
|
||||
worker_caps = _caps(worker_capabilities, "WORKER_CAPABILITIES_INVALID")
|
||||
if not set(required).issubset(set(worker_caps)):
|
||||
fail("CAPABILITY_MISMATCH")
|
||||
if type(max_concurrency) is not int or not 1 <= max_concurrency <= 64:
|
||||
fail("MAX_CONCURRENCY_INVALID")
|
||||
if type(ttl_seconds) is not int or not 30 <= ttl_seconds <= p.maximum_binding_ttl_seconds:
|
||||
fail("BINDING_TTL_INVALID")
|
||||
if now + ttl_seconds > int(delegation["expires_at"]):
|
||||
fail("BINDING_EXCEEDS_PARENT")
|
||||
payload = {
|
||||
"schema": WORKER_BINDING_SCHEMA,
|
||||
"binding_id": binding_id,
|
||||
"nonce": nonce,
|
||||
"parent_delegation_id": delegation["delegation_id"],
|
||||
"parent_delegation_digest": hashlib.sha256(canonical_json_bytes(dict(delegation))).hexdigest(),
|
||||
"repository": p.repository,
|
||||
"task_id": p.task_id,
|
||||
"task_identity_sha256": p.task_identity_sha256,
|
||||
"target_branch": p.target_branch,
|
||||
"base_oid": p.base_oid,
|
||||
"generation": p.generation,
|
||||
"execution_plan_sha256": p.execution_plan_sha256,
|
||||
"external_job_identity": p.external_job_identity,
|
||||
"worker_id": worker_id,
|
||||
"runtime_id": runtime_id,
|
||||
"runtime_sha256": runtime_sha256,
|
||||
"task_contract_version": task_contract_version,
|
||||
"supported_contract_versions": list(supported),
|
||||
"required_capabilities": list(required),
|
||||
"worker_capabilities": list(worker_caps),
|
||||
"max_concurrency": max_concurrency,
|
||||
"policy_digest": p.digest,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 5),
|
||||
"expires_at": now + ttl_seconds,
|
||||
}
|
||||
return self._sign(payload)
|
||||
|
||||
def issue_runtime_proof(
|
||||
self,
|
||||
delegation: Mapping[str, Any],
|
||||
binding: Mapping[str, Any],
|
||||
*,
|
||||
proof_id: str,
|
||||
nonce: str,
|
||||
heartbeat_at: int,
|
||||
adapter_readiness: bool,
|
||||
resource_readiness: bool,
|
||||
active_claim_count: int,
|
||||
ttl_seconds: int,
|
||||
) -> SignedOperationalArtifact:
|
||||
now = int(self.clock())
|
||||
p = self.policy
|
||||
verify_delegation_scope(delegation, p, now=now)
|
||||
verify_binding_scope(binding, delegation, p, now=now)
|
||||
if "worker.runtime.proof" not in delegation["allowed_capabilities"]:
|
||||
fail("RUNTIME_PROOF_CAPABILITY_NOT_DELEGATED")
|
||||
_safe_id(proof_id, "PROOF_ID_INVALID")
|
||||
_safe_id(nonce, "PROOF_NONCE_INVALID")
|
||||
if type(heartbeat_at) is not int or heartbeat_at > now + 5 or now - heartbeat_at > p.maximum_heartbeat_age_seconds:
|
||||
fail("HEARTBEAT_STALE")
|
||||
if adapter_readiness is not True or resource_readiness is not True:
|
||||
fail("RUNTIME_NOT_READY")
|
||||
if type(active_claim_count) is not int or active_claim_count < 0 or active_claim_count >= int(binding["max_concurrency"]):
|
||||
fail("MAX_CONCURRENCY_REACHED")
|
||||
if type(ttl_seconds) is not int or not 10 <= ttl_seconds <= p.maximum_proof_ttl_seconds:
|
||||
fail("PROOF_TTL_INVALID")
|
||||
if now + ttl_seconds > min(int(binding["expires_at"]), int(delegation["expires_at"])):
|
||||
fail("PROOF_EXCEEDS_PARENT")
|
||||
payload = {
|
||||
"schema": WORKER_PROOF_SCHEMA,
|
||||
"proof_id": proof_id,
|
||||
"nonce": nonce,
|
||||
"parent_delegation_id": delegation["delegation_id"],
|
||||
"parent_delegation_digest": hashlib.sha256(canonical_json_bytes(dict(delegation))).hexdigest(),
|
||||
"binding_id": binding["binding_id"],
|
||||
"binding_digest": hashlib.sha256(canonical_json_bytes(dict(binding))).hexdigest(),
|
||||
"repository": p.repository,
|
||||
"task_id": p.task_id,
|
||||
"task_identity_sha256": p.task_identity_sha256,
|
||||
"generation": p.generation,
|
||||
"execution_plan_sha256": p.execution_plan_sha256,
|
||||
"worker_id": binding["worker_id"],
|
||||
"runtime_id": binding["runtime_id"],
|
||||
"runtime_sha256": binding["runtime_sha256"],
|
||||
"heartbeat_at": heartbeat_at,
|
||||
"adapter_readiness": True,
|
||||
"resource_readiness": True,
|
||||
"active_claim_count": active_claim_count,
|
||||
"max_concurrency": binding["max_concurrency"],
|
||||
"policy_digest": p.digest,
|
||||
"issued_at": now,
|
||||
"expires_at": now + ttl_seconds,
|
||||
}
|
||||
return self._sign(payload)
|
||||
|
||||
|
||||
DELEGATION_FIELDS = {
|
||||
"schema","delegation_id","nonce","owner_identity","repository","task_id",
|
||||
"task_identity_sha256","target_branch","base_oid","generation","execution_plan_sha256",
|
||||
"external_job_identity","allowed_capabilities","allowed_worker_ids","policy_digest",
|
||||
"issued_at","not_before","expires_at","revocation_epoch","status",
|
||||
"signature_namespace","signature_key_id","canonical_payload_digest",
|
||||
}
|
||||
BINDING_FIELDS = {
|
||||
"schema","binding_id","nonce","parent_delegation_id","parent_delegation_digest",
|
||||
"repository","task_id","task_identity_sha256","target_branch","base_oid","generation",
|
||||
"execution_plan_sha256","external_job_identity","worker_id","runtime_id","runtime_sha256",
|
||||
"task_contract_version","supported_contract_versions","required_capabilities","worker_capabilities",
|
||||
"max_concurrency","policy_digest","issued_at","not_before","expires_at",
|
||||
"signature_namespace","signature_key_id","canonical_payload_digest",
|
||||
}
|
||||
PROOF_FIELDS = {
|
||||
"schema","proof_id","nonce","parent_delegation_id","parent_delegation_digest",
|
||||
"binding_id","binding_digest","repository","task_id","task_identity_sha256","generation",
|
||||
"execution_plan_sha256","worker_id","runtime_id","runtime_sha256","heartbeat_at",
|
||||
"adapter_readiness","resource_readiness","active_claim_count","max_concurrency",
|
||||
"policy_digest","issued_at","expires_at","signature_namespace","signature_key_id",
|
||||
"canonical_payload_digest",
|
||||
}
|
||||
RECOVERY_FIELDS = {
|
||||
"schema","decision_id","nonce","parent_delegation_id","parent_delegation_digest",
|
||||
"task_id","task_identity_sha256","expected_state","expected_revision","expected_attempt",
|
||||
"original_deadline","new_deadline","repository","target_branch","base_oid","generation",
|
||||
"execution_plan_sha256","external_job_identity","policy_digest","one_time_use",
|
||||
"issued_at","not_before","expires_at","signature_namespace","signature_key_id",
|
||||
"canonical_payload_digest",
|
||||
}
|
||||
|
||||
|
||||
def verify_delegation_scope(value: Mapping[str, Any], policy: OperationalPolicy, *, now: int | None = None) -> None:
|
||||
policy.validate()
|
||||
_exact_fields(value, DELEGATION_FIELDS, "OPERATIONAL_DELEGATION_FIELDS_INVALID")
|
||||
now = int(time.time()) if now is None else int(now)
|
||||
if (
|
||||
value["schema"] != OPERATIONAL_DELEGATION_SCHEMA
|
||||
or value["status"] != "ACTIVE"
|
||||
or value["revocation_epoch"] != 0
|
||||
or value["owner_identity"] != OWNER_IDENTITY
|
||||
or value["repository"] != policy.repository
|
||||
or value["task_id"] != policy.task_id
|
||||
or value["task_identity_sha256"] != policy.task_identity_sha256
|
||||
or value["target_branch"] != policy.target_branch
|
||||
or value["base_oid"] != policy.base_oid
|
||||
or value["generation"] != policy.generation
|
||||
or value["execution_plan_sha256"] != policy.execution_plan_sha256
|
||||
or value["external_job_identity"] != policy.external_job_identity
|
||||
or value["policy_digest"] != policy.digest
|
||||
or not set(value["allowed_capabilities"]).issubset(set(policy.allowed_capabilities))
|
||||
or not set(value["allowed_worker_ids"]).issubset(set(policy.allowed_worker_ids))
|
||||
or value["signature_namespace"] != OWNER_NAMESPACE
|
||||
or not _verify_digest_field(value)
|
||||
or not int(value["not_before"]) <= now < int(value["expires_at"])
|
||||
):
|
||||
fail("OPERATIONAL_DELEGATION_SCOPE_INVALID")
|
||||
|
||||
|
||||
def verify_binding_scope(binding: Mapping[str, Any], delegation: Mapping[str, Any], policy: OperationalPolicy, *, now: int | None = None) -> None:
|
||||
_exact_fields(binding, BINDING_FIELDS, "WORKER_BINDING_FIELDS_INVALID")
|
||||
now = int(time.time()) if now is None else int(now)
|
||||
expected_parent = hashlib.sha256(canonical_json_bytes(dict(delegation))).hexdigest()
|
||||
if (
|
||||
binding["schema"] != WORKER_BINDING_SCHEMA
|
||||
or binding["parent_delegation_id"] != delegation["delegation_id"]
|
||||
or binding["parent_delegation_digest"] != expected_parent
|
||||
or binding["repository"] != policy.repository
|
||||
or binding["task_id"] != policy.task_id
|
||||
or binding["task_identity_sha256"] != policy.task_identity_sha256
|
||||
or binding["target_branch"] != policy.target_branch
|
||||
or binding["base_oid"] != policy.base_oid
|
||||
or binding["generation"] != policy.generation
|
||||
or binding["execution_plan_sha256"] != policy.execution_plan_sha256
|
||||
or binding["external_job_identity"] != policy.external_job_identity
|
||||
or binding["worker_id"] not in policy.allowed_worker_ids
|
||||
or binding["policy_digest"] != policy.digest
|
||||
or binding["signature_namespace"] != MACHINE_NAMESPACE
|
||||
or not _verify_digest_field(binding)
|
||||
or not int(binding["not_before"]) <= now < int(binding["expires_at"])
|
||||
):
|
||||
fail("WORKER_BINDING_SCOPE_INVALID")
|
||||
required = set(binding["required_capabilities"])
|
||||
worker = set(binding["worker_capabilities"])
|
||||
if not required.issubset(worker) or binding["task_contract_version"] not in binding["supported_contract_versions"]:
|
||||
fail("WORKER_BINDING_COMPATIBILITY_INVALID")
|
||||
|
||||
|
||||
def verify_proof_scope(proof: Mapping[str, Any], binding: Mapping[str, Any], delegation: Mapping[str, Any], policy: OperationalPolicy, *, now: int | None = None) -> None:
|
||||
_exact_fields(proof, PROOF_FIELDS, "WORKER_PROOF_FIELDS_INVALID")
|
||||
now = int(time.time()) if now is None else int(now)
|
||||
if (
|
||||
proof["schema"] != WORKER_PROOF_SCHEMA
|
||||
or proof["parent_delegation_id"] != delegation["delegation_id"]
|
||||
or proof["parent_delegation_digest"] != hashlib.sha256(canonical_json_bytes(dict(delegation))).hexdigest()
|
||||
or proof["binding_id"] != binding["binding_id"]
|
||||
or proof["binding_digest"] != hashlib.sha256(canonical_json_bytes(dict(binding))).hexdigest()
|
||||
or proof["repository"] != policy.repository
|
||||
or proof["task_id"] != policy.task_id
|
||||
or proof["task_identity_sha256"] != policy.task_identity_sha256
|
||||
or proof["generation"] != policy.generation
|
||||
or proof["execution_plan_sha256"] != policy.execution_plan_sha256
|
||||
or proof["worker_id"] != binding["worker_id"]
|
||||
or proof["runtime_id"] != binding["runtime_id"]
|
||||
or proof["runtime_sha256"] != binding["runtime_sha256"]
|
||||
or proof["max_concurrency"] != binding["max_concurrency"]
|
||||
or proof["policy_digest"] != policy.digest
|
||||
or proof["signature_namespace"] != MACHINE_NAMESPACE
|
||||
or proof["adapter_readiness"] is not True
|
||||
or proof["resource_readiness"] is not True
|
||||
or not _verify_digest_field(proof)
|
||||
or not now < int(proof["expires_at"])
|
||||
or now - int(proof["heartbeat_at"]) > policy.maximum_heartbeat_age_seconds
|
||||
or int(proof["active_claim_count"]) >= int(proof["max_concurrency"])
|
||||
):
|
||||
fail("WORKER_PROOF_SCOPE_INVALID")
|
||||
|
||||
|
||||
def verify_recovery_scope(decision: Mapping[str, Any], delegation: Mapping[str, Any], policy: OperationalPolicy, *, now: int | None = None) -> None:
|
||||
_exact_fields(decision, RECOVERY_FIELDS, "RECOVERY_DECISION_FIELDS_INVALID")
|
||||
now = int(time.time()) if now is None else int(now)
|
||||
if (
|
||||
decision["schema"] != RECOVERY_DECISION_SCHEMA
|
||||
or decision["parent_delegation_id"] != delegation["delegation_id"]
|
||||
or decision["parent_delegation_digest"] != hashlib.sha256(canonical_json_bytes(dict(delegation))).hexdigest()
|
||||
or decision["task_id"] != policy.task_id
|
||||
or decision["task_identity_sha256"] != policy.task_identity_sha256
|
||||
or decision["expected_state"] != "EXPIRED"
|
||||
or decision["repository"] != policy.repository
|
||||
or decision["target_branch"] != policy.target_branch
|
||||
or decision["base_oid"] != policy.base_oid
|
||||
or decision["generation"] != policy.generation
|
||||
or decision["execution_plan_sha256"] != policy.execution_plan_sha256
|
||||
or decision["external_job_identity"] != policy.external_job_identity
|
||||
or decision["policy_digest"] != policy.digest
|
||||
or decision["one_time_use"] is not True
|
||||
or decision["signature_namespace"] != OWNER_NAMESPACE
|
||||
or not _verify_digest_field(decision)
|
||||
or not int(decision["not_before"]) <= now < int(decision["expires_at"])
|
||||
or int(decision["new_deadline"]) <= now
|
||||
):
|
||||
fail("RECOVERY_DECISION_SCOPE_INVALID")
|
||||
|
||||
|
||||
class OperationalVerifier:
|
||||
"""Verify signatures through the public root-owned SignerRegistry."""
|
||||
|
||||
def __init__(self, registry_path: Path, *, require_root_owner: bool = True):
|
||||
self.registry_path = Path(registry_path)
|
||||
self.require_root_owner = require_root_owner
|
||||
|
||||
def _verify(self, artifact: SignedOperationalArtifact | Mapping[str, Any], *, role: str, signer_id: str, namespace: str) -> dict[str, Any]:
|
||||
if isinstance(artifact, SignedOperationalArtifact):
|
||||
payload, signature, key_id, artifact_namespace = artifact.payload, artifact.signature, artifact.key_id, artifact.namespace
|
||||
else:
|
||||
_exact_fields(artifact, {"payload","signature","key_id","namespace"}, "SIGNED_ARTIFACT_FIELDS_INVALID")
|
||||
payload, signature, key_id, artifact_namespace = artifact["payload"], artifact["signature"], artifact["key_id"], artifact["namespace"]
|
||||
if artifact_namespace != namespace:
|
||||
fail("SIGNED_ARTIFACT_NAMESPACE_INVALID")
|
||||
registry = SignerRegistry(self.registry_path, require_root_owner=self.require_root_owner)
|
||||
rec = registry.require(signer_id, role=role)
|
||||
if rec.fingerprint != key_id:
|
||||
fail("SIGNED_ARTIFACT_KEY_ID_INVALID")
|
||||
if role == "MACHINE_OPERATIONAL":
|
||||
owner_rec = registry.require(DEFAULT_OWNER_SIGNER_ID, role="OWNER_AUTHORITY")
|
||||
if rec.parent_signer_id != owner_rec.signer_id or rec.parent_fingerprint != owner_rec.fingerprint:
|
||||
fail("MACHINE_PARENT_BINDING_INVALID")
|
||||
# RegistryOwnerApprovalVerifier handles Owner namespace; generic machine uses registry public key.
|
||||
if role == "OWNER_AUTHORITY":
|
||||
verifier = RegistryOwnerApprovalVerifier(self.registry_path, require_root_owner=self.require_root_owner, signer_id=signer_id)
|
||||
ok = verifier.verify(namespace=namespace, payload=canonical_json_bytes(payload), signature=signature, key_id=key_id)
|
||||
else:
|
||||
from aios_single_host_authority import _load_public_key_line
|
||||
pub = _load_public_key_line(rec.public_key)
|
||||
ok = verify_sshsig(pub, canonical_json_bytes(payload), signature, namespace=namespace, expected_key_id=key_id)
|
||||
if not ok:
|
||||
fail("SIGNED_ARTIFACT_SIGNATURE_INVALID")
|
||||
return dict(payload)
|
||||
|
||||
def owner(self, artifact: SignedOperationalArtifact | Mapping[str, Any]) -> dict[str, Any]:
|
||||
return self._verify(artifact, role="OWNER_AUTHORITY", signer_id=DEFAULT_OWNER_SIGNER_ID, namespace=OWNER_NAMESPACE)
|
||||
|
||||
def machine(self, artifact: SignedOperationalArtifact | Mapping[str, Any]) -> dict[str, Any]:
|
||||
return self._verify(artifact, role="MACHINE_OPERATIONAL", signer_id=DEFAULT_MACHINE_SIGNER_ID, namespace=MACHINE_NAMESPACE)
|
||||
@@ -0,0 +1,410 @@
|
||||
"""Unix-domain fixed-schema service for operational migration authority."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import sqlite3
|
||||
import struct
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_single_host_authority import (
|
||||
DEFAULT_REGISTRY_PATH,
|
||||
OWNER_NAMESPACE,
|
||||
MACHINE_NAMESPACE,
|
||||
SingleHostAuthorityError,
|
||||
)
|
||||
from aios_single_host_operational_authority import (
|
||||
OperationalAuthorityError,
|
||||
OperationalMachineAuthority,
|
||||
OperationalOwnerAuthority,
|
||||
OperationalPolicy,
|
||||
OperationalVerifier,
|
||||
SignedOperationalArtifact,
|
||||
verify_binding_scope,
|
||||
verify_delegation_scope,
|
||||
)
|
||||
|
||||
DEFAULT_POLICY = Path("/etc/aios/single-host-operational-policy.json")
|
||||
DEFAULT_OWNER_KEY = Path("/var/lib/aios-owner-authority/owner-authority.key")
|
||||
DEFAULT_MACHINE_KEY = Path("/var/lib/aios-machine-signer/machine-operational.key")
|
||||
DEFAULT_OWNER_SOCKET = Path("/run/aios/owner-authority.sock")
|
||||
DEFAULT_MACHINE_SOCKET = Path("/run/aios/machine-signer.sock")
|
||||
DEFAULT_LEDGER = Path("/var/lib/aios-operational-authority/issuance-ledger.sqlite3")
|
||||
MAX_REQUEST = 128 * 1024
|
||||
READINESS_SCHEMA = "aios.signer-readiness-challenge.v1"
|
||||
|
||||
|
||||
class OperationalServiceError(RuntimeError):
|
||||
def __init__(self, code: str):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def fail(code: str) -> None:
|
||||
raise OperationalServiceError(code)
|
||||
|
||||
|
||||
def _read_policy(path: Path, *, require_root_owner: bool = True) -> OperationalPolicy:
|
||||
path=Path(path)
|
||||
if not path.is_absolute():
|
||||
fail("OPERATIONAL_POLICY_PATH_INVALID")
|
||||
flags=os.O_RDONLY
|
||||
if hasattr(os,"O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
try:
|
||||
fd=os.open(path,flags)
|
||||
except OSError as exc:
|
||||
raise OperationalServiceError("OPERATIONAL_POLICY_UNAVAILABLE") from exc
|
||||
try:
|
||||
st=os.fstat(fd)
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
fail("OPERATIONAL_POLICY_NOT_REGULAR")
|
||||
mode=stat.S_IMODE(st.st_mode)
|
||||
if mode & 0o022:
|
||||
fail("OPERATIONAL_POLICY_WRITABLE_BY_GROUP_OR_OTHER")
|
||||
if require_root_owner and st.st_uid != 0:
|
||||
fail("OPERATIONAL_POLICY_NOT_ROOT_OWNED")
|
||||
raw=bytearray()
|
||||
while len(raw) <= MAX_REQUEST:
|
||||
chunk=os.read(fd,min(65536,MAX_REQUEST+1-len(raw)))
|
||||
if not chunk: break
|
||||
raw.extend(chunk)
|
||||
if not raw or len(raw)>MAX_REQUEST:
|
||||
fail("OPERATIONAL_POLICY_UNAVAILABLE")
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
obj=json.loads(bytes(raw).decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise OperationalServiceError("OPERATIONAL_POLICY_INVALID") from exc
|
||||
return OperationalPolicy.from_mapping(obj)
|
||||
|
||||
|
||||
def _artifact(value: Mapping[str, Any]) -> SignedOperationalArtifact:
|
||||
if not isinstance(value, Mapping) or set(value) != {"payload","signature","key_id","namespace"}:
|
||||
fail("SIGNED_ARTIFACT_INVALID")
|
||||
if not isinstance(value["payload"], Mapping):
|
||||
fail("SIGNED_ARTIFACT_INVALID")
|
||||
return SignedOperationalArtifact(
|
||||
payload=dict(value["payload"]),
|
||||
signature=str(value["signature"]),
|
||||
key_id=str(value["key_id"]),
|
||||
namespace=str(value["namespace"]),
|
||||
)
|
||||
|
||||
|
||||
def _peer_uid(conn: socket.socket) -> int:
|
||||
if not hasattr(socket, "SO_PEERCRED"):
|
||||
fail("PEER_CREDENTIALS_UNAVAILABLE")
|
||||
pid, uid, gid = struct.unpack("3i", conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i")))
|
||||
return uid
|
||||
|
||||
|
||||
|
||||
class DurableIssuanceLedger:
|
||||
"""Root-owned exact-idempotent structured signing ledger."""
|
||||
|
||||
def __init__(self, path: Path):
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if os.name == "posix": os.chmod(self.path.parent, 0o700)
|
||||
if self.path.exists() and self.path.is_symlink():
|
||||
fail("ISSUANCE_LEDGER_SYMLINK_FORBIDDEN")
|
||||
conn = sqlite3.connect(self.path)
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=DELETE")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS issuance (
|
||||
role TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
nonce TEXT NOT NULL,
|
||||
request_sha256 TEXT NOT NULL,
|
||||
response_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(role, operation, nonce)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
os.chmod(self.path, 0o600)
|
||||
|
||||
def issue(self, *, role: str, operation: str, nonce: str, request: Mapping[str, Any], issuer: Any) -> dict[str, Any]:
|
||||
request_sha = hashlib.sha256(
|
||||
json.dumps(request, sort_keys=True, separators=(",",":"), ensure_ascii=False).encode("utf-8")
|
||||
).hexdigest()
|
||||
conn = sqlite3.connect(self.path, timeout=5, isolation_level=None)
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute(
|
||||
"SELECT request_sha256,response_json FROM issuance WHERE role=? AND operation=? AND nonce=?",
|
||||
(role, operation, nonce),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
if row[0] != request_sha:
|
||||
conn.rollback()
|
||||
fail("NONCE_REUSE_WITH_DIFFERENT_REQUEST")
|
||||
result = json.loads(row[1])
|
||||
conn.commit()
|
||||
return result
|
||||
result = issuer()
|
||||
encoded = json.dumps(result, sort_keys=True, separators=(",",":"), ensure_ascii=False)
|
||||
conn.execute(
|
||||
"INSERT INTO issuance(role,operation,nonce,request_sha256,response_json,created_at) VALUES(?,?,?,?,?,?)",
|
||||
(role, operation, nonce, request_sha, encoded, int(time.time())),
|
||||
)
|
||||
conn.commit()
|
||||
return result
|
||||
except sqlite3.IntegrityError:
|
||||
conn.rollback()
|
||||
fail("ISSUANCE_LEDGER_RACE")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
class OperationalAuthorityService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
role: str,
|
||||
policy_path: Path = DEFAULT_POLICY,
|
||||
registry_path: Path = DEFAULT_REGISTRY_PATH,
|
||||
owner_key_path: Path = DEFAULT_OWNER_KEY,
|
||||
machine_key_path: Path = DEFAULT_MACHINE_KEY,
|
||||
socket_path: Path | None = None,
|
||||
ledger_path: Path = DEFAULT_LEDGER,
|
||||
allowed_uids: tuple[int, ...] = (0,),
|
||||
clock: Any = time.time,
|
||||
require_root_owner: bool = True,
|
||||
):
|
||||
if role not in {"owner","machine"}:
|
||||
fail("ROLE_INVALID")
|
||||
self.role = role
|
||||
self.policy_path = Path(policy_path)
|
||||
self.registry_path = Path(registry_path)
|
||||
self.owner_key_path = Path(owner_key_path)
|
||||
self.machine_key_path = Path(machine_key_path)
|
||||
self.socket_path = Path(socket_path or (DEFAULT_OWNER_SOCKET if role == "owner" else DEFAULT_MACHINE_SOCKET))
|
||||
self.allowed_uids = tuple(allowed_uids)
|
||||
self.ledger = DurableIssuanceLedger(ledger_path)
|
||||
self.clock = clock
|
||||
self.require_root_owner = require_root_owner
|
||||
|
||||
def _components(self):
|
||||
policy = _read_policy(self.policy_path, require_root_owner=self.require_root_owner)
|
||||
verifier = OperationalVerifier(self.registry_path, require_root_owner=self.require_root_owner)
|
||||
if self.role == "owner":
|
||||
signer = OperationalOwnerAuthority(self.owner_key_path, policy, require_root_owner=self.require_root_owner, clock=self.clock)
|
||||
else:
|
||||
signer = OperationalMachineAuthority(self.machine_key_path, policy, require_root_owner=self.require_root_owner, clock=self.clock)
|
||||
return policy, verifier, signer
|
||||
|
||||
def handle(self, request: Mapping[str, Any], *, peer_uid: int) -> dict[str, Any]:
|
||||
if peer_uid not in self.allowed_uids:
|
||||
fail("PEER_UID_FORBIDDEN")
|
||||
if not isinstance(request, Mapping) or set(request) != {"operation","payload"} or not isinstance(request["payload"], Mapping):
|
||||
fail("REQUEST_INVALID")
|
||||
op = request["operation"]
|
||||
p = request["payload"]
|
||||
policy, verifier, signer = self._components()
|
||||
|
||||
if op == "identity":
|
||||
return {
|
||||
"status":"PASS",
|
||||
"role":self.role,
|
||||
"policy_digest":policy.digest,
|
||||
"key_id":signer.fingerprint,
|
||||
"socket":str(self.socket_path),
|
||||
"arbitrary_signing":False,
|
||||
}
|
||||
|
||||
if op == "readiness_challenge":
|
||||
if set(p) != {"challenge_id","challenge_sha256"}:
|
||||
fail("REQUEST_FIELDS_INVALID")
|
||||
challenge_id = str(p["challenge_id"])
|
||||
challenge_sha256 = str(p["challenge_sha256"])
|
||||
if not challenge_id or len(challenge_id) > 160:
|
||||
fail("READINESS_CHALLENGE_ID_INVALID")
|
||||
if not isinstance(challenge_sha256, str) or not re.fullmatch(r"[0-9a-f]{64}", challenge_sha256):
|
||||
fail("READINESS_CHALLENGE_SHA256_INVALID")
|
||||
namespace = OWNER_NAMESPACE if self.role == "owner" else MACHINE_NAMESPACE
|
||||
def issue():
|
||||
art = signer._sign({
|
||||
"schema": READINESS_SCHEMA,
|
||||
"role": self.role,
|
||||
"challenge_id": challenge_id,
|
||||
"challenge_sha256": challenge_sha256,
|
||||
"policy_digest": policy.digest,
|
||||
"issued_at": int(self.clock()),
|
||||
})
|
||||
return {"status":"PASS","artifact":art.to_dict()}
|
||||
return self.ledger.issue(
|
||||
role=self.role,
|
||||
operation=op,
|
||||
nonce=challenge_id,
|
||||
request=p,
|
||||
issuer=issue,
|
||||
)
|
||||
|
||||
if self.role == "owner":
|
||||
if op == "issue_operational_delegation":
|
||||
if set(p) != {"delegation_id","nonce","ttl_seconds"}:
|
||||
fail("REQUEST_FIELDS_INVALID")
|
||||
def issue():
|
||||
art = signer.issue_operational_delegation(
|
||||
delegation_id=str(p["delegation_id"]),
|
||||
nonce=str(p["nonce"]),
|
||||
ttl_seconds=p["ttl_seconds"],
|
||||
)
|
||||
return {"status":"PASS","artifact":art.to_dict()}
|
||||
return self.ledger.issue(role=self.role, operation=op, nonce=str(p["nonce"]), request=p, issuer=issue)
|
||||
if op == "issue_expired_task_recovery":
|
||||
if set(p) != {"delegation","decision_id","nonce","expected_revision","expected_attempt","original_deadline","new_deadline","ttl_seconds"}:
|
||||
fail("REQUEST_FIELDS_INVALID")
|
||||
parent_art = _artifact(p["delegation"])
|
||||
parent = verifier.owner(parent_art)
|
||||
verify_delegation_scope(parent, policy, now=int(self.clock()))
|
||||
def issue():
|
||||
art = signer.issue_recovery_decision(
|
||||
parent,
|
||||
decision_id=str(p["decision_id"]),
|
||||
nonce=str(p["nonce"]),
|
||||
expected_revision=p["expected_revision"],
|
||||
expected_attempt=p["expected_attempt"],
|
||||
original_deadline=p["original_deadline"],
|
||||
new_deadline=p["new_deadline"],
|
||||
ttl_seconds=p["ttl_seconds"],
|
||||
)
|
||||
return {"status":"PASS","artifact":art.to_dict()}
|
||||
return self.ledger.issue(role=self.role, operation=op, nonce=str(p["nonce"]), request=p, issuer=issue)
|
||||
fail("OWNER_OPERATION_FORBIDDEN")
|
||||
|
||||
if op == "issue_worker_runtime_binding":
|
||||
if set(p) != {
|
||||
"delegation","binding_id","nonce","worker_id","runtime_id","runtime_sha256",
|
||||
"task_contract_version","supported_contract_versions","required_capabilities",
|
||||
"worker_capabilities","max_concurrency","ttl_seconds"
|
||||
}:
|
||||
fail("REQUEST_FIELDS_INVALID")
|
||||
parent_art = _artifact(p["delegation"])
|
||||
parent = verifier.owner(parent_art)
|
||||
verify_delegation_scope(parent, policy, now=int(self.clock()))
|
||||
def issue():
|
||||
art = signer.issue_runtime_binding(
|
||||
parent,
|
||||
binding_id=str(p["binding_id"]), nonce=str(p["nonce"]),
|
||||
worker_id=str(p["worker_id"]), runtime_id=str(p["runtime_id"]),
|
||||
runtime_sha256=str(p["runtime_sha256"]),
|
||||
task_contract_version=str(p["task_contract_version"]),
|
||||
supported_contract_versions=tuple(p["supported_contract_versions"]),
|
||||
required_capabilities=tuple(p["required_capabilities"]),
|
||||
worker_capabilities=tuple(p["worker_capabilities"]),
|
||||
max_concurrency=p["max_concurrency"], ttl_seconds=p["ttl_seconds"],
|
||||
)
|
||||
return {"status":"PASS","artifact":art.to_dict()}
|
||||
return self.ledger.issue(role=self.role, operation=op, nonce=str(p["nonce"]), request=p, issuer=issue)
|
||||
|
||||
if op == "issue_worker_runtime_proof":
|
||||
if set(p) != {
|
||||
"delegation","binding","proof_id","nonce","heartbeat_at","adapter_readiness",
|
||||
"resource_readiness","active_claim_count","ttl_seconds"
|
||||
}:
|
||||
fail("REQUEST_FIELDS_INVALID")
|
||||
parent_art = _artifact(p["delegation"])
|
||||
binding_art = _artifact(p["binding"])
|
||||
parent = verifier.owner(parent_art)
|
||||
binding = verifier.machine(binding_art)
|
||||
verify_delegation_scope(parent, policy, now=int(self.clock()))
|
||||
verify_binding_scope(binding, parent, policy, now=int(self.clock()))
|
||||
def issue():
|
||||
art = signer.issue_runtime_proof(
|
||||
parent, binding,
|
||||
proof_id=str(p["proof_id"]), nonce=str(p["nonce"]),
|
||||
heartbeat_at=p["heartbeat_at"],
|
||||
adapter_readiness=p["adapter_readiness"],
|
||||
resource_readiness=p["resource_readiness"],
|
||||
active_claim_count=p["active_claim_count"],
|
||||
ttl_seconds=p["ttl_seconds"],
|
||||
)
|
||||
return {"status":"PASS","artifact":art.to_dict()}
|
||||
return self.ledger.issue(role=self.role, operation=op, nonce=str(p["nonce"]), request=p, issuer=issue)
|
||||
|
||||
fail("MACHINE_OPERATION_FORBIDDEN")
|
||||
|
||||
def serve_forever(self) -> None:
|
||||
path = self.socket_path
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.exists() or path.is_symlink():
|
||||
if path.is_socket():
|
||||
path.unlink()
|
||||
else:
|
||||
fail("SOCKET_PATH_OCCUPIED")
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.bind(str(path))
|
||||
os.chmod(path, 0o600)
|
||||
sock.listen(16)
|
||||
while True:
|
||||
conn, _ = sock.accept()
|
||||
with conn:
|
||||
try:
|
||||
uid = _peer_uid(conn)
|
||||
chunks = []
|
||||
total = 0
|
||||
while True:
|
||||
data = conn.recv(65536)
|
||||
if not data:
|
||||
break
|
||||
total += len(data)
|
||||
if total > MAX_REQUEST:
|
||||
fail("REQUEST_TOO_LARGE")
|
||||
chunks.append(data)
|
||||
if b"\n" in data:
|
||||
break
|
||||
raw = b"".join(chunks)
|
||||
line = raw.split(b"\n",1)[0]
|
||||
req = json.loads(line.decode("utf-8"))
|
||||
result = self.handle(req, peer_uid=uid)
|
||||
out = {"ok":True,"result":result}
|
||||
except (OperationalServiceError, OperationalAuthorityError, SingleHostAuthorityError) as exc:
|
||||
out = {"ok":False,"error":getattr(exc,"code",str(exc))}
|
||||
except Exception:
|
||||
out = {"ok":False,"error":"INTERNAL_FAIL_CLOSED"}
|
||||
conn.sendall(json.dumps(out, sort_keys=True, separators=(",",":")).encode("utf-8")+b"\n")
|
||||
finally:
|
||||
sock.close()
|
||||
try:
|
||||
path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--role", choices=("owner","machine"), required=True)
|
||||
ap.add_argument("--policy", default=str(DEFAULT_POLICY))
|
||||
ap.add_argument("--registry", default=str(DEFAULT_REGISTRY_PATH))
|
||||
ap.add_argument("--owner-key", default=str(DEFAULT_OWNER_KEY))
|
||||
ap.add_argument("--machine-key", default=str(DEFAULT_MACHINE_KEY))
|
||||
ap.add_argument("--socket")
|
||||
ns = ap.parse_args()
|
||||
service = OperationalAuthorityService(
|
||||
role=ns.role,
|
||||
policy_path=Path(ns.policy),
|
||||
registry_path=Path(ns.registry),
|
||||
owner_key_path=Path(ns.owner_key),
|
||||
machine_key_path=Path(ns.machine_key),
|
||||
socket_path=Path(ns.socket) if ns.socket else None,
|
||||
)
|
||||
service.serve_forever()
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,945 @@
|
||||
"""Fail-closed bootstrap for narrow Owner-delegated Agent Task admission.
|
||||
|
||||
This module solves the authorization bootstrap problem without turning the
|
||||
runtime anchor into a writer and without weakening TaskPrincipal semantics.
|
||||
|
||||
Security model:
|
||||
|
||||
* A task-admission envelope is created from a server-normalized request.
|
||||
* The envelope is authorized either by an already-valid OwnerDelegationEpoch
|
||||
or by an Owner signature verified by an injected trust backend.
|
||||
* The derived TaskAdmissionGrant can only narrow the parent authorization.
|
||||
* The grant is handed to the existing Agent Task control plane through an
|
||||
injected TaskAdmissionPort. This module never writes the Task database.
|
||||
* TaskPrincipal issuance remains downstream of a real Task claim/TaskLease.
|
||||
* Exact idempotent replay returns the same admitted Task; conflicting reuse is
|
||||
rejected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from contextlib import closing, contextmanager
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import Any, Callable, Mapping, Protocol, Sequence
|
||||
|
||||
|
||||
SCHEMA_ENVELOPE = "aios.task-admission-envelope.v1"
|
||||
SCHEMA_GRANT = "aios.task-admission-grant.v1"
|
||||
SCHEMA_EPOCH = "aios.owner-delegation-epoch.v1"
|
||||
SCHEMA_RECEIPT = "aios.task-admission-receipt.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}$")
|
||||
IDEMPOTENCY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{7,255}$")
|
||||
CAPABILITY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
KEY_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@+-]{2,255}$")
|
||||
|
||||
PROHIBITED_CAPABILITIES = frozenset(
|
||||
{
|
||||
"git.global_write",
|
||||
"git.main_write",
|
||||
"git.force_push",
|
||||
"tag.publish",
|
||||
"baseline.finalize",
|
||||
"production.write",
|
||||
"production.switch",
|
||||
"runtime_anchor.write",
|
||||
"shell.arbitrary",
|
||||
"credential.export",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TaskAdmissionError(RuntimeError):
|
||||
"""Stable fail-closed task-admission error."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: str,
|
||||
status: int = 400,
|
||||
*,
|
||||
details: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status = status
|
||||
self.details = dict(details or {})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TaskAdmissionPolicy:
|
||||
repository: str = "aios/aios"
|
||||
trust_domain: str = "aios.owner"
|
||||
branch_prefix: str = "agent/"
|
||||
allowed_capabilities: frozenset[str] = frozenset(
|
||||
{
|
||||
"governed.task.create",
|
||||
"agent-task:control",
|
||||
"task.principal.issue",
|
||||
"writer.lease",
|
||||
"governed.write.files",
|
||||
"git.read",
|
||||
"git.test",
|
||||
"governed.write.branch",
|
||||
"governed.write.file",
|
||||
"testing",
|
||||
"verification",
|
||||
}
|
||||
)
|
||||
max_file_count: int = 64
|
||||
max_task_ttl_seconds: int = 3600
|
||||
max_envelope_ttl_seconds: int = 900
|
||||
max_risk: str = "medium"
|
||||
policy_digest: str = ""
|
||||
|
||||
def normalized_policy_digest(self) -> str:
|
||||
if self.policy_digest:
|
||||
return self.policy_digest
|
||||
return sha256_json(
|
||||
{
|
||||
"repository": self.repository,
|
||||
"trust_domain": self.trust_domain,
|
||||
"branch_prefix": self.branch_prefix,
|
||||
"allowed_capabilities": sorted(self.allowed_capabilities),
|
||||
"max_file_count": self.max_file_count,
|
||||
"max_task_ttl_seconds": self.max_task_ttl_seconds,
|
||||
"max_envelope_ttl_seconds": self.max_envelope_ttl_seconds,
|
||||
"max_risk": self.max_risk,
|
||||
"prohibited_capabilities": sorted(PROHIBITED_CAPABILITIES),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnerDelegationEpoch:
|
||||
"""Already-verified durable Owner authorization.
|
||||
|
||||
The verifier/backend is responsible for authenticating the epoch before
|
||||
returning it to this service.
|
||||
"""
|
||||
|
||||
epoch_id: str
|
||||
owner_identity: str
|
||||
trust_domain: str
|
||||
repository: str
|
||||
allowed_branch_prefixes: tuple[str, ...]
|
||||
allowed_capabilities: frozenset[str]
|
||||
allowed_file_prefixes: tuple[str, ...]
|
||||
risk_ceiling: str
|
||||
issued_at: int
|
||||
not_before: int
|
||||
expires_at: int
|
||||
revocation_epoch: int
|
||||
policy_digest: str
|
||||
schema: str = SCHEMA_EPOCH
|
||||
|
||||
def as_public_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value["allowed_capabilities"] = sorted(self.allowed_capabilities)
|
||||
value["allowed_branch_prefixes"] = list(self.allowed_branch_prefixes)
|
||||
value["allowed_file_prefixes"] = list(self.allowed_file_prefixes)
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TaskAdmissionEnvelope:
|
||||
envelope_id: str
|
||||
idempotency_key: str
|
||||
task_id: str
|
||||
repository: str
|
||||
authority_base_oid: str
|
||||
target_branch: str
|
||||
generation: int
|
||||
execution_digest: str
|
||||
requested_capabilities: tuple[str, ...]
|
||||
file_allowlist: tuple[str, ...]
|
||||
risk: str
|
||||
trust_domain: str
|
||||
policy_digest: str
|
||||
issued_at: int
|
||||
not_before: int
|
||||
expires_at: int
|
||||
nonce: str
|
||||
request_digest: str
|
||||
schema: str = SCHEMA_ENVELOPE
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value["requested_capabilities"] = list(self.requested_capabilities)
|
||||
value["file_allowlist"] = list(self.file_allowlist)
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TaskAdmissionGrant:
|
||||
grant_id: str
|
||||
envelope_id: str
|
||||
task_id: str
|
||||
repository: str
|
||||
authority_base_oid: str
|
||||
target_branch: str
|
||||
generation: int
|
||||
execution_digest: str
|
||||
capabilities: tuple[str, ...]
|
||||
file_allowlist: tuple[str, ...]
|
||||
risk: str
|
||||
trust_domain: str
|
||||
policy_digest: str
|
||||
authorization_kind: str
|
||||
owner_identity: str
|
||||
parent_epoch_id: str | None
|
||||
issued_at: int
|
||||
expires_at: int
|
||||
one_time_use: bool = True
|
||||
schema: str = SCHEMA_GRANT
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value["capabilities"] = list(self.capabilities)
|
||||
value["file_allowlist"] = list(self.file_allowlist)
|
||||
return value
|
||||
|
||||
|
||||
class OwnerAuthorizationBackend(Protocol):
|
||||
"""Bridge to existing trusted Owner authorization.
|
||||
|
||||
No permissive default implementation is provided.
|
||||
"""
|
||||
|
||||
def verify_delegation_epoch(self, epoch_id: str) -> OwnerDelegationEpoch:
|
||||
...
|
||||
|
||||
def verify_owner_signature(
|
||||
self,
|
||||
*,
|
||||
key_id: str,
|
||||
canonical_payload: bytes,
|
||||
signature_b64: str,
|
||||
) -> str:
|
||||
"""Return the verified Owner identity or raise."""
|
||||
...
|
||||
|
||||
|
||||
class TaskAdmissionPort(Protocol):
|
||||
"""Existing Agent Task control-plane port."""
|
||||
|
||||
def admit(self, grant: TaskAdmissionGrant) -> Mapping[str, Any]:
|
||||
...
|
||||
|
||||
|
||||
def utc_seconds() -> int:
|
||||
return int(datetime.now(timezone.utc).timestamp())
|
||||
|
||||
|
||||
def canonical_json_bytes(value: Any) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sha256_json(value: Any) -> str:
|
||||
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
||||
|
||||
|
||||
def _normalize_files(raw: Sequence[object], *, maximum: int) -> tuple[str, ...]:
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_FILE_ALLOWLIST_REJECTED", 403)
|
||||
if not raw or len(raw) > maximum:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_FILE_ALLOWLIST_REJECTED", 403)
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in raw:
|
||||
if (
|
||||
not isinstance(item, str)
|
||||
or not item
|
||||
or "\\" in item
|
||||
or any(ord(char) < 32 or ord(char) == 127 for char in item)
|
||||
):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_FILE_PATH_INVALID", 403)
|
||||
path = PurePosixPath(item)
|
||||
if path.is_absolute() or ".." in path.parts or "." in path.parts:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_FILE_PATH_INVALID", 403)
|
||||
text = path.as_posix()
|
||||
# Do not normalize a caller supplied path. A signature must bind the
|
||||
# exact spelling, not a convenient equivalent such as ``a//b``.
|
||||
if text != item:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_FILE_PATH_INVALID", 403)
|
||||
if text.startswith(".git/") or text == ".git":
|
||||
raise TaskAdmissionError("TASK_ADMISSION_FILE_PATH_INVALID", 403)
|
||||
if text not in seen:
|
||||
normalized.append(text)
|
||||
seen.add(text)
|
||||
if not normalized:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_FILE_ALLOWLIST_REJECTED", 403)
|
||||
return tuple(sorted(normalized))
|
||||
|
||||
|
||||
def _normalize_capabilities(
|
||||
raw: Sequence[object],
|
||||
*,
|
||||
policy: TaskAdmissionPolicy,
|
||||
) -> tuple[str, ...]:
|
||||
if not raw:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_CAPABILITIES_REQUIRED", 403)
|
||||
values: set[str] = set()
|
||||
for item in raw:
|
||||
if not isinstance(item, str) or not CAPABILITY_RE.fullmatch(item):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_CAPABILITY_INVALID", 403)
|
||||
if "*" in item:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_WILDCARD_CAPABILITY_REJECTED", 403)
|
||||
values.add(item)
|
||||
if values & PROHIBITED_CAPABILITIES:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_PROHIBITED_CAPABILITY", 403)
|
||||
if not values.issubset(policy.allowed_capabilities):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_CAPABILITY_OUT_OF_POLICY", 403)
|
||||
return tuple(sorted(values))
|
||||
|
||||
|
||||
def _risk_rank(value: str) -> int:
|
||||
ranks = {"low": 0, "medium": 1, "high": 2}
|
||||
try:
|
||||
return ranks[value]
|
||||
except KeyError:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_RISK_INVALID", 403) from None
|
||||
|
||||
|
||||
def _request_for_digest(
|
||||
*,
|
||||
task_id: str,
|
||||
repository: str,
|
||||
authority_base_oid: str,
|
||||
target_branch: str,
|
||||
generation: int,
|
||||
execution_digest: str,
|
||||
requested_capabilities: tuple[str, ...],
|
||||
file_allowlist: tuple[str, ...],
|
||||
risk: str,
|
||||
trust_domain: str,
|
||||
policy_digest: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"repository": repository,
|
||||
"authority_base_oid": authority_base_oid,
|
||||
"target_branch": target_branch,
|
||||
"generation": generation,
|
||||
"execution_digest": execution_digest,
|
||||
"requested_capabilities": list(requested_capabilities),
|
||||
"file_allowlist": list(file_allowlist),
|
||||
"risk": risk,
|
||||
"trust_domain": trust_domain,
|
||||
"policy_digest": policy_digest,
|
||||
}
|
||||
|
||||
|
||||
class TaskAdmissionStore:
|
||||
"""Portable durable state; all replay decisions are transactionally stored."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.lock_root = Path(str(self.path) + ".locks")
|
||||
self.lock_root.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
with self._connection() as con:
|
||||
con.executescript(
|
||||
"""
|
||||
PRAGMA journal_mode=WAL;
|
||||
CREATE TABLE IF NOT EXISTS task_admission_envelopes(
|
||||
idempotency_key TEXT PRIMARY KEY,
|
||||
envelope_id TEXT NOT NULL UNIQUE,
|
||||
request_digest TEXT NOT NULL,
|
||||
envelope_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS task_admission_grants(
|
||||
envelope_id TEXT PRIMARY KEY,
|
||||
grant_id TEXT NOT NULL UNIQUE,
|
||||
grant_digest TEXT NOT NULL,
|
||||
grant_json TEXT NOT NULL,
|
||||
authorization_digest TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS task_admission_receipts(
|
||||
envelope_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL UNIQUE,
|
||||
receipt_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
columns = {
|
||||
row["name"] for row in con.execute("PRAGMA table_info(task_admission_grants)")
|
||||
}
|
||||
if "authorization_digest" not in columns:
|
||||
con.execute(
|
||||
"ALTER TABLE task_admission_grants ADD COLUMN authorization_digest TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
con = sqlite3.connect(self.path, timeout=30, isolation_level=None)
|
||||
con.row_factory = sqlite3.Row
|
||||
return con
|
||||
|
||||
@contextmanager
|
||||
def _connection(self):
|
||||
"""Yield an owned main-store connection and close it deterministically."""
|
||||
with closing(self._connect()) as con:
|
||||
try:
|
||||
yield con
|
||||
finally:
|
||||
if con.in_transaction:
|
||||
con.rollback()
|
||||
|
||||
@contextmanager
|
||||
def admission_lock(self, envelope_id: str, *, timeout_seconds: float = 5.0):
|
||||
"""Hold a crash-releasing lock for one envelope, outside the main DB."""
|
||||
lock_name = hashlib.sha256(envelope_id.encode("utf-8")).hexdigest() + ".sqlite3"
|
||||
lock_path = self.lock_root / lock_name
|
||||
with closing(sqlite3.connect(lock_path, timeout=timeout_seconds, isolation_level=None)) as con:
|
||||
try:
|
||||
con.execute("BEGIN EXCLUSIVE")
|
||||
except sqlite3.OperationalError as exc:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_IN_PROGRESS", 503) from exc
|
||||
try:
|
||||
yield
|
||||
con.execute("COMMIT")
|
||||
finally:
|
||||
if con.in_transaction:
|
||||
con.rollback()
|
||||
|
||||
def get_by_idempotency(self, key: str) -> dict[str, Any] | None:
|
||||
with self._connection() as con:
|
||||
row = con.execute(
|
||||
"SELECT * FROM task_admission_envelopes WHERE idempotency_key=?",
|
||||
(key,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def get_envelope(self, envelope_id: str) -> dict[str, Any] | None:
|
||||
with self._connection() as con:
|
||||
row = con.execute(
|
||||
"SELECT * FROM task_admission_envelopes WHERE envelope_id=?",
|
||||
(envelope_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def save_envelope(
|
||||
self,
|
||||
*,
|
||||
idempotency_key: str,
|
||||
envelope_id: str,
|
||||
request_digest: str,
|
||||
envelope_json: str,
|
||||
created_at: int,
|
||||
) -> dict[str, Any]:
|
||||
with self._lock, self._connection() as con:
|
||||
con.execute("BEGIN IMMEDIATE")
|
||||
row = con.execute(
|
||||
"SELECT request_digest,envelope_id FROM task_admission_envelopes WHERE idempotency_key=?",
|
||||
(idempotency_key,),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
con.execute("ROLLBACK")
|
||||
if row["request_digest"] != request_digest:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_IDEMPOTENCY_CONFLICT", 409)
|
||||
return json.loads(
|
||||
con.execute(
|
||||
"SELECT envelope_json FROM task_admission_envelopes WHERE idempotency_key=?",
|
||||
(idempotency_key,),
|
||||
).fetchone()["envelope_json"]
|
||||
)
|
||||
con.execute(
|
||||
"""
|
||||
INSERT INTO task_admission_envelopes(
|
||||
idempotency_key,envelope_id,request_digest,envelope_json,status,created_at
|
||||
) VALUES(?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
idempotency_key,
|
||||
envelope_id,
|
||||
request_digest,
|
||||
envelope_json,
|
||||
"PENDING_AUTHORIZATION",
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
con.execute("COMMIT")
|
||||
return json.loads(envelope_json)
|
||||
|
||||
def claim_grant(
|
||||
self,
|
||||
*,
|
||||
envelope_id: str,
|
||||
grant: TaskAdmissionGrant,
|
||||
authorization_digest: str,
|
||||
created_at: int,
|
||||
) -> tuple[TaskAdmissionGrant, bool]:
|
||||
"""Persist the immutable grant before the external Task side effect.
|
||||
|
||||
``BEGIN IMMEDIATE`` serializes only this SQLite writer transaction;
|
||||
it does not serialize unrelated envelopes. A second proof cannot
|
||||
replace the winning proof/grant for the same envelope.
|
||||
"""
|
||||
grant_json = canonical_json_bytes(grant.as_dict()).decode("utf-8")
|
||||
grant_digest = hashlib.sha256(grant_json.encode("utf-8")).hexdigest()
|
||||
with self._lock, self._connection() as con:
|
||||
con.execute("BEGIN IMMEDIATE")
|
||||
row = con.execute(
|
||||
"SELECT grant_json,authorization_digest FROM task_admission_grants WHERE envelope_id=?",
|
||||
(envelope_id,),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
con.execute("ROLLBACK")
|
||||
if row["authorization_digest"] != authorization_digest:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_AUTHORIZATION_CONFLICT", 409)
|
||||
raw = json.loads(row["grant_json"])
|
||||
return (
|
||||
TaskAdmissionGrant(
|
||||
**{
|
||||
**raw,
|
||||
"capabilities": tuple(raw["capabilities"]),
|
||||
"file_allowlist": tuple(raw["file_allowlist"]),
|
||||
}
|
||||
),
|
||||
False,
|
||||
)
|
||||
con.execute(
|
||||
"INSERT INTO task_admission_grants(envelope_id,grant_id,grant_digest,grant_json,authorization_digest,created_at) VALUES(?,?,?,?,?,?)",
|
||||
(envelope_id, grant.grant_id, grant_digest, grant_json, authorization_digest, created_at),
|
||||
)
|
||||
con.execute(
|
||||
"UPDATE task_admission_envelopes SET status='ADMITTING' WHERE envelope_id=?",
|
||||
(envelope_id,),
|
||||
)
|
||||
con.execute("COMMIT")
|
||||
return grant, True
|
||||
|
||||
def receipt(self, envelope_id: str) -> dict[str, Any] | None:
|
||||
with self._connection() as con:
|
||||
row = con.execute(
|
||||
"SELECT receipt_json FROM task_admission_receipts WHERE envelope_id=?",
|
||||
(envelope_id,),
|
||||
).fetchone()
|
||||
return json.loads(row["receipt_json"]) if row else None
|
||||
|
||||
def save_grant_and_receipt(
|
||||
self,
|
||||
*,
|
||||
envelope_id: str,
|
||||
grant: TaskAdmissionGrant,
|
||||
receipt: Mapping[str, Any],
|
||||
created_at: int,
|
||||
) -> dict[str, Any]:
|
||||
grant_json = canonical_json_bytes(grant.as_dict()).decode("utf-8")
|
||||
grant_digest = hashlib.sha256(grant_json.encode("utf-8")).hexdigest()
|
||||
receipt_json = canonical_json_bytes(dict(receipt)).decode("utf-8")
|
||||
with self._lock, self._connection() as con:
|
||||
con.execute("BEGIN IMMEDIATE")
|
||||
existing = con.execute(
|
||||
"SELECT receipt_json FROM task_admission_receipts WHERE envelope_id=?",
|
||||
(envelope_id,),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
con.execute("ROLLBACK")
|
||||
return json.loads(existing["receipt_json"])
|
||||
stored = con.execute(
|
||||
"SELECT grant_id,grant_digest FROM task_admission_grants WHERE envelope_id=?",
|
||||
(envelope_id,),
|
||||
).fetchone()
|
||||
if stored is None:
|
||||
con.execute(
|
||||
"INSERT INTO task_admission_grants(envelope_id,grant_id,grant_digest,grant_json,authorization_digest,created_at) VALUES(?,?,?,?,?,?)",
|
||||
(envelope_id, grant.grant_id, grant_digest, grant_json, "", created_at),
|
||||
)
|
||||
elif stored["grant_id"] != grant.grant_id or stored["grant_digest"] != grant_digest:
|
||||
con.execute("ROLLBACK")
|
||||
raise TaskAdmissionError("TASK_ADMISSION_GRANT_CONFLICT", 409)
|
||||
con.execute(
|
||||
"""
|
||||
INSERT INTO task_admission_receipts(
|
||||
envelope_id,task_id,receipt_json,created_at
|
||||
) VALUES(?,?,?,?)
|
||||
""",
|
||||
(
|
||||
envelope_id,
|
||||
grant.task_id,
|
||||
receipt_json,
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
con.execute(
|
||||
"UPDATE task_admission_envelopes SET status='ADMITTED' WHERE envelope_id=?",
|
||||
(envelope_id,),
|
||||
)
|
||||
con.execute("COMMIT")
|
||||
return dict(receipt)
|
||||
|
||||
def status(self, envelope_id: str) -> dict[str, Any]:
|
||||
row = self.get_envelope(envelope_id)
|
||||
if row is None:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_ENVELOPE_NOT_FOUND", 404)
|
||||
receipt = self.receipt(envelope_id)
|
||||
return {
|
||||
"envelope_id": envelope_id,
|
||||
"status": row["status"],
|
||||
"task": receipt,
|
||||
}
|
||||
|
||||
|
||||
class TaskAdmissionBootstrapService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
store: TaskAdmissionStore,
|
||||
authorization: OwnerAuthorizationBackend,
|
||||
task_port: TaskAdmissionPort,
|
||||
policy: TaskAdmissionPolicy | None = None,
|
||||
clock=utc_seconds,
|
||||
post_admission: Callable[[TaskAdmissionGrant, Mapping[str, Any]], Any] | None = None,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.authorization = authorization
|
||||
self.task_port = task_port
|
||||
self.policy = policy or TaskAdmissionPolicy()
|
||||
self.clock = clock
|
||||
# This is a narrow server-side projection boundary. The official
|
||||
# Agent Task port remains the admission authority; a projection may
|
||||
# only establish local execution state after its exact Task readback.
|
||||
self.post_admission = post_admission
|
||||
self._admit_lock = threading.RLock()
|
||||
|
||||
def create_envelope(self, raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
required = {
|
||||
"idempotency_key",
|
||||
"task_id",
|
||||
"repository",
|
||||
"authority_base_oid",
|
||||
"target_branch",
|
||||
"generation",
|
||||
"execution_digest",
|
||||
"requested_capabilities",
|
||||
"file_allowlist",
|
||||
"risk",
|
||||
}
|
||||
optional = {"expires_in_seconds"}
|
||||
if set(raw) - (required | optional) or not required.issubset(raw):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_REQUEST_FIELDS_INVALID")
|
||||
idempotency_key = str(raw["idempotency_key"])
|
||||
task_id = str(raw["task_id"])
|
||||
repository = str(raw["repository"])
|
||||
authority_base_oid = str(raw["authority_base_oid"])
|
||||
target_branch = str(raw["target_branch"])
|
||||
execution_digest = str(raw["execution_digest"])
|
||||
if not IDEMPOTENCY_RE.fullmatch(idempotency_key):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_IDEMPOTENCY_KEY_INVALID")
|
||||
if not TASK_RE.fullmatch(task_id):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_TASK_ID_INVALID")
|
||||
if repository != self.policy.repository:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_REPOSITORY_REJECTED", 403)
|
||||
if not OID_RE.fullmatch(authority_base_oid):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_BASE_OID_INVALID")
|
||||
if (
|
||||
not BRANCH_RE.fullmatch(target_branch)
|
||||
or not target_branch.startswith(self.policy.branch_prefix)
|
||||
or "//" in target_branch
|
||||
or ".." in target_branch
|
||||
or "@{" in target_branch
|
||||
or target_branch.endswith(("/", "."))
|
||||
or any(
|
||||
not component
|
||||
or component.startswith(".")
|
||||
or component.endswith(".lock")
|
||||
for component in target_branch.split("/")
|
||||
)
|
||||
):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_BRANCH_REJECTED", 403)
|
||||
generation = raw["generation"]
|
||||
if not isinstance(generation, int) or isinstance(generation, bool):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_GENERATION_INVALID")
|
||||
if generation < 1:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_GENERATION_INVALID")
|
||||
if not re.fullmatch(r"^[0-9a-f]{64}$", execution_digest):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EXECUTION_DIGEST_INVALID")
|
||||
requested_capabilities = _normalize_capabilities(
|
||||
raw["requested_capabilities"],
|
||||
policy=self.policy,
|
||||
)
|
||||
file_allowlist = _normalize_files(
|
||||
raw["file_allowlist"],
|
||||
maximum=self.policy.max_file_count,
|
||||
)
|
||||
risk = str(raw["risk"])
|
||||
if _risk_rank(risk) > _risk_rank(self.policy.max_risk):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_RISK_OUT_OF_POLICY", 403)
|
||||
ttl = raw.get("expires_in_seconds", self.policy.max_envelope_ttl_seconds)
|
||||
if (
|
||||
not isinstance(ttl, int)
|
||||
or isinstance(ttl, bool)
|
||||
or ttl < 60
|
||||
or ttl > self.policy.max_envelope_ttl_seconds
|
||||
):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_ENVELOPE_TTL_INVALID")
|
||||
now = int(self.clock())
|
||||
policy_digest = self.policy.normalized_policy_digest()
|
||||
request_value = _request_for_digest(
|
||||
task_id=task_id,
|
||||
repository=repository,
|
||||
authority_base_oid=authority_base_oid,
|
||||
target_branch=target_branch,
|
||||
generation=generation,
|
||||
execution_digest=execution_digest,
|
||||
requested_capabilities=requested_capabilities,
|
||||
file_allowlist=file_allowlist,
|
||||
risk=risk,
|
||||
trust_domain=self.policy.trust_domain,
|
||||
policy_digest=policy_digest,
|
||||
)
|
||||
request_digest = sha256_json(request_value)
|
||||
nonce = secrets.token_urlsafe(24)
|
||||
envelope_id = "task-admission-" + hashlib.sha256(
|
||||
(idempotency_key + "\0" + request_digest + "\0" + nonce).encode("utf-8")
|
||||
).hexdigest()[:32]
|
||||
envelope = TaskAdmissionEnvelope(
|
||||
envelope_id=envelope_id,
|
||||
idempotency_key=idempotency_key,
|
||||
task_id=task_id,
|
||||
repository=repository,
|
||||
authority_base_oid=authority_base_oid,
|
||||
target_branch=target_branch,
|
||||
generation=generation,
|
||||
execution_digest=execution_digest,
|
||||
requested_capabilities=requested_capabilities,
|
||||
file_allowlist=file_allowlist,
|
||||
risk=risk,
|
||||
trust_domain=self.policy.trust_domain,
|
||||
policy_digest=policy_digest,
|
||||
issued_at=now,
|
||||
not_before=now,
|
||||
expires_at=now + ttl,
|
||||
nonce=nonce,
|
||||
request_digest=request_digest,
|
||||
)
|
||||
envelope_dict = envelope.as_dict()
|
||||
persisted = self.store.save_envelope(
|
||||
idempotency_key=idempotency_key,
|
||||
envelope_id=envelope_id,
|
||||
request_digest=request_digest,
|
||||
envelope_json=canonical_json_bytes(envelope_dict).decode("utf-8"),
|
||||
created_at=now,
|
||||
)
|
||||
won = persisted["envelope_id"] == envelope_id
|
||||
return {
|
||||
"schema": SCHEMA_ENVELOPE,
|
||||
"envelope": persisted,
|
||||
"canonical_payload_base64": base64.b64encode(
|
||||
canonical_json_bytes(persisted)
|
||||
).decode("ascii"),
|
||||
"idempotent": not won,
|
||||
}
|
||||
|
||||
def _load_envelope(self, envelope_id: str) -> TaskAdmissionEnvelope:
|
||||
row = self.store.get_envelope(envelope_id)
|
||||
if row is None:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_ENVELOPE_NOT_FOUND", 404)
|
||||
raw = json.loads(row["envelope_json"])
|
||||
return TaskAdmissionEnvelope(
|
||||
**{
|
||||
**raw,
|
||||
"requested_capabilities": tuple(raw["requested_capabilities"]),
|
||||
"file_allowlist": tuple(raw["file_allowlist"]),
|
||||
}
|
||||
)
|
||||
|
||||
def _verify_epoch(
|
||||
self,
|
||||
*,
|
||||
epoch_id: str,
|
||||
envelope: TaskAdmissionEnvelope,
|
||||
now: int,
|
||||
) -> OwnerDelegationEpoch:
|
||||
epoch = self.authorization.verify_delegation_epoch(epoch_id)
|
||||
if epoch.schema != SCHEMA_EPOCH:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_SCHEMA_INVALID", 403)
|
||||
if epoch.epoch_id != epoch_id:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_ID_MISMATCH", 403)
|
||||
if (
|
||||
epoch.trust_domain != envelope.trust_domain
|
||||
or epoch.repository != envelope.repository
|
||||
or epoch.policy_digest != envelope.policy_digest
|
||||
):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_BINDING_MISMATCH", 403)
|
||||
if epoch.revocation_epoch != 0:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_REVOKED", 403)
|
||||
if not epoch.not_before <= now < epoch.expires_at:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_EXPIRED", 403)
|
||||
if not any(
|
||||
envelope.target_branch.startswith(prefix)
|
||||
for prefix in epoch.allowed_branch_prefixes
|
||||
):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_BRANCH_REJECTED", 403)
|
||||
caps = set(envelope.requested_capabilities)
|
||||
if not caps.issubset(epoch.allowed_capabilities):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_CAPABILITY_WIDENING", 403)
|
||||
if _risk_rank(envelope.risk) > _risk_rank(epoch.risk_ceiling):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_RISK_WIDENING", 403)
|
||||
for path in envelope.file_allowlist:
|
||||
if not any(
|
||||
path == prefix.rstrip("/")
|
||||
or path.startswith(prefix.rstrip("/") + "/")
|
||||
for prefix in epoch.allowed_file_prefixes
|
||||
):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_FILE_WIDENING", 403)
|
||||
return epoch
|
||||
|
||||
def authorize_and_admit(self, raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
required = {"envelope_id", "authorization_kind"}
|
||||
allowed = required | {
|
||||
"delegation_epoch_id",
|
||||
"owner_key_id",
|
||||
"signature_base64",
|
||||
}
|
||||
if set(raw) - allowed or not required.issubset(raw):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_AUTH_FIELDS_INVALID")
|
||||
envelope_id = str(raw["envelope_id"])
|
||||
with self.store.admission_lock(envelope_id):
|
||||
# Every replay revalidates policy, temporal bounds, revocation and
|
||||
# proof after the crash-releasing lock is owned.
|
||||
envelope = self._load_envelope(envelope_id)
|
||||
now = int(self.clock())
|
||||
if envelope.policy_digest != self.policy.normalized_policy_digest():
|
||||
raise TaskAdmissionError("TASK_ADMISSION_POLICY_DIGEST_DRIFT", 403)
|
||||
if now < envelope.not_before or now >= envelope.expires_at:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_ENVELOPE_EXPIRED", 403)
|
||||
|
||||
kind = str(raw["authorization_kind"])
|
||||
parent_epoch: OwnerDelegationEpoch | None = None
|
||||
if kind == "delegation_epoch":
|
||||
epoch_id = str(raw.get("delegation_epoch_id") or "")
|
||||
if not epoch_id:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_REQUIRED")
|
||||
parent_epoch = self._verify_epoch(epoch_id=epoch_id, envelope=envelope, now=now)
|
||||
owner_identity = parent_epoch.owner_identity
|
||||
parent_epoch_id: str | None = parent_epoch.epoch_id
|
||||
elif kind == "owner_signature":
|
||||
key_id = str(raw.get("owner_key_id") or "")
|
||||
signature = str(raw.get("signature_base64") or "")
|
||||
if not KEY_ID_RE.fullmatch(key_id) or not signature:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_OWNER_SIGNATURE_REQUIRED")
|
||||
owner_identity = self.authorization.verify_owner_signature(
|
||||
key_id=key_id,
|
||||
canonical_payload=canonical_json_bytes(envelope.as_dict()),
|
||||
signature_b64=signature,
|
||||
)
|
||||
if not isinstance(owner_identity, str) or not owner_identity:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_OWNER_SIGNATURE_INVALID", 403)
|
||||
parent_epoch_id = None
|
||||
else:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_AUTHORIZATION_KIND_REJECTED", 403)
|
||||
|
||||
authorization_digest = sha256_json(dict(raw))
|
||||
grant_id = "task-grant-" + hashlib.sha256(
|
||||
(envelope.envelope_id + "\0" + kind + "\0" + owner_identity + "\0" + (parent_epoch_id or "")).encode("utf-8")
|
||||
).hexdigest()[:32]
|
||||
grant = TaskAdmissionGrant(
|
||||
grant_id=grant_id, envelope_id=envelope.envelope_id, task_id=envelope.task_id,
|
||||
repository=envelope.repository, authority_base_oid=envelope.authority_base_oid,
|
||||
target_branch=envelope.target_branch, generation=envelope.generation,
|
||||
execution_digest=envelope.execution_digest, capabilities=envelope.requested_capabilities,
|
||||
file_allowlist=envelope.file_allowlist, risk=envelope.risk,
|
||||
trust_domain=envelope.trust_domain, policy_digest=envelope.policy_digest,
|
||||
authorization_kind=kind, owner_identity=owner_identity,
|
||||
parent_epoch_id=parent_epoch_id, issued_at=now,
|
||||
expires_at=min(
|
||||
envelope.expires_at,
|
||||
now + self.policy.max_task_ttl_seconds,
|
||||
parent_epoch.expires_at if parent_epoch is not None else envelope.expires_at,
|
||||
),
|
||||
)
|
||||
persisted_grant, _ = self.store.claim_grant(
|
||||
envelope_id=envelope_id, grant=grant,
|
||||
authorization_digest=authorization_digest, created_at=now,
|
||||
)
|
||||
if now >= persisted_grant.expires_at:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_GRANT_EXPIRED", 403)
|
||||
if parent_epoch is not None and persisted_grant.expires_at > parent_epoch.expires_at:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_GRANT_PARENT_EXPIRY_CONFLICT", 409)
|
||||
existing_receipt = self.store.receipt(envelope_id)
|
||||
if existing_receipt is not None:
|
||||
return {**existing_receipt, "idempotent": True}
|
||||
|
||||
try:
|
||||
result = self.task_port.admit(persisted_grant)
|
||||
except TaskAdmissionError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_TASK_STATE_UNKNOWN", 503) from exc
|
||||
if not isinstance(result, Mapping):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_PORT_RESPONSE_INVALID", 502)
|
||||
state = str(result.get("state") or result.get("status") or "")
|
||||
if str(result.get("task_id") or "") != persisted_grant.task_id:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_PORT_TASK_MISMATCH", 502)
|
||||
if state not in {"QUEUED", "CLAIMED", "RUNNING"}:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_PORT_STATE_INVALID", 502, details={"state": state})
|
||||
if self.post_admission is not None:
|
||||
projection_result: Mapping[str, Any] = result
|
||||
projection_reader = getattr(self.task_port, "projection_result", None)
|
||||
if callable(projection_reader):
|
||||
try:
|
||||
candidate = projection_reader(persisted_grant, result)
|
||||
except TaskAdmissionError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise TaskAdmissionError(
|
||||
"TASK_ADMISSION_EXECUTION_PROJECTION_FAILED", 503
|
||||
) from exc
|
||||
if not isinstance(candidate, Mapping):
|
||||
raise TaskAdmissionError(
|
||||
"TASK_ADMISSION_EXECUTION_PROJECTION_INVALID", 503
|
||||
)
|
||||
projection_result = candidate
|
||||
try:
|
||||
projection = self.post_admission(
|
||||
persisted_grant, projection_result
|
||||
)
|
||||
except TaskAdmissionError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise TaskAdmissionError(
|
||||
"TASK_ADMISSION_EXECUTION_PROJECTION_FAILED", 503
|
||||
) from exc
|
||||
if projection is not None and not isinstance(projection, Mapping):
|
||||
raise TaskAdmissionError(
|
||||
"TASK_ADMISSION_EXECUTION_PROJECTION_INVALID", 503
|
||||
)
|
||||
receipt = {
|
||||
"schema": SCHEMA_RECEIPT, "envelope_id": persisted_grant.envelope_id,
|
||||
"grant_id": persisted_grant.grant_id, "task_id": persisted_grant.task_id,
|
||||
"task_state": state, "authorization_kind": persisted_grant.authorization_kind,
|
||||
"owner_identity": persisted_grant.owner_identity, "parent_epoch_id": persisted_grant.parent_epoch_id,
|
||||
"authority_base_oid": persisted_grant.authority_base_oid,
|
||||
"target_branch": persisted_grant.target_branch, "generation": persisted_grant.generation,
|
||||
"execution_digest": persisted_grant.execution_digest,
|
||||
"grant_digest_sha256": sha256_json(persisted_grant.as_dict()),
|
||||
"admitted_at": now, "idempotent": False,
|
||||
}
|
||||
return self.store.save_grant_and_receipt(
|
||||
envelope_id=envelope_id, grant=persisted_grant, receipt=receipt, created_at=now,
|
||||
)
|
||||
|
||||
def status(self, envelope_id: str) -> dict[str, Any]:
|
||||
return self.store.status(envelope_id)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Bridge contract surface for Task Admission Bootstrap.
|
||||
|
||||
This module is intentionally transport-agnostic. The live Bridge may merge
|
||||
these routes/operations into its existing authenticated/HTTPS surface, but the
|
||||
authorization proof for the authorize operation is the verified delegation
|
||||
epoch or Owner signature carried by the request itself.
|
||||
|
||||
No handler here can issue TaskPrincipal assertions or WriterLeases. Those remain
|
||||
strictly downstream of a real admitted/claimed Agent Task.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_task_admission_bootstrap import TaskAdmissionBootstrapService
|
||||
|
||||
|
||||
TASK_ADMISSION_ROUTES = {
|
||||
"/v2/task-admission/envelope": "getTaskAdmissionEnvelope",
|
||||
"/v2/task-admission/authorize": "submitTaskAdmissionAuthorization",
|
||||
"/v2/task-admission/status": "getTaskAdmissionStatus",
|
||||
}
|
||||
|
||||
TASK_ADMISSION_OPERATION_REQUIRED_FIELDS = {
|
||||
"getTaskAdmissionEnvelope": (
|
||||
"idempotency_key",
|
||||
"task_id",
|
||||
"repository",
|
||||
"authority_base_oid",
|
||||
"target_branch",
|
||||
"generation",
|
||||
"execution_digest",
|
||||
"requested_capabilities",
|
||||
"file_allowlist",
|
||||
"risk",
|
||||
),
|
||||
"submitTaskAdmissionAuthorization": (
|
||||
"envelope_id",
|
||||
"authorization_kind",
|
||||
),
|
||||
"getTaskAdmissionStatus": ("envelope_id",),
|
||||
}
|
||||
|
||||
TASK_ADMISSION_OPERATION_OPTIONAL_FIELDS = {
|
||||
"getTaskAdmissionEnvelope": ("expires_in_seconds",),
|
||||
"submitTaskAdmissionAuthorization": (
|
||||
"delegation_epoch_id",
|
||||
"owner_key_id",
|
||||
"signature_base64",
|
||||
),
|
||||
"getTaskAdmissionStatus": (),
|
||||
}
|
||||
|
||||
|
||||
class TaskAdmissionBridgeContract:
|
||||
def __init__(self, service: TaskAdmissionBootstrapService) -> None:
|
||||
self.service = service
|
||||
|
||||
@staticmethod
|
||||
def _validate_fields(operation: str, request: Mapping[str, Any]) -> None:
|
||||
required = set(TASK_ADMISSION_OPERATION_REQUIRED_FIELDS[operation])
|
||||
allowed = required | set(TASK_ADMISSION_OPERATION_OPTIONAL_FIELDS[operation])
|
||||
if not required.issubset(request):
|
||||
raise ValueError("TASK_ADMISSION_BRIDGE_REQUIRED_FIELDS_MISSING")
|
||||
if set(request) - allowed:
|
||||
raise ValueError("TASK_ADMISSION_BRIDGE_UNKNOWN_FIELDS")
|
||||
|
||||
def dispatch(self, operation: str, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if operation not in TASK_ADMISSION_OPERATION_REQUIRED_FIELDS:
|
||||
raise ValueError("TASK_ADMISSION_BRIDGE_OPERATION_NOT_FOUND")
|
||||
self._validate_fields(operation, request)
|
||||
if operation == "getTaskAdmissionEnvelope":
|
||||
return self.service.create_envelope(request)
|
||||
if operation == "submitTaskAdmissionAuthorization":
|
||||
return self.service.authorize_and_admit(request)
|
||||
if operation == "getTaskAdmissionStatus":
|
||||
return self.service.status(str(request["envelope_id"]))
|
||||
raise ValueError("TASK_ADMISSION_BRIDGE_OPERATION_NOT_FOUND")
|
||||
|
||||
def handlers(self) -> dict[str, Any]:
|
||||
return {
|
||||
operation: (
|
||||
lambda request, operation=operation: self.dispatch(operation, request)
|
||||
)
|
||||
for operation in TASK_ADMISSION_OPERATION_REQUIRED_FIELDS
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,394 @@
|
||||
"""Append-only ClaimGuard binding for the superseded Single-Host child Task."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_task_lineage_supersession_initial_projection import (
|
||||
PROJECTION_RECEIPT_SCHEMA,
|
||||
PROJECTION_TABLE,
|
||||
SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
)
|
||||
|
||||
SOURCE_TASK_ID = "TASK-SINGLEHOST-TRUSTROOT-P0-20260904"
|
||||
CHILD_TASK_ID = SOURCE_TASK_ID + "-S1"
|
||||
WORKER_ID = "p0-task-principal-v15-20260830"
|
||||
REPOSITORY = "aios/aios"
|
||||
TARGET_BRANCH = "agent/single-host-trust-root-p0-20260904"
|
||||
GENERATION = 1
|
||||
EXTERNAL_JOB_ID = "job-4e10f92a6a5742518a9f1d8745c845ab"
|
||||
BINDING_SCHEMA = "aios.task-claim-guard-binding.v2"
|
||||
RECEIPT_SCHEMA = "aios.task-claim-guard-binding-receipt.v2"
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class ClaimGuardBindingError(RuntimeError):
|
||||
def __init__(self, code: str, http_status: int = 409):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.http_status = int(http_status)
|
||||
|
||||
|
||||
def _fail(code: str, status: int = 409) -> None:
|
||||
raise ClaimGuardBindingError(code, status)
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
|
||||
|
||||
def _sha_text(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _sha_json(value: Any) -> str:
|
||||
return _sha_text(canonical_json(value))
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _row_dict(row: Any) -> dict[str, Any]:
|
||||
if row is None:
|
||||
return {}
|
||||
if hasattr(row, "keys"):
|
||||
return {str(key): row[key] for key in row.keys()}
|
||||
_fail("TASK_ROW_MAPPING_UNAVAILABLE", 500)
|
||||
|
||||
|
||||
def _json(raw: Any, code: str) -> Any:
|
||||
if not isinstance(raw, str):
|
||||
_fail(code)
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ClaimGuardBindingError(code) from exc
|
||||
|
||||
|
||||
def _default_policy_factory(value: Mapping[str, Any]):
|
||||
try:
|
||||
from aios_single_host_operational_authority import OperationalPolicy
|
||||
except ImportError as exc:
|
||||
raise ClaimGuardBindingError("OPERATIONAL_POLICY_RUNTIME_UNAVAILABLE", 503) from exc
|
||||
return OperationalPolicy.from_mapping(value)
|
||||
|
||||
|
||||
def _policy_value(policy: Any, name: str) -> Any:
|
||||
try:
|
||||
return getattr(policy, name)
|
||||
except AttributeError as exc:
|
||||
raise ClaimGuardBindingError("OPERATIONAL_POLICY_FIELDS_INVALID") from exc
|
||||
|
||||
|
||||
def _load_projection_evidence(connection, relation) -> dict[str, Any]:
|
||||
try:
|
||||
row = connection.execute(
|
||||
f"SELECT * FROM {PROJECTION_TABLE} WHERE task_id=? AND source_task_id=? "
|
||||
"AND supersession_ordinal=?",
|
||||
(CHILD_TASK_ID, SOURCE_TASK_ID, 1),
|
||||
).fetchone()
|
||||
except sqlite3.OperationalError as exc:
|
||||
raise ClaimGuardBindingError(
|
||||
"FORMAL_CHILD_INITIAL_PROJECTION_SCHEMA_UNAVAILABLE", 503
|
||||
) from exc
|
||||
if row is None:
|
||||
_fail("FORMAL_CHILD_INITIAL_PROJECTION_REQUIRED")
|
||||
value = _row_dict(row)
|
||||
receipt = _json(value.get("receipt_json"), "CHILD_PROJECTION_RECEIPT_INVALID")
|
||||
canonical_digest = value.get("canonical_child_execution_digest")
|
||||
source_digest = value.get("source_business_execution_digest")
|
||||
exact = {
|
||||
"task_id": CHILD_TASK_ID,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"supersession_ordinal": 1,
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"external_job_id": EXTERNAL_JOB_ID,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"owner_decision_sha256": relation["owner_decision_sha256"],
|
||||
}
|
||||
if any(value.get(key) != expected for key, expected in exact.items()):
|
||||
_fail("CHILD_PROJECTION_LINEAGE_MISMATCH")
|
||||
if (
|
||||
not isinstance(canonical_digest, str)
|
||||
or HEX64.fullmatch(canonical_digest) is None
|
||||
or canonical_digest == SOURCE_BUSINESS_EXECUTION_DIGEST
|
||||
or source_digest != SOURCE_BUSINESS_EXECUTION_DIGEST
|
||||
or _sha_text(value["receipt_json"]) != value.get("receipt_sha256")
|
||||
or not isinstance(receipt, Mapping)
|
||||
or receipt.get("schema") != PROJECTION_RECEIPT_SCHEMA
|
||||
or receipt.get("canonical_child_execution_digest") != canonical_digest
|
||||
or receipt.get("source_business_execution_digest") != source_digest
|
||||
or receipt.get("owner_supersession_decision_sha256")
|
||||
!= relation["owner_decision_sha256"]
|
||||
):
|
||||
_fail("CHILD_PROJECTION_RECEIPT_MISMATCH")
|
||||
return {
|
||||
**value,
|
||||
"canonical_child_execution_digest": canonical_digest,
|
||||
"source_business_execution_digest": source_digest,
|
||||
}
|
||||
|
||||
|
||||
def install_child_binding(
|
||||
connection,
|
||||
*,
|
||||
policy: Mapping[str, Any],
|
||||
runtime_id: str,
|
||||
runtime_observation_digest: str,
|
||||
task_contract_version: str,
|
||||
policy_factory: Callable[[Mapping[str, Any]], Any] = _default_policy_factory,
|
||||
now: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Generate and persist the only permitted child binding from current DB state."""
|
||||
if not isinstance(policy, Mapping):
|
||||
_fail("OPERATIONAL_POLICY_INVALID")
|
||||
if not isinstance(runtime_id, str) or not runtime_id or len(runtime_id) > 512:
|
||||
_fail("RUNTIME_ID_INVALID")
|
||||
if not isinstance(runtime_observation_digest, str) or HEX64.fullmatch(runtime_observation_digest) is None:
|
||||
_fail("RUNTIME_OBSERVATION_DIGEST_INVALID")
|
||||
if not isinstance(task_contract_version, str) or not task_contract_version or len(task_contract_version) > 128:
|
||||
_fail("TASK_CONTRACT_VERSION_INVALID")
|
||||
relation = connection.execute(
|
||||
"SELECT * FROM task_lineage_supersessions WHERE source_task_id=? AND supersession_ordinal=1 AND child_task_id=?",
|
||||
(SOURCE_TASK_ID, CHILD_TASK_ID),
|
||||
).fetchone()
|
||||
if relation is None:
|
||||
_fail("FORMAL_SUPERSESSION_REQUIRED")
|
||||
projection = _load_projection_evidence(connection, relation)
|
||||
canonical_execution_digest = projection["canonical_child_execution_digest"]
|
||||
task_row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (CHILD_TASK_ID,)).fetchone()
|
||||
if task_row is None:
|
||||
_fail("CHILD_TASK_NOT_FOUND", 404)
|
||||
task = _row_dict(task_row)
|
||||
if (
|
||||
task.get("status") != "QUEUED" or int(task.get("revision", -1)) < 2
|
||||
or int(task.get("attempt", -1)) != 0 or task.get("claimed_by") is not None
|
||||
or task.get("lease_token_hash") is not None or task.get("lease_expires_at") is not None
|
||||
or task.get("result_json") is not None or task.get("verification_json") is not None
|
||||
):
|
||||
_fail("CHILD_PROJECTED_CLAIMABLE_STATE_REQUIRED")
|
||||
event_rows = connection.execute(
|
||||
"SELECT event_type,payload_json FROM events WHERE task_id=? ORDER BY event_id",
|
||||
(CHILD_TASK_ID,),
|
||||
).fetchall()
|
||||
if [row[0] for row in event_rows] != [
|
||||
"TASK_CREATED",
|
||||
"TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZED",
|
||||
]:
|
||||
_fail("CANONICAL_INITIAL_PROJECTION_REQUIRED")
|
||||
projection_event = _json(
|
||||
event_rows[-1][1], "CANONICAL_INITIAL_PROJECTION_EVENT_INVALID"
|
||||
)
|
||||
if (
|
||||
projection_event.get("source_business_execution_digest")
|
||||
!= SOURCE_BUSINESS_EXECUTION_DIGEST
|
||||
or projection_event.get("canonical_child_execution_digest")
|
||||
!= canonical_execution_digest
|
||||
or projection_event.get("owner_supersession_decision_sha256")
|
||||
!= relation["owner_decision_sha256"]
|
||||
or _sha_json(projection_event) != projection["projection_event_sha256"]
|
||||
):
|
||||
_fail("CANONICAL_INITIAL_PROJECTION_EVENT_MISMATCH")
|
||||
metadata_raw = task.get("metadata_json")
|
||||
capabilities_raw = task.get("required_capabilities_json")
|
||||
metadata = _json(metadata_raw, "CHILD_METADATA_INVALID")
|
||||
capabilities = _json(capabilities_raw, "CHILD_CAPABILITIES_INVALID")
|
||||
if (
|
||||
_sha_text(metadata_raw) != projection["metadata_sha256"]
|
||||
or _sha_text(capabilities_raw)
|
||||
!= projection["required_capabilities_sha256"]
|
||||
):
|
||||
_fail("CHILD_PROJECTION_TASK_DIGEST_MISMATCH")
|
||||
if not isinstance(metadata, Mapping) or not isinstance(capabilities, list) or not capabilities:
|
||||
_fail("CHILD_PROJECTION_INVALID")
|
||||
if any(not isinstance(value, str) or not value for value in capabilities) or len(set(capabilities)) != len(capabilities):
|
||||
_fail("CHILD_CAPABILITIES_INVALID")
|
||||
execution = metadata.get("execution")
|
||||
if not isinstance(execution, Mapping):
|
||||
_fail("CHILD_EXECUTION_PROJECTION_MISSING")
|
||||
exact_execution = {
|
||||
"task_id": CHILD_TASK_ID, "repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH, "generation": GENERATION,
|
||||
"execution_digest": canonical_execution_digest, "job_id": EXTERNAL_JOB_ID,
|
||||
}
|
||||
if any(execution.get(key) != value for key, value in exact_execution.items()):
|
||||
_fail("CHILD_EXECUTION_BINDING_MISMATCH")
|
||||
parsed_policy = policy_factory(dict(policy))
|
||||
if hasattr(parsed_policy, "validate"):
|
||||
parsed_policy.validate()
|
||||
policy_exact = {
|
||||
"task_id": CHILD_TASK_ID, "repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH, "generation": GENERATION,
|
||||
"execution_plan_sha256": canonical_execution_digest,
|
||||
"external_job_identity": EXTERNAL_JOB_ID,
|
||||
}
|
||||
if any(_policy_value(parsed_policy, key) != value for key, value in policy_exact.items()):
|
||||
_fail("OPERATIONAL_POLICY_SCOPE_MISMATCH")
|
||||
if tuple(_policy_value(parsed_policy, "allowed_worker_ids")) != (WORKER_ID,):
|
||||
_fail("OPERATIONAL_POLICY_WORKER_MISMATCH")
|
||||
expected_oid = execution.get("expected_branch_oid", execution.get("base_oid"))
|
||||
if expected_oid is not None and _policy_value(parsed_policy, "base_oid") != expected_oid:
|
||||
_fail("OPERATIONAL_POLICY_BASE_OID_MISMATCH")
|
||||
policy_json = canonical_json(dict(policy))
|
||||
policy_sha = _sha_text(policy_json)
|
||||
policy_digest = str(_policy_value(parsed_policy, "digest"))
|
||||
if policy_digest != policy_sha:
|
||||
_fail("OPERATIONAL_POLICY_DIGEST_MISMATCH")
|
||||
binding = {
|
||||
"schema": BINDING_SCHEMA, "task_id": CHILD_TASK_ID,
|
||||
"source_task_id": SOURCE_TASK_ID, "supersession_ordinal": 1,
|
||||
"expected_revision": int(task["revision"]),
|
||||
"metadata_sha256": _sha_text(metadata_raw),
|
||||
"required_capabilities_sha256": _sha_json(sorted(capabilities)),
|
||||
"worker_id": WORKER_ID,
|
||||
"execution_digest": canonical_execution_digest,
|
||||
"canonical_child_execution_digest": canonical_execution_digest,
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"repository": REPOSITORY, "target_branch": TARGET_BRANCH, "generation": GENERATION,
|
||||
"external_job_id": EXTERNAL_JOB_ID, "policy_sha256": policy_sha,
|
||||
"policy_digest": policy_digest, "runtime_id": runtime_id,
|
||||
"runtime_observation_digest": runtime_observation_digest,
|
||||
"task_contract_version": task_contract_version,
|
||||
"owner_supersession_decision_sha256": relation["owner_decision_sha256"],
|
||||
}
|
||||
binding_json = canonical_json(binding)
|
||||
binding_sha = _sha_text(binding_json)
|
||||
binding_id = "sha256:" + binding_sha
|
||||
existing = connection.execute(
|
||||
"SELECT binding_sha256 FROM task_claim_guard_bindings_v2 WHERE task_id=?", (CHILD_TASK_ID,)
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if existing[0] != binding_sha:
|
||||
_fail("CLAIM_GUARD_BINDING_ALREADY_EXISTS")
|
||||
receipt = connection.execute(
|
||||
"SELECT receipt_json FROM task_claim_guard_binding_receipts_v2 WHERE binding_id=?", (binding_id,)
|
||||
).fetchone()
|
||||
if receipt is None:
|
||||
_fail("CLAIM_GUARD_BINDING_RECEIPT_MISSING", 500)
|
||||
return {**json.loads(receipt[0]), "idempotent": True}
|
||||
created_at = now or _now()
|
||||
connection.execute(
|
||||
"""INSERT INTO task_claim_guard_bindings_v2(
|
||||
binding_id,task_id,source_task_id,supersession_ordinal,expected_revision,
|
||||
metadata_sha256,required_capabilities_sha256,worker_id,execution_digest,
|
||||
repository,target_branch,generation,policy_json,policy_sha256,binding_json,
|
||||
binding_sha256,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(binding_id, CHILD_TASK_ID, SOURCE_TASK_ID, 1, binding["expected_revision"],
|
||||
binding["metadata_sha256"], binding["required_capabilities_sha256"], WORKER_ID,
|
||||
canonical_execution_digest, REPOSITORY, TARGET_BRANCH, GENERATION, policy_json, policy_sha,
|
||||
binding_json, binding_sha, created_at),
|
||||
)
|
||||
receipt = {
|
||||
"schema": RECEIPT_SCHEMA, "status": "BOUND", "binding_id": binding_id,
|
||||
"binding_sha256": binding_sha, "task_id": CHILD_TASK_ID,
|
||||
"expected_revision": binding["expected_revision"],
|
||||
"metadata_sha256": binding["metadata_sha256"], "worker_id": WORKER_ID,
|
||||
"execution_digest": canonical_execution_digest,
|
||||
"canonical_child_execution_digest": canonical_execution_digest,
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH, "generation": GENERATION,
|
||||
"owner_supersession_decision_sha256": relation["owner_decision_sha256"],
|
||||
}
|
||||
receipt_sha = _sha_json(receipt)
|
||||
receipt_id = "sha256:" + receipt_sha
|
||||
connection.execute(
|
||||
"INSERT INTO task_claim_guard_binding_receipts_v2 VALUES(?,?,?,?,?,?)",
|
||||
(receipt_id, binding_id, CHILD_TASK_ID, canonical_json(receipt), receipt_sha, created_at),
|
||||
)
|
||||
return {**receipt, "receipt_id": receipt_id, "receipt_sha256": receipt_sha, "idempotent": False}
|
||||
|
||||
|
||||
def _load_child_binding(connection) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM task_claim_guard_bindings_v2 WHERE task_id=?", (CHILD_TASK_ID,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
_fail("CHILD_CLAIM_GUARD_BINDING_MISSING")
|
||||
value = _row_dict(row)
|
||||
binding = _json(value["binding_json"], "CLAIM_GUARD_BINDING_INVALID")
|
||||
policy = _json(value["policy_json"], "OPERATIONAL_POLICY_INVALID")
|
||||
relation = connection.execute(
|
||||
"SELECT * FROM task_lineage_supersessions WHERE source_task_id=? "
|
||||
"AND supersession_ordinal=1 AND child_task_id=?",
|
||||
(SOURCE_TASK_ID, CHILD_TASK_ID),
|
||||
).fetchone()
|
||||
if relation is None:
|
||||
_fail("FORMAL_SUPERSESSION_REQUIRED")
|
||||
projection = _load_projection_evidence(connection, relation)
|
||||
canonical_execution_digest = projection["canonical_child_execution_digest"]
|
||||
if (
|
||||
_sha_text(value["binding_json"]) != value["binding_sha256"]
|
||||
or binding.get("schema") != BINDING_SCHEMA
|
||||
or value.get("execution_digest") != canonical_execution_digest
|
||||
or binding.get("execution_digest") != canonical_execution_digest
|
||||
or binding.get("canonical_child_execution_digest")
|
||||
!= canonical_execution_digest
|
||||
or binding.get("source_business_execution_digest")
|
||||
!= SOURCE_BUSINESS_EXECUTION_DIGEST
|
||||
or binding.get("owner_supersession_decision_sha256")
|
||||
!= relation["owner_decision_sha256"]
|
||||
or policy.get("execution_plan_sha256") != canonical_execution_digest
|
||||
):
|
||||
_fail("CLAIM_GUARD_BINDING_DIGEST_MISMATCH")
|
||||
task = connection.execute("SELECT * FROM tasks WHERE task_id=?", (CHILD_TASK_ID,)).fetchone()
|
||||
task = _row_dict(task)
|
||||
if (
|
||||
task.get("status") != "QUEUED" or int(task.get("revision", -1)) != int(binding["expected_revision"])
|
||||
or int(task.get("attempt", -1)) != 0 or task.get("claimed_by") is not None
|
||||
or task.get("lease_token_hash") is not None or task.get("lease_expires_at") is not None
|
||||
or _sha_text(task.get("metadata_json", "")) != binding["metadata_sha256"]
|
||||
or task.get("result_json") is not None or task.get("verification_json") is not None
|
||||
):
|
||||
_fail("CHILD_CLAIM_GUARD_TASK_CAS_MISMATCH")
|
||||
return binding, policy
|
||||
|
||||
|
||||
def _run_child_guard(connection, binding, policy_value, agent_id):
|
||||
try:
|
||||
from aios_single_host_operational_authority import OperationalPolicy, OperationalVerifier
|
||||
from aios_singlehost_claim_guard_stage1 import _LiveReader, REGISTRY_PATH
|
||||
from aios_task_claim_guard import ClaimGuard, ClaimGuardError as CoreError
|
||||
except ImportError as exc:
|
||||
raise ClaimGuardBindingError("CLAIM_GUARD_RUNTIME_UNAVAILABLE", 503) from exc
|
||||
policy = OperationalPolicy.from_mapping(policy_value)
|
||||
cfg = {
|
||||
"task_id": CHILD_TASK_ID, "expected_claim_revision": binding["expected_revision"],
|
||||
"task_metadata_sha256": binding["metadata_sha256"],
|
||||
"required_capabilities_sha256": binding["required_capabilities_sha256"],
|
||||
"task_contract_version": binding["task_contract_version"], "worker_id": WORKER_ID,
|
||||
"policy_digest": binding["policy_digest"], "runtime_id": binding["runtime_id"],
|
||||
"runtime_observation_digest": binding["runtime_observation_digest"],
|
||||
}
|
||||
reader = _LiveReader(connection, policy, cfg, now=int(datetime.now(timezone.utc).timestamp()))
|
||||
try:
|
||||
ClaimGuard(policy, OperationalVerifier(REGISTRY_PATH, require_root_owner=True)).validate_in_transaction(
|
||||
reader, task_id=CHILD_TASK_ID,
|
||||
expected_revision=int(binding["expected_revision"]), worker_id=agent_id,
|
||||
)
|
||||
except CoreError as exc:
|
||||
raise ClaimGuardBindingError(exc.code) from exc
|
||||
|
||||
|
||||
def enforce_claim_guard(connection, *, task_id: str, agent_id: str) -> None:
|
||||
"""Exact two-lineage dispatcher; unrelated Tasks retain their prior behavior."""
|
||||
if task_id == SOURCE_TASK_ID:
|
||||
try:
|
||||
from aios_singlehost_claim_guard_stage1 import enforce_claim_guard as frozen_guard
|
||||
except ImportError as exc:
|
||||
raise ClaimGuardBindingError("FROZEN_SOURCE_CLAIM_GUARD_UNAVAILABLE", 503) from exc
|
||||
frozen_guard(connection, task_id=task_id, agent_id=agent_id)
|
||||
return
|
||||
if task_id != CHILD_TASK_ID:
|
||||
return
|
||||
if agent_id != WORKER_ID:
|
||||
_fail("WORKER_ID_MISMATCH", 403)
|
||||
binding, policy = _load_child_binding(connection)
|
||||
_run_child_guard(connection, binding, policy, agent_id)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""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"]}
|
||||
@@ -0,0 +1,339 @@
|
||||
"""One-time, Owner-signed, append-only supersession for one exact Task lineage."""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
DECISION_SCHEMA = "aios.task-lineage-supersession-decision.v1"
|
||||
RECEIPT_SCHEMA = "aios.task-lineage-supersession-receipt.v1"
|
||||
OWNER_NAMESPACE = "aios-owner-approval-v1"
|
||||
OWNER_PRINCIPAL = "aios-task-grant-signer"
|
||||
OWNER_KEY_ID = "SHA256:rPqpxfPHtaZPM3zSWojX8Q7VxzCknsYqOokyo/aAM3A"
|
||||
SOURCE_TASK_ID = "TASK-SINGLEHOST-TRUSTROOT-P0-20260904"
|
||||
CHILD_TASK_ID = SOURCE_TASK_ID + "-S1"
|
||||
REPOSITORY = "aios/aios"
|
||||
TARGET_BRANCH = "agent/single-host-trust-root-p0-20260904"
|
||||
GENERATION = 1
|
||||
EXECUTION_DIGEST = "e99bda6a251832eb3865271eb5687af7eea44dd261144cdcdfa57104c0780536"
|
||||
EXTERNAL_JOB_ID = "job-4e10f92a6a5742518a9f1d8745c845ab"
|
||||
SUPERSESSION_ORDINAL = 1
|
||||
CANONICAL_PROFILE = "aios.task-initial-execution.v1"
|
||||
CANONICAL_PROFILE_VERSION = 1
|
||||
SUPERSESSION_REASON = "formal-lineage-supersession-after-expired-unclaimed-source"
|
||||
MAX_TTL = timedelta(minutes=5)
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+:-]{2,511}$")
|
||||
|
||||
|
||||
class TaskLineageSupersessionError(RuntimeError):
|
||||
def __init__(self, code: str, http_status: int = 409):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.http_status = int(http_status)
|
||||
|
||||
|
||||
class SignatureVerifier(Protocol):
|
||||
def verify(self, *, namespace: str, payload: bytes, signature: str, key_id: str) -> bool: ...
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
|
||||
|
||||
def sha256_text(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _fail(code: str, status: int = 409) -> None:
|
||||
raise TaskLineageSupersessionError(code, status)
|
||||
|
||||
|
||||
def _utc(value: Any, field: str) -> datetime:
|
||||
if not isinstance(value, str) or not value or len(value) > 80:
|
||||
_fail(field.upper() + "_INVALID", 400)
|
||||
try:
|
||||
result = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise TaskLineageSupersessionError(field.upper() + "_INVALID", 400) from exc
|
||||
if result.tzinfo is None:
|
||||
_fail(field.upper() + "_INVALID", 400)
|
||||
return result.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _text(value: Any, field: str, *, maximum: int = 1024) -> str:
|
||||
if not isinstance(value, str) or not value or len(value) > maximum:
|
||||
_fail(field.upper() + "_INVALID", 400)
|
||||
return value
|
||||
|
||||
|
||||
def decision_payload(raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {key: raw[key] for key in raw if key != "signature"}
|
||||
|
||||
|
||||
DECISION_FIELDS = {
|
||||
"schema", "decision_id", "nonce", "source_task_id",
|
||||
"source_task_identity_sha256", "source_metadata_sha256",
|
||||
"source_expected_status", "source_expected_revision", "source_expected_attempt",
|
||||
"source_expected_claimed_by", "source_expected_lease_token_hash",
|
||||
"source_expected_lease_expires_at", "repository", "target_branch", "generation",
|
||||
"execution_digest", "external_job_id", "child_task_id", "supersession_ordinal",
|
||||
"canonical_projection_profile", "canonical_projection_version", "supersession_reason",
|
||||
"owner_identity", "signature_namespace", "signature_principal", "key_id",
|
||||
"issued_at", "not_before", "expires_at", "one_time_use", "signature",
|
||||
}
|
||||
|
||||
|
||||
def validate_decision(raw: Mapping[str, Any], *, now: datetime) -> dict[str, Any]:
|
||||
if not isinstance(raw, Mapping) or set(raw) != DECISION_FIELDS:
|
||||
_fail("DECISION_FIELDS_INVALID", 400)
|
||||
exact = {
|
||||
"schema": DECISION_SCHEMA,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"source_expected_status": "EXPIRED",
|
||||
"source_expected_attempt": 0,
|
||||
"source_expected_claimed_by": None,
|
||||
"source_expected_lease_token_hash": None,
|
||||
"source_expected_lease_expires_at": None,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"execution_digest": EXECUTION_DIGEST,
|
||||
"external_job_id": EXTERNAL_JOB_ID,
|
||||
"child_task_id": CHILD_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
"supersession_reason": SUPERSESSION_REASON,
|
||||
"owner_identity": OWNER_PRINCIPAL,
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_principal": OWNER_PRINCIPAL,
|
||||
"key_id": OWNER_KEY_ID,
|
||||
"one_time_use": True,
|
||||
}
|
||||
for field, expected in exact.items():
|
||||
if raw[field] != expected:
|
||||
_fail(field.upper() + "_MISMATCH", 403 if field.startswith(("owner", "signature", "key")) else 409)
|
||||
for field in ("decision_id", "nonce"):
|
||||
value = _text(raw[field], field, maximum=256)
|
||||
if SAFE_ID.fullmatch(value) is None:
|
||||
_fail(field.upper() + "_INVALID", 400)
|
||||
for field in ("source_task_identity_sha256", "source_metadata_sha256"):
|
||||
if not isinstance(raw[field], str) or HEX64.fullmatch(raw[field]) is None:
|
||||
_fail(field.upper() + "_INVALID", 400)
|
||||
if type(raw["source_expected_revision"]) is not int or raw["source_expected_revision"] < 1:
|
||||
_fail("SOURCE_EXPECTED_REVISION_INVALID", 400)
|
||||
_text(raw["signature"], "signature", maximum=32768)
|
||||
issued_at = _utc(raw["issued_at"], "issued_at")
|
||||
not_before = _utc(raw["not_before"], "not_before")
|
||||
expires_at = _utc(raw["expires_at"], "expires_at")
|
||||
if issued_at > expires_at or not_before > expires_at:
|
||||
_fail("DECISION_TIME_ORDER_INVALID", 403)
|
||||
if expires_at - issued_at > MAX_TTL or expires_at <= issued_at:
|
||||
_fail("DECISION_TTL_INVALID", 403)
|
||||
now = now.astimezone(timezone.utc)
|
||||
if now < not_before or now >= expires_at:
|
||||
_fail("DECISION_EXPIRED_OR_NOT_YET_VALID", 403)
|
||||
if issued_at > now + timedelta(seconds=5) or now - issued_at > MAX_TTL:
|
||||
_fail("DECISION_ISSUED_AT_INVALID", 403)
|
||||
return {**dict(raw), "issued_at_dt": issued_at, "not_before_dt": not_before, "expires_at_dt": expires_at}
|
||||
|
||||
|
||||
def _default_identity_digest(row: sqlite3.Row) -> str:
|
||||
try:
|
||||
from aios_expired_task_recovery import task_identity_digest
|
||||
except ImportError as exc:
|
||||
raise TaskLineageSupersessionError("FORMAL_TASK_IDENTITY_HELPER_UNAVAILABLE", 503) from exc
|
||||
return str(task_identity_digest(row))
|
||||
|
||||
|
||||
def _json_object(row: sqlite3.Row, field: str) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(row[field])
|
||||
except (TypeError, ValueError, KeyError) as exc:
|
||||
raise TaskLineageSupersessionError("SOURCE_TASK_JSON_INVALID", 409) from exc
|
||||
if not isinstance(value, Mapping):
|
||||
_fail("SOURCE_TASK_JSON_INVALID")
|
||||
return dict(value)
|
||||
|
||||
|
||||
def _verify_source(row: sqlite3.Row, d: Mapping[str, Any], identity_digest: Callable[[sqlite3.Row], str]) -> None:
|
||||
if row["task_id"] != SOURCE_TASK_ID:
|
||||
_fail("SOURCE_TASK_ID_MISMATCH")
|
||||
cas = (
|
||||
row["status"] == "EXPIRED"
|
||||
and int(row["revision"]) == d["source_expected_revision"]
|
||||
and int(row["attempt"]) == 0
|
||||
and row["claimed_by"] is None
|
||||
and row["lease_token_hash"] is None
|
||||
and row["lease_expires_at"] is None
|
||||
)
|
||||
if not cas:
|
||||
_fail("SOURCE_TASK_EXACT_CAS_MISMATCH")
|
||||
metadata_raw = row["metadata_json"]
|
||||
if not isinstance(metadata_raw, str) or sha256_text(metadata_raw) != d["source_metadata_sha256"]:
|
||||
_fail("SOURCE_METADATA_DIGEST_MISMATCH")
|
||||
if identity_digest(row) != d["source_task_identity_sha256"]:
|
||||
_fail("SOURCE_TASK_IDENTITY_DIGEST_MISMATCH")
|
||||
metadata = _json_object(row, "metadata_json")
|
||||
execution = metadata.get("execution")
|
||||
constraints = _json_object(row, "constraints_json")
|
||||
if not isinstance(execution, Mapping):
|
||||
_fail("SOURCE_EXECUTION_BINDING_INVALID")
|
||||
expected = {
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"execution_digest": EXECUTION_DIGEST,
|
||||
"external_job_id": EXTERNAL_JOB_ID,
|
||||
"task_id": SOURCE_TASK_ID,
|
||||
}
|
||||
if any(execution.get(key) != value for key, value in expected.items()):
|
||||
_fail("SOURCE_EXECUTION_BINDING_MISMATCH")
|
||||
if constraints.get("generation") != GENERATION:
|
||||
_fail("SOURCE_GENERATION_CONSTRAINT_MISMATCH")
|
||||
|
||||
|
||||
class TaskLineageSupersessionController:
|
||||
def __init__(self, db_path: str | Path, verifier: SignatureVerifier, *, clock=None, identity_digest=None):
|
||||
self.db_path = Path(db_path)
|
||||
self.verifier = verifier
|
||||
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
||||
self.identity_digest = identity_digest or _default_identity_digest
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.db_path, timeout=30, isolation_level=None)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA busy_timeout=30000")
|
||||
return connection
|
||||
|
||||
def _now(self) -> datetime:
|
||||
value = self.clock()
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
@staticmethod
|
||||
def _assert_schema(connection: sqlite3.Connection) -> None:
|
||||
required = {"tasks", "events", "task_lineage_supersessions", "task_lineage_supersession_receipts"}
|
||||
actual = {row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
if not required.issubset(actual):
|
||||
_fail("SUPERSESSION_SCHEMA_UNAVAILABLE", 503)
|
||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(tasks)")}
|
||||
task_columns = {
|
||||
"task_id", "goal", "instructions", "success_criteria_json",
|
||||
"required_capabilities_json", "constraints_json", "metadata_json", "deadline",
|
||||
"priority", "max_attempts", "attempt", "status", "claimed_by",
|
||||
"lease_token_hash", "lease_expires_at", "result_json", "result_digest",
|
||||
"result_idempotency_key", "verification_json", "revision", "created_at", "updated_at",
|
||||
}
|
||||
if not task_columns.issubset(columns):
|
||||
_fail("TASK_SCHEMA_UNSUPPORTED", 503)
|
||||
|
||||
def supersede(self, raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
now = self._now()
|
||||
decision = validate_decision(raw, now=now)
|
||||
payload_bytes = canonical_json(decision_payload(raw)).encode("utf-8")
|
||||
if not self.verifier.verify(
|
||||
namespace=OWNER_NAMESPACE, payload=payload_bytes,
|
||||
signature=decision["signature"], key_id=decision["key_id"],
|
||||
):
|
||||
_fail("DECISION_SIGNATURE_INVALID", 403)
|
||||
decision_sha = hashlib.sha256(payload_bytes).hexdigest()
|
||||
now_text = now.isoformat()
|
||||
with closing(self._connect()) as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
self._assert_schema(connection)
|
||||
replay = connection.execute(
|
||||
"SELECT owner_decision_sha256,receipt_json FROM task_lineage_supersession_receipts WHERE decision_id=? OR nonce=?",
|
||||
(decision["decision_id"], decision["nonce"]),
|
||||
).fetchone()
|
||||
if replay is not None:
|
||||
if replay["owner_decision_sha256"] != decision_sha:
|
||||
_fail("DECISION_REPLAY_MISMATCH")
|
||||
result = json.loads(replay["receipt_json"])
|
||||
connection.commit()
|
||||
return {**result, "idempotent": True}
|
||||
source = connection.execute("SELECT * FROM tasks WHERE task_id=?", (SOURCE_TASK_ID,)).fetchone()
|
||||
if source is None:
|
||||
_fail("SOURCE_TASK_NOT_FOUND", 404)
|
||||
_verify_source(source, decision, self.identity_digest)
|
||||
if connection.execute(
|
||||
"SELECT 1 FROM task_lineage_supersessions WHERE source_task_id=? AND supersession_ordinal=?",
|
||||
(SOURCE_TASK_ID, SUPERSESSION_ORDINAL),
|
||||
).fetchone():
|
||||
_fail("SUPERSESSION_ALREADY_EXISTS")
|
||||
if connection.execute("SELECT 1 FROM tasks WHERE task_id=?", (CHILD_TASK_ID,)).fetchone():
|
||||
_fail("CHILD_TASK_ALREADY_EXISTS")
|
||||
connection.execute(
|
||||
"""INSERT INTO tasks(
|
||||
task_id,goal,instructions,success_criteria_json,required_capabilities_json,
|
||||
constraints_json,metadata_json,deadline,priority,max_attempts,attempt,status,
|
||||
claimed_by,lease_token_hash,lease_expires_at,result_json,result_digest,
|
||||
result_idempotency_key,verification_json,revision,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
CHILD_TASK_ID, source["goal"], source["instructions"], source["success_criteria_json"],
|
||||
"[]", source["constraints_json"], "{}", None, source["priority"], source["max_attempts"],
|
||||
0, "QUEUED", None, None, None, None, None, None, None, 1, now_text, now_text,
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"""INSERT INTO task_lineage_supersessions(
|
||||
source_task_id,supersession_ordinal,child_task_id,source_task_identity_sha256,
|
||||
source_metadata_sha256,owner_decision_sha256,decision_id,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)""",
|
||||
(SOURCE_TASK_ID, SUPERSESSION_ORDINAL, CHILD_TASK_ID,
|
||||
decision["source_task_identity_sha256"], decision["source_metadata_sha256"],
|
||||
decision_sha, decision["decision_id"], now_text),
|
||||
)
|
||||
event_payload = {
|
||||
"task_id": CHILD_TASK_ID, "supersedes_task_id": SOURCE_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"source_task_identity_sha256": decision["source_task_identity_sha256"],
|
||||
"source_metadata_sha256": decision["source_metadata_sha256"],
|
||||
"owner_supersession_decision_sha256": decision_sha,
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
}
|
||||
connection.execute(
|
||||
"INSERT INTO events(task_id,agent_id,event_type,payload_json,created_at) VALUES(?,?,?,?,?)",
|
||||
(CHILD_TASK_ID, None, "TASK_CREATED", canonical_json(event_payload), now_text),
|
||||
)
|
||||
receipt = {
|
||||
"schema": RECEIPT_SCHEMA, "status": "SUPERSEDED",
|
||||
"decision_id": decision["decision_id"], "owner_decision_sha256": decision_sha,
|
||||
"source_task_id": SOURCE_TASK_ID, "source_task_identity_sha256": decision["source_task_identity_sha256"],
|
||||
"source_metadata_sha256": decision["source_metadata_sha256"],
|
||||
"source_status": "EXPIRED", "source_revision": decision["source_expected_revision"],
|
||||
"source_attempt": 0, "source_history_preserved": True,
|
||||
"child_task_id": CHILD_TASK_ID, "supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"child_status": "QUEUED", "child_revision": 1, "child_attempt": 0,
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
"exact_identity_cas": True, "one_time_use": True,
|
||||
"owner_signature_namespace": OWNER_NAMESPACE,
|
||||
"owner_signature_principal": OWNER_PRINCIPAL,
|
||||
"owner_signature_key_id": OWNER_KEY_ID,
|
||||
"owner_signature_sha256": sha256_text(decision["signature"]),
|
||||
}
|
||||
connection.execute(
|
||||
"""INSERT INTO task_lineage_supersession_receipts(
|
||||
decision_id,nonce,source_task_id,supersession_ordinal,child_task_id,
|
||||
owner_decision_sha256,decision_json,receipt_json,consumed_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)""",
|
||||
(decision["decision_id"], decision["nonce"], SOURCE_TASK_ID, SUPERSESSION_ORDINAL,
|
||||
CHILD_TASK_ID, decision_sha, canonical_json(dict(raw)), canonical_json(receipt), now_text),
|
||||
)
|
||||
connection.commit()
|
||||
return {**receipt, "idempotent": False}
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,548 @@
|
||||
"""Canonical initial projection for the exact Single-Host supersession child.
|
||||
|
||||
The source business execution digest is lineage evidence. It is deliberately
|
||||
not used as the child execution-envelope digest: the canonical envelope binds
|
||||
the child Task id and therefore has its own server-derived digest.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_agent_task_initial_execution_projection import (
|
||||
InitialExecutionProjectionError,
|
||||
InitialExecutionProjectionRequest,
|
||||
InitialExecutionProjectionStore,
|
||||
RUNTIME_CAPABILITY_RE,
|
||||
_now,
|
||||
_require_string,
|
||||
_required_authorization_id,
|
||||
)
|
||||
from aios_online_agent_execution import (
|
||||
ExecutionProjectionError,
|
||||
GovernedExecutionBinding,
|
||||
validate_execution_envelope,
|
||||
)
|
||||
from aios_task_lineage_supersession import (
|
||||
CANONICAL_PROFILE,
|
||||
CANONICAL_PROFILE_VERSION,
|
||||
CHILD_TASK_ID,
|
||||
DECISION_SCHEMA,
|
||||
EXECUTION_DIGEST as SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
EXTERNAL_JOB_ID,
|
||||
GENERATION,
|
||||
OWNER_KEY_ID,
|
||||
OWNER_NAMESPACE,
|
||||
OWNER_PRINCIPAL,
|
||||
RECEIPT_SCHEMA as SUPERSESSION_RECEIPT_SCHEMA,
|
||||
REPOSITORY,
|
||||
SOURCE_TASK_ID,
|
||||
SUPERSESSION_ORDINAL,
|
||||
TARGET_BRANCH,
|
||||
canonical_json,
|
||||
decision_payload,
|
||||
)
|
||||
|
||||
|
||||
PROJECTION_INTENT_SCHEMA = "aios.task-lineage-supersession-initial-projection-intent.v1"
|
||||
PROJECTION_RECEIPT_SCHEMA = "aios.task-lineage-supersession-initial-projection-receipt.v1"
|
||||
PROJECTION_IDEMPOTENCY_KEY = "task-lineage-supersession-s1-initial-projection-v1"
|
||||
PROJECTION_REASON = "canonical-initial-projection-for-formal-supersession-child"
|
||||
PROJECTION_TABLE = "task_lineage_supersession_initial_projections"
|
||||
|
||||
|
||||
def _sha_text(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _sha_json(value: Any) -> str:
|
||||
return _sha_text(canonical_json(value))
|
||||
|
||||
|
||||
def _json_object(raw: Any, code: str) -> dict[str, Any]:
|
||||
if not isinstance(raw, str):
|
||||
raise InitialExecutionProjectionError(code, 409)
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise InitialExecutionProjectionError(code, 409) from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise InitialExecutionProjectionError(code, 409)
|
||||
return dict(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SupersessionChildProjectionIntent:
|
||||
"""Caller-selectable portion of the exact child projection.
|
||||
|
||||
Task identity, CAS values, Job identity, projection profile, reason and
|
||||
idempotency are intentionally absent from the request and server-owned.
|
||||
"""
|
||||
|
||||
adapter: str
|
||||
required_capability: str
|
||||
authorization_id: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(
|
||||
cls, value: Mapping[str, Any]
|
||||
) -> "SupersessionChildProjectionIntent":
|
||||
if not isinstance(value, Mapping) or set(value) != {
|
||||
"adapter",
|
||||
"required_capability",
|
||||
"authorization_id",
|
||||
}:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_INITIAL_PROJECTION_FIELDS_INVALID", 400
|
||||
)
|
||||
adapter = _require_string(value["adapter"], "RUNTIME_ADAPTER_INVALID")
|
||||
capability = _require_string(
|
||||
value["required_capability"],
|
||||
"RUNTIME_CAPABILITY_INVALID",
|
||||
RUNTIME_CAPABILITY_RE,
|
||||
)
|
||||
if adapter not in {"hermes", "openclaw"} or capability != f"agent.runtime.{adapter}":
|
||||
raise InitialExecutionProjectionError("RUNTIME_CAPABILITY_MISMATCH", 400)
|
||||
authorization_id = _require_string(
|
||||
value["authorization_id"], "AUTHORIZATION_ID_INVALID"
|
||||
)
|
||||
return cls(adapter, capability, authorization_id)
|
||||
|
||||
def public(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": PROJECTION_INTENT_SCHEMA,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"child_task_id": CHILD_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
"expected_status": "QUEUED",
|
||||
"expected_revision": 1,
|
||||
"expected_attempt": 0,
|
||||
"expected_metadata_sha256": _sha_json({}),
|
||||
"expected_required_capabilities_sha256": _sha_json([]),
|
||||
"expected_job_id": EXTERNAL_JOB_ID,
|
||||
"adapter": self.adapter,
|
||||
"required_capability": self.required_capability,
|
||||
"authorization_id": self.authorization_id,
|
||||
"idempotency_key": PROJECTION_IDEMPOTENCY_KEY,
|
||||
"reason": PROJECTION_REASON,
|
||||
}
|
||||
|
||||
def initial_request(self, canonical_execution_digest: str) -> InitialExecutionProjectionRequest:
|
||||
return InitialExecutionProjectionRequest.from_mapping(
|
||||
{
|
||||
"expected_status": "QUEUED",
|
||||
"expected_revision": 1,
|
||||
"expected_attempt": 0,
|
||||
"expected_metadata_sha256": _sha_json({}),
|
||||
"expected_required_capabilities_sha256": _sha_json([]),
|
||||
"expected_job_id": EXTERNAL_JOB_ID,
|
||||
"expected_execution_digest": canonical_execution_digest,
|
||||
"required_capability": self.required_capability,
|
||||
"adapter": self.adapter,
|
||||
"authorization_id": self.authorization_id,
|
||||
"idempotency_key": PROJECTION_IDEMPOTENCY_KEY,
|
||||
"reason": PROJECTION_REASON,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
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'"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _load_lineage_context(
|
||||
connection: sqlite3.Connection, *, expect_projected: bool
|
||||
) -> dict[str, Any]:
|
||||
required_tables = {
|
||||
"tasks",
|
||||
"events",
|
||||
"task_lineage_supersessions",
|
||||
"task_lineage_supersession_receipts",
|
||||
PROJECTION_TABLE,
|
||||
}
|
||||
if not required_tables.issubset(_table_names(connection)):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_INITIAL_PROJECTION_SCHEMA_UNAVAILABLE", 503
|
||||
)
|
||||
relation = connection.execute(
|
||||
"SELECT * FROM task_lineage_supersessions "
|
||||
"WHERE source_task_id=? AND supersession_ordinal=? AND child_task_id=?",
|
||||
(SOURCE_TASK_ID, SUPERSESSION_ORDINAL, CHILD_TASK_ID),
|
||||
).fetchone()
|
||||
if relation is None:
|
||||
raise InitialExecutionProjectionError("FORMAL_SUPERSESSION_REQUIRED", 409)
|
||||
receipt_row = connection.execute(
|
||||
"SELECT * FROM task_lineage_supersession_receipts "
|
||||
"WHERE decision_id=? AND source_task_id=? AND supersession_ordinal=? "
|
||||
"AND child_task_id=?",
|
||||
(
|
||||
relation["decision_id"],
|
||||
SOURCE_TASK_ID,
|
||||
SUPERSESSION_ORDINAL,
|
||||
CHILD_TASK_ID,
|
||||
),
|
||||
).fetchone()
|
||||
if receipt_row is None:
|
||||
raise InitialExecutionProjectionError("SUPERSESSION_RECEIPT_REQUIRED", 409)
|
||||
decision = _json_object(receipt_row["decision_json"], "SUPERSESSION_DECISION_INVALID")
|
||||
receipt = _json_object(receipt_row["receipt_json"], "SUPERSESSION_RECEIPT_INVALID")
|
||||
decision_sha256 = _sha_text(canonical_json(decision_payload(decision)))
|
||||
exact_decision = {
|
||||
"schema": DECISION_SCHEMA,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"child_task_id": CHILD_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"external_job_id": EXTERNAL_JOB_ID,
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
"owner_identity": OWNER_PRINCIPAL,
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_principal": OWNER_PRINCIPAL,
|
||||
"key_id": OWNER_KEY_ID,
|
||||
"one_time_use": True,
|
||||
}
|
||||
if any(decision.get(key) != expected for key, expected in exact_decision.items()):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_DECISION_SCOPE_MISMATCH", 409
|
||||
)
|
||||
if (
|
||||
decision_sha256 != relation["owner_decision_sha256"]
|
||||
or decision_sha256 != receipt_row["owner_decision_sha256"]
|
||||
or decision.get("source_task_identity_sha256")
|
||||
!= relation["source_task_identity_sha256"]
|
||||
or decision.get("source_metadata_sha256") != relation["source_metadata_sha256"]
|
||||
or receipt.get("schema") != SUPERSESSION_RECEIPT_SCHEMA
|
||||
or receipt.get("owner_decision_sha256") != decision_sha256
|
||||
or receipt.get("source_task_id") != SOURCE_TASK_ID
|
||||
or receipt.get("child_task_id") != CHILD_TASK_ID
|
||||
or receipt.get("supersession_ordinal") != SUPERSESSION_ORDINAL
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_LINEAGE_EVIDENCE_MISMATCH", 409
|
||||
)
|
||||
events = connection.execute(
|
||||
"SELECT event_type,payload_json FROM events WHERE task_id=? ORDER BY event_id",
|
||||
(CHILD_TASK_ID,),
|
||||
).fetchall()
|
||||
expected_events = ["TASK_CREATED"]
|
||||
if expect_projected:
|
||||
expected_events.append("TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZED")
|
||||
if [str(row["event_type"]) for row in events] != expected_events:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_CHILD_EVENT_CHAIN_INVALID", 409
|
||||
)
|
||||
created = _json_object(events[0]["payload_json"], "SUPERSESSION_CREATED_EVENT_INVALID")
|
||||
exact_created = {
|
||||
"task_id": CHILD_TASK_ID,
|
||||
"supersedes_task_id": SOURCE_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"source_task_identity_sha256": relation["source_task_identity_sha256"],
|
||||
"source_metadata_sha256": relation["source_metadata_sha256"],
|
||||
"owner_supersession_decision_sha256": decision_sha256,
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
}
|
||||
if any(created.get(key) != expected for key, expected in exact_created.items()):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_CREATED_EVENT_SCOPE_MISMATCH", 409
|
||||
)
|
||||
return {
|
||||
"source_task_identity_sha256": relation["source_task_identity_sha256"],
|
||||
"source_metadata_sha256": relation["source_metadata_sha256"],
|
||||
"owner_decision_sha256": decision_sha256,
|
||||
}
|
||||
|
||||
|
||||
def _validate_canonical_child_execution(
|
||||
resolved: Mapping[str, Any],
|
||||
*,
|
||||
intent: SupersessionChildProjectionIntent,
|
||||
runtime_input: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
binding = GovernedExecutionBinding.from_mapping(resolved)
|
||||
execution = validate_execution_envelope(resolved, binding=binding)
|
||||
except ExecutionProjectionError as exc:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_CANONICAL_EXECUTION_INVALID", 409
|
||||
) from exc
|
||||
exact_binding = {
|
||||
"task_id": CHILD_TASK_ID,
|
||||
"job_id": EXTERNAL_JOB_ID,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
}
|
||||
if any(execution.get(key) != expected for key, expected in exact_binding.items()):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_CANONICAL_EXECUTION_BINDING_MISMATCH", 409
|
||||
)
|
||||
if (
|
||||
execution.get("action") != "agent_runtime"
|
||||
or execution.get("adapter") != intent.adapter
|
||||
or execution.get("capability_scope") != [intent.required_capability]
|
||||
or execution.get("runtime_input") != dict(runtime_input)
|
||||
or execution.get("change_set") != []
|
||||
or execution.get("verification") != []
|
||||
or execution.get("governance", {}).get("writer_lease") is not False
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_AGENT_RUNTIME_ENVELOPE_INVALID", 409
|
||||
)
|
||||
if execution["execution_digest"] == SOURCE_BUSINESS_EXECUTION_DIGEST:
|
||||
raise InitialExecutionProjectionError(
|
||||
"CHILD_CANONICAL_DIGEST_COLLIDES_WITH_SOURCE_BUSINESS_DIGEST", 409
|
||||
)
|
||||
return execution
|
||||
|
||||
|
||||
class _SupersessionProjectionTransactionStore(InitialExecutionProjectionStore):
|
||||
"""Per-invocation store that adds immutable lineage evidence atomically."""
|
||||
|
||||
allows_supersession_child = True
|
||||
|
||||
def __init__(self, db_path, resolved: Mapping[str, Any], intent):
|
||||
self._resolved = dict(resolved)
|
||||
self.intent = intent
|
||||
super().__init__(db_path, self._resolve_once)
|
||||
|
||||
def _resolve_once(self, *_args):
|
||||
return dict(self._resolved)
|
||||
|
||||
def _projection_event_extension(
|
||||
self, connection, task_id, request, execution, applied_revision
|
||||
):
|
||||
if task_id != CHILD_TASK_ID:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_CHILD_TASK_ID_MISMATCH", 409
|
||||
)
|
||||
context = _load_lineage_context(connection, expect_projected=False)
|
||||
return {
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"source_task_identity_sha256": context["source_task_identity_sha256"],
|
||||
"source_metadata_sha256": context["source_metadata_sha256"],
|
||||
"owner_supersession_decision_sha256": context["owner_decision_sha256"],
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"canonical_child_execution_digest": execution["execution_digest"],
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
}
|
||||
|
||||
def _insert_projection_extension(
|
||||
self,
|
||||
connection,
|
||||
task_id,
|
||||
request,
|
||||
execution,
|
||||
event_payload,
|
||||
applied_revision,
|
||||
):
|
||||
context = _load_lineage_context(connection, expect_projected=True)
|
||||
task = connection.execute(
|
||||
"SELECT metadata_json,required_capabilities_json FROM tasks WHERE task_id=?",
|
||||
(CHILD_TASK_ID,),
|
||||
).fetchone()
|
||||
if task is None:
|
||||
raise InitialExecutionProjectionError("TASK_NOT_FOUND", 404)
|
||||
intent_json = canonical_json(self.intent.public())
|
||||
intent_sha256 = _sha_text(intent_json)
|
||||
receipt = {
|
||||
"schema": PROJECTION_RECEIPT_SCHEMA,
|
||||
"projection_id": "sha256:" + intent_sha256,
|
||||
"task_id": CHILD_TASK_ID,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"source_task_identity_sha256": context["source_task_identity_sha256"],
|
||||
"source_metadata_sha256": context["source_metadata_sha256"],
|
||||
"owner_supersession_decision_sha256": context["owner_decision_sha256"],
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"canonical_child_execution_digest": execution["execution_digest"],
|
||||
"external_job_id": EXTERNAL_JOB_ID,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"adapter": self.intent.adapter,
|
||||
"required_capability": self.intent.required_capability,
|
||||
"expected_revision": 1,
|
||||
"applied_revision": applied_revision,
|
||||
"attempt": 0,
|
||||
"metadata_sha256": _sha_text(task["metadata_json"]),
|
||||
"required_capabilities_sha256": _sha_text(
|
||||
task["required_capabilities_json"]
|
||||
),
|
||||
"projection_event_sha256": _sha_json(dict(event_payload)),
|
||||
}
|
||||
receipt_json = canonical_json(receipt)
|
||||
receipt_sha256 = _sha_text(receipt_json)
|
||||
connection.execute(
|
||||
f"""INSERT INTO {PROJECTION_TABLE}(
|
||||
projection_id,task_id,source_task_id,supersession_ordinal,
|
||||
intent_sha256,intent_json,owner_decision_sha256,
|
||||
source_task_identity_sha256,source_metadata_sha256,
|
||||
source_business_execution_digest,canonical_child_execution_digest,
|
||||
external_job_id,repository,target_branch,generation,adapter,
|
||||
required_capability,expected_revision,applied_revision,metadata_sha256,
|
||||
required_capabilities_sha256,projection_event_sha256,receipt_json,
|
||||
receipt_sha256,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
receipt["projection_id"],
|
||||
CHILD_TASK_ID,
|
||||
SOURCE_TASK_ID,
|
||||
SUPERSESSION_ORDINAL,
|
||||
intent_sha256,
|
||||
intent_json,
|
||||
context["owner_decision_sha256"],
|
||||
context["source_task_identity_sha256"],
|
||||
context["source_metadata_sha256"],
|
||||
SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
execution["execution_digest"],
|
||||
EXTERNAL_JOB_ID,
|
||||
REPOSITORY,
|
||||
TARGET_BRANCH,
|
||||
GENERATION,
|
||||
self.intent.adapter,
|
||||
self.intent.required_capability,
|
||||
1,
|
||||
applied_revision,
|
||||
receipt["metadata_sha256"],
|
||||
receipt["required_capabilities_sha256"],
|
||||
receipt["projection_event_sha256"],
|
||||
receipt_json,
|
||||
receipt_sha256,
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SupersessionChildInitialProjectionStore:
|
||||
"""Server-owned adapter from exact supersession lineage to current ABI."""
|
||||
|
||||
def __init__(self, db_path, resolver):
|
||||
self.db_path = db_path
|
||||
self.resolver = resolver
|
||||
|
||||
def _connect(self):
|
||||
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 _replay(connection, intent: SupersessionChildProjectionIntent):
|
||||
if PROJECTION_TABLE not in _table_names(connection):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_INITIAL_PROJECTION_SCHEMA_UNAVAILABLE", 503
|
||||
)
|
||||
row = connection.execute(
|
||||
f"SELECT * FROM {PROJECTION_TABLE} WHERE task_id=?", (CHILD_TASK_ID,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
tables = _table_names(connection)
|
||||
if "task_initial_execution_projections" in tables:
|
||||
generic = connection.execute(
|
||||
"SELECT 1 FROM task_initial_execution_projections "
|
||||
"WHERE task_id=? AND idempotency_key=?",
|
||||
(CHILD_TASK_ID, PROJECTION_IDEMPOTENCY_KEY),
|
||||
).fetchone()
|
||||
if generic is not None:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_PROJECTION_RECEIPT_MISSING", 500
|
||||
)
|
||||
return None
|
||||
intent_sha256 = _sha_json(intent.public())
|
||||
if row["intent_sha256"] != intent_sha256:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_INITIAL_PROJECTION_INTENT_CONFLICT", 409
|
||||
)
|
||||
receipt = _json_object(
|
||||
row["receipt_json"], "SUPERSESSION_PROJECTION_RECEIPT_INVALID"
|
||||
)
|
||||
if (
|
||||
_sha_text(row["receipt_json"]) != row["receipt_sha256"]
|
||||
or receipt.get("schema") != PROJECTION_RECEIPT_SCHEMA
|
||||
or receipt.get("canonical_child_execution_digest")
|
||||
!= row["canonical_child_execution_digest"]
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_PROJECTION_RECEIPT_DIGEST_MISMATCH", 500
|
||||
)
|
||||
_load_lineage_context(connection, expect_projected=True)
|
||||
task = connection.execute(
|
||||
"SELECT * FROM tasks WHERE task_id=?", (CHILD_TASK_ID,)
|
||||
).fetchone()
|
||||
if (
|
||||
task is None
|
||||
or task["status"] != "QUEUED"
|
||||
or int(task["revision"]) != int(row["applied_revision"])
|
||||
or int(task["attempt"]) != 0
|
||||
or task["claimed_by"] is not None
|
||||
or task["lease_token_hash"] is not None
|
||||
or task["lease_expires_at"] is not None
|
||||
or _sha_text(task["metadata_json"]) != row["metadata_sha256"]
|
||||
or _sha_text(task["required_capabilities_json"])
|
||||
!= row["required_capabilities_sha256"]
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_PROJECTED_TASK_CAS_MISMATCH", 409
|
||||
)
|
||||
return {**receipt, "idempotent": True}
|
||||
|
||||
def project(self, value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
intent = SupersessionChildProjectionIntent.from_mapping(value)
|
||||
if intent.authorization_id != _required_authorization_id():
|
||||
raise InitialExecutionProjectionError("AUTHORIZATION_ID_MISMATCH", 403)
|
||||
with self._connect() as connection:
|
||||
replay = self._replay(connection, intent)
|
||||
if replay is not None:
|
||||
return replay
|
||||
probe_request = intent.initial_request("0" * 64)
|
||||
probe_store = _SupersessionProjectionTransactionStore(
|
||||
self.db_path, {}, intent
|
||||
)
|
||||
task, _, _, _ = probe_store._load_and_validate(
|
||||
connection, CHILD_TASK_ID, probe_request
|
||||
)
|
||||
_load_lineage_context(connection, expect_projected=False)
|
||||
runtime_input = probe_store._runtime_input(task)
|
||||
resolved = dict(
|
||||
self.resolver(
|
||||
CHILD_TASK_ID,
|
||||
EXTERNAL_JOB_ID,
|
||||
intent.required_capability,
|
||||
intent.adapter,
|
||||
runtime_input,
|
||||
)
|
||||
)
|
||||
execution = _validate_canonical_child_execution(
|
||||
resolved, intent=intent, runtime_input=runtime_input
|
||||
)
|
||||
store = _SupersessionProjectionTransactionStore(
|
||||
self.db_path, resolved, intent
|
||||
)
|
||||
result = store.project(
|
||||
CHILD_TASK_ID, intent.initial_request(execution["execution_digest"])
|
||||
)
|
||||
with self._connect() as connection:
|
||||
receipt = self._replay(connection, intent)
|
||||
if receipt is None:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_PROJECTION_RECEIPT_MISSING", 500
|
||||
)
|
||||
return {**receipt, "idempotent": bool(result["idempotent"])}
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Fixed Owner operation for one exact Task-lineage supersession.
|
||||
|
||||
This staging overlay deliberately extends the installed single-host operational
|
||||
authority instead of exposing a generic signing primitive. The caller can
|
||||
only provide the three live CAS values which cannot be known at build time.
|
||||
Every authority, lineage and execution field is constructed server-side.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import secrets
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_single_host_authority import OWNER_IDENTITY, OWNER_NAMESPACE, sha256_json
|
||||
from aios_single_host_operational_authority import (
|
||||
OperationalAuthorityError,
|
||||
OperationalOwnerAuthority,
|
||||
OperationalPolicy,
|
||||
SignedOperationalArtifact,
|
||||
)
|
||||
|
||||
TASK_LINEAGE_SUPERSESSION_OPERATION = "issue_task_lineage_supersession"
|
||||
TASK_LINEAGE_SUPERSESSION_SCHEMA = "aios.task-lineage-supersession-decision.v1"
|
||||
SOURCE_TASK_ID = "TASK-SINGLEHOST-TRUSTROOT-P0-20260904"
|
||||
CHILD_TASK_ID = SOURCE_TASK_ID + "-S1"
|
||||
SUPERSESSION_ORDINAL = 1
|
||||
REPOSITORY = "aios/aios"
|
||||
TARGET_BRANCH = "agent/single-host-trust-root-p0-20260904"
|
||||
GENERATION = 1
|
||||
SOURCE_BUSINESS_EXECUTION_DIGEST = (
|
||||
"e99bda6a251832eb3865271eb5687af7eea44dd261144cdcdfa57104c0780536"
|
||||
)
|
||||
EXTERNAL_JOB_IDENTITY = "job-4e10f92a6a5742518a9f1d8745c845ab"
|
||||
CANONICAL_PROJECTION_PROFILE = "aios.task-initial-execution.v1"
|
||||
CANONICAL_PROJECTION_VERSION = 1
|
||||
SUPERSESSION_REASON = "formal-lineage-supersession-after-expired-unclaimed-source"
|
||||
DECISION_TTL_SECONDS = 300
|
||||
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
SAFE_TOKEN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+:-]{2,511}$")
|
||||
|
||||
|
||||
def _fail(code: str) -> None:
|
||||
raise OperationalAuthorityError(code)
|
||||
|
||||
|
||||
def _assert_fixed_policy(policy: OperationalPolicy) -> None:
|
||||
"""Refuse issuance if the live policy is not the approved source scope."""
|
||||
policy.validate()
|
||||
exact = {
|
||||
"task_id": SOURCE_TASK_ID,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"execution_plan_sha256": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"external_job_identity": EXTERNAL_JOB_IDENTITY,
|
||||
}
|
||||
if any(getattr(policy, field) != expected for field, expected in exact.items()):
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_POLICY_MISMATCH")
|
||||
if policy.maximum_recovery_decision_ttl_seconds < DECISION_TTL_SECONDS:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_POLICY_TTL_TOO_NARROW")
|
||||
|
||||
|
||||
TASK_LINEAGE_SUPERSESSION_FIELDS = {
|
||||
"schema",
|
||||
"decision_id",
|
||||
"nonce",
|
||||
"source_task_id",
|
||||
"source_task_identity_sha256",
|
||||
"source_metadata_sha256",
|
||||
"source_expected_status",
|
||||
"source_expected_revision",
|
||||
"source_expected_attempt",
|
||||
"source_expected_claimed_by",
|
||||
"source_expected_lease_token_hash",
|
||||
"source_expected_lease_expires_at",
|
||||
"repository",
|
||||
"target_branch",
|
||||
"generation",
|
||||
"source_business_execution_digest",
|
||||
"external_job_identity",
|
||||
"child_task_id",
|
||||
"supersession_ordinal",
|
||||
"canonical_projection_profile",
|
||||
"canonical_projection_version",
|
||||
"supersession_reason",
|
||||
"owner_identity",
|
||||
"policy_digest",
|
||||
"one_time_use",
|
||||
"issued_at",
|
||||
"not_before",
|
||||
"expires_at",
|
||||
"signature_namespace",
|
||||
"signature_key_id",
|
||||
"canonical_payload_digest",
|
||||
}
|
||||
|
||||
|
||||
def verify_task_lineage_supersession_scope(
|
||||
decision: Mapping[str, Any],
|
||||
policy: OperationalPolicy,
|
||||
*,
|
||||
now: int,
|
||||
) -> None:
|
||||
"""Validate a signature-verified decision against the fixed source scope."""
|
||||
_assert_fixed_policy(policy)
|
||||
if not isinstance(decision, Mapping) or set(decision) != TASK_LINEAGE_SUPERSESSION_FIELDS:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_FIELDS_INVALID")
|
||||
exact = {
|
||||
"schema": TASK_LINEAGE_SUPERSESSION_SCHEMA,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"source_expected_status": "EXPIRED",
|
||||
"source_expected_attempt": 0,
|
||||
"source_expected_claimed_by": None,
|
||||
"source_expected_lease_token_hash": None,
|
||||
"source_expected_lease_expires_at": None,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"external_job_identity": EXTERNAL_JOB_IDENTITY,
|
||||
"child_task_id": CHILD_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"canonical_projection_profile": CANONICAL_PROJECTION_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROJECTION_VERSION,
|
||||
"supersession_reason": SUPERSESSION_REASON,
|
||||
"owner_identity": OWNER_IDENTITY,
|
||||
"policy_digest": policy.digest,
|
||||
"one_time_use": True,
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
}
|
||||
if any(decision.get(field) != expected for field, expected in exact.items()):
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_SCOPE_INVALID")
|
||||
if decision.get("source_task_identity_sha256") != policy.task_identity_sha256:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_SOURCE_IDENTITY_INVALID")
|
||||
for field in ("source_task_identity_sha256", "source_metadata_sha256"):
|
||||
if not isinstance(decision.get(field), str) or HEX64.fullmatch(decision[field]) is None:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_DIGEST_INVALID")
|
||||
if type(decision.get("source_expected_revision")) is not int or not 1 <= decision["source_expected_revision"] < 2**63:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_REVISION_INVALID")
|
||||
for field in ("decision_id", "nonce"):
|
||||
if not isinstance(decision.get(field), str) or SAFE_TOKEN.fullmatch(decision[field]) is None:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_GENERATED_ID_INVALID")
|
||||
for field in ("issued_at", "not_before", "expires_at"):
|
||||
if type(decision.get(field)) is not int:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_TIME_INVALID")
|
||||
if not decision["not_before"] <= now < decision["expires_at"]:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_TIME_INVALID")
|
||||
if decision["issued_at"] > now + 5 or now - decision["issued_at"] > DECISION_TTL_SECONDS:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_TIME_INVALID")
|
||||
if decision["expires_at"] - decision["issued_at"] != DECISION_TTL_SECONDS:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_TIME_INVALID")
|
||||
digest_value = dict(decision)
|
||||
supplied_digest = digest_value.pop("canonical_payload_digest", None)
|
||||
if supplied_digest != sha256_json(digest_value):
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_CANONICAL_DIGEST_INVALID")
|
||||
|
||||
|
||||
class TaskLineageSupersessionOwnerAuthority(OperationalOwnerAuthority):
|
||||
"""Owner signer with one bounded constructor and no caller-defined message."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args: Any,
|
||||
token_hex: Callable[[int], str] = secrets.token_hex,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._token_hex = token_hex
|
||||
|
||||
def issue_task_lineage_supersession(
|
||||
self,
|
||||
*,
|
||||
source_expected_revision: int,
|
||||
source_task_identity_sha256: str,
|
||||
source_metadata_sha256: str,
|
||||
) -> SignedOperationalArtifact:
|
||||
_assert_fixed_policy(self.policy)
|
||||
if type(source_expected_revision) is not int or not 1 <= source_expected_revision < 2**63:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_REVISION_INVALID")
|
||||
if source_task_identity_sha256 != self.policy.task_identity_sha256:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_SOURCE_IDENTITY_INVALID")
|
||||
if not isinstance(source_metadata_sha256, str) or HEX64.fullmatch(source_metadata_sha256) is None:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_METADATA_DIGEST_INVALID")
|
||||
|
||||
decision_id = "task-lineage-supersession-" + self._token_hex(16)
|
||||
nonce = "task-lineage-supersession-nonce-" + self._token_hex(16)
|
||||
if SAFE_TOKEN.fullmatch(decision_id) is None or SAFE_TOKEN.fullmatch(nonce) is None:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_GENERATED_ID_INVALID")
|
||||
now = int(self.clock())
|
||||
payload = {
|
||||
"schema": TASK_LINEAGE_SUPERSESSION_SCHEMA,
|
||||
"decision_id": decision_id,
|
||||
"nonce": nonce,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"source_task_identity_sha256": source_task_identity_sha256,
|
||||
"source_metadata_sha256": source_metadata_sha256,
|
||||
"source_expected_status": "EXPIRED",
|
||||
"source_expected_revision": source_expected_revision,
|
||||
"source_expected_attempt": 0,
|
||||
"source_expected_claimed_by": None,
|
||||
"source_expected_lease_token_hash": None,
|
||||
"source_expected_lease_expires_at": None,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"external_job_identity": EXTERNAL_JOB_IDENTITY,
|
||||
"child_task_id": CHILD_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"canonical_projection_profile": CANONICAL_PROJECTION_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROJECTION_VERSION,
|
||||
"supersession_reason": SUPERSESSION_REASON,
|
||||
"owner_identity": OWNER_IDENTITY,
|
||||
"policy_digest": self.policy.digest,
|
||||
"one_time_use": True,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 5),
|
||||
"expires_at": now + DECISION_TTL_SECONDS,
|
||||
}
|
||||
return self._sign(payload)
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Staging-only server overlay for the fixed lineage Owner operation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_single_host_authority import DEFAULT_REGISTRY_PATH
|
||||
from aios_single_host_operational_authority import (
|
||||
OperationalMachineAuthority,
|
||||
OperationalVerifier,
|
||||
)
|
||||
from aios_single_host_operational_authority_server import (
|
||||
DEFAULT_LEDGER,
|
||||
DEFAULT_MACHINE_KEY,
|
||||
DEFAULT_OWNER_KEY,
|
||||
DEFAULT_POLICY,
|
||||
DurableIssuanceLedger,
|
||||
OperationalAuthorityService as BaseOperationalAuthorityService,
|
||||
_read_policy,
|
||||
fail,
|
||||
)
|
||||
from aios_task_lineage_supersession_owner_authority import (
|
||||
TASK_LINEAGE_SUPERSESSION_OPERATION,
|
||||
TaskLineageSupersessionOwnerAuthority,
|
||||
)
|
||||
|
||||
REQUEST_FIELDS = {
|
||||
"source_expected_revision",
|
||||
"source_task_identity_sha256",
|
||||
"source_metadata_sha256",
|
||||
}
|
||||
|
||||
|
||||
class TaskLineageSupersessionIssuanceLedger(DurableIssuanceLedger):
|
||||
"""Operation-scoped one-time fence layered on the existing durable ledger."""
|
||||
|
||||
def issue_once(
|
||||
self,
|
||||
*,
|
||||
role: str,
|
||||
operation: str,
|
||||
request: Mapping[str, Any],
|
||||
issuer: Callable[[], dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
request_sha = hashlib.sha256(
|
||||
json.dumps(
|
||||
request,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
connection = sqlite3.connect(self.path, timeout=5, isolation_level=None)
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute(
|
||||
"SELECT 1 FROM issuance WHERE role=? AND operation=? LIMIT 1",
|
||||
(role, operation),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
connection.rollback()
|
||||
fail("TASK_LINEAGE_SUPERSESSION_ALREADY_ISSUED")
|
||||
result = issuer()
|
||||
artifact = result.get("artifact")
|
||||
nonce = artifact.get("payload", {}).get("nonce") if isinstance(artifact, Mapping) else None
|
||||
if not isinstance(nonce, str) or not nonce:
|
||||
connection.rollback()
|
||||
fail("TASK_LINEAGE_SUPERSESSION_ISSUER_RESULT_INVALID")
|
||||
encoded = json.dumps(
|
||||
result,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO issuance(role,operation,nonce,request_sha256,response_json,created_at) "
|
||||
"VALUES(?,?,?,?,?,?)",
|
||||
(role, operation, nonce, request_sha, encoded, int(time.time())),
|
||||
)
|
||||
connection.commit()
|
||||
return result
|
||||
except sqlite3.IntegrityError:
|
||||
connection.rollback()
|
||||
fail("ISSUANCE_LEDGER_RACE")
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
class OperationalAuthorityService(BaseOperationalAuthorityService):
|
||||
"""Preserve all existing operations and add one exact Owner-only operation."""
|
||||
|
||||
def __init__(self, *args: Any, token_hex=None, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.ledger = TaskLineageSupersessionIssuanceLedger(self.ledger.path)
|
||||
self._token_hex = token_hex
|
||||
|
||||
def _components(self):
|
||||
policy = _read_policy(
|
||||
self.policy_path,
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
verifier = OperationalVerifier(
|
||||
self.registry_path,
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
if self.role == "owner":
|
||||
kwargs = {}
|
||||
if self._token_hex is not None:
|
||||
kwargs["token_hex"] = self._token_hex
|
||||
signer = TaskLineageSupersessionOwnerAuthority(
|
||||
self.owner_key_path,
|
||||
policy,
|
||||
require_root_owner=self.require_root_owner,
|
||||
clock=self.clock,
|
||||
**kwargs,
|
||||
)
|
||||
else:
|
||||
signer = OperationalMachineAuthority(
|
||||
self.machine_key_path,
|
||||
policy,
|
||||
require_root_owner=self.require_root_owner,
|
||||
clock=self.clock,
|
||||
)
|
||||
return policy, verifier, signer
|
||||
|
||||
def handle(self, request: Mapping[str, Any], *, peer_uid: int) -> dict[str, Any]:
|
||||
if peer_uid not in self.allowed_uids:
|
||||
fail("PEER_UID_FORBIDDEN")
|
||||
if not isinstance(request, Mapping) or set(request) != {"operation", "payload"}:
|
||||
fail("REQUEST_INVALID")
|
||||
if request.get("operation") != TASK_LINEAGE_SUPERSESSION_OPERATION:
|
||||
return super().handle(request, peer_uid=peer_uid)
|
||||
if self.role != "owner":
|
||||
fail("MACHINE_OPERATION_FORBIDDEN")
|
||||
payload = request.get("payload")
|
||||
if not isinstance(payload, Mapping) or set(payload) != REQUEST_FIELDS:
|
||||
fail("REQUEST_FIELDS_INVALID")
|
||||
_policy, _verifier, signer = self._components()
|
||||
|
||||
def issue() -> dict[str, Any]:
|
||||
artifact = signer.issue_task_lineage_supersession(
|
||||
source_expected_revision=payload["source_expected_revision"],
|
||||
source_task_identity_sha256=payload["source_task_identity_sha256"],
|
||||
source_metadata_sha256=payload["source_metadata_sha256"],
|
||||
)
|
||||
return {"status": "PASS", "artifact": artifact.to_dict()}
|
||||
|
||||
return self.ledger.issue_once(
|
||||
role=self.role,
|
||||
operation=TASK_LINEAGE_SUPERSESSION_OPERATION,
|
||||
request=payload,
|
||||
issuer=issue,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--role", choices=("owner", "machine"), required=True)
|
||||
parser.add_argument("--policy", default=str(DEFAULT_POLICY))
|
||||
parser.add_argument("--registry", default=str(DEFAULT_REGISTRY_PATH))
|
||||
parser.add_argument("--owner-key", default=str(DEFAULT_OWNER_KEY))
|
||||
parser.add_argument("--machine-key", default=str(DEFAULT_MACHINE_KEY))
|
||||
parser.add_argument("--socket")
|
||||
parser.add_argument("--ledger", default=str(DEFAULT_LEDGER))
|
||||
arguments = parser.parse_args()
|
||||
service = OperationalAuthorityService(
|
||||
role=arguments.role,
|
||||
policy_path=Path(arguments.policy),
|
||||
registry_path=Path(arguments.registry),
|
||||
owner_key_path=Path(arguments.owner_key),
|
||||
machine_key_path=Path(arguments.machine_key),
|
||||
socket_path=Path(arguments.socket) if arguments.socket else None,
|
||||
ledger_path=Path(arguments.ledger),
|
||||
)
|
||||
service.serve_forever()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,798 @@
|
||||
#!/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,6 +31,8 @@ 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}$")
|
||||
@@ -73,6 +75,24 @@ 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")
|
||||
@@ -107,6 +127,8 @@ 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()
|
||||
|
||||
@@ -126,9 +148,12 @@ 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")
|
||||
|
||||
@@ -282,6 +307,12 @@ 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}")
|
||||
@@ -498,7 +529,14 @@ 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)
|
||||
@@ -591,17 +629,18 @@ 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", 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("--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("--repository-identity", default="aios/aios")
|
||||
p.add_argument("--tag-adapter", required=True, nargs="+")
|
||||
p.add_argument("--deploy-adapter", required=True, nargs="+")
|
||||
@@ -612,18 +651,13 @@ def parser() -> argparse.ArgumentParser:
|
||||
|
||||
def main() -> int:
|
||||
args = parser().parse_args()
|
||||
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,
|
||||
)
|
||||
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)
|
||||
try:
|
||||
result = execute(
|
||||
binding=binding,
|
||||
@@ -634,6 +668,7 @@ 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
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
@@ -306,14 +307,76 @@ def authoritative_diff(
|
||||
from_oid = validate_git_revision(from_revision, "from", git_dir=path)
|
||||
to_oid = validate_git_revision(to_revision, "to", git_dir=path)
|
||||
args = ["diff", "--no-ext-diff", "--no-textconv", from_oid, to_oid]
|
||||
result = run_authoritative_git(args, git_dir=path)
|
||||
patch_limit = max(1, min(_MAX_OUTPUT, 256 * 1024))
|
||||
process = subprocess.Popen(
|
||||
["git", "--git-dir", str(path), *args],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
|
||||
)
|
||||
chunks: list[bytes] = []
|
||||
stored_bytes = 0
|
||||
total_bytes = 0
|
||||
started = time.monotonic()
|
||||
try:
|
||||
while process.stdout is not None:
|
||||
chunk = process.stdout.read(64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total_bytes += len(chunk)
|
||||
if stored_bytes < patch_limit:
|
||||
remaining = patch_limit - stored_bytes
|
||||
selected = chunk[:remaining]
|
||||
chunks.append(selected)
|
||||
stored_bytes += len(selected)
|
||||
if time.monotonic() - started > _TIMEOUT:
|
||||
process.kill()
|
||||
raise AuthoritativeGitError("GIT_REF_QUERY_TIMEOUT")
|
||||
stderr = (
|
||||
process.stderr.read(8192).decode("utf-8", errors="replace")
|
||||
if process.stderr is not None
|
||||
else ""
|
||||
)
|
||||
returncode = process.wait(timeout=max(0.1, _TIMEOUT - (time.monotonic() - started)))
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
process.kill()
|
||||
process.wait()
|
||||
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED") from exc
|
||||
if returncode != 0:
|
||||
raise AuthoritativeGitError(_redact(stderr.strip() or "GIT_REF_QUERY_FAILED")[:200])
|
||||
try:
|
||||
names_result = run_authoritative_git(
|
||||
["diff", "--no-ext-diff", "--no-textconv", "--name-status", from_oid, to_oid],
|
||||
git_dir=path,
|
||||
)
|
||||
changed_paths = [
|
||||
line.split("\t", 1)[-1]
|
||||
for line in names_result.stdout.splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
except AuthoritativeGitOutputTooLarge:
|
||||
changed_paths = []
|
||||
try:
|
||||
stats_result = run_authoritative_git(
|
||||
["diff", "--no-ext-diff", "--no-textconv", "--stat", from_oid, to_oid],
|
||||
git_dir=path,
|
||||
)
|
||||
stats = stats_result.stdout.strip()
|
||||
except AuthoritativeGitOutputTooLarge:
|
||||
stats = "GIT_STAT_OUTPUT_TRUNCATED"
|
||||
patch = b"".join(chunks).decode("utf-8", errors="replace")
|
||||
return {
|
||||
"code": 0,
|
||||
"stdout": result.stdout,
|
||||
"stdout": patch,
|
||||
"stderr": "",
|
||||
"cmd": ["git", f"--git-dir={path}", *args],
|
||||
"from_oid": from_oid,
|
||||
"to_oid": to_oid,
|
||||
"changed_paths": changed_paths,
|
||||
"stats": stats,
|
||||
"total_size_bytes": total_bytes,
|
||||
"truncated": total_bytes > patch_limit,
|
||||
"patch_limit_bytes": patch_limit,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -995,7 +995,7 @@ def validate_writer_lease_request_v2(raw: Mapping[str, Any], probe: Any) -> dict
|
||||
"target_branch": request.target_branch,
|
||||
"generation": request.generation,
|
||||
"lease_seconds": request.lease_seconds,
|
||||
"create_target_branch": False,
|
||||
"worktree_mode": "detached_at_expected_head",
|
||||
"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,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Single canonical Production Baseline authority for the GitOps reader.
|
||||
|
||||
The API and the one-shot writer import this module. Workspace and release
|
||||
copies are projections only; they are never selected as a fallback source.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
AUTHORITY_PATH = Path(os.getenv(
|
||||
"AIOS_GITOPS_BASELINE_AUTHORITY",
|
||||
"/var/lib/aios-gitops-runtime-anchor/baseline/current.json",
|
||||
))
|
||||
AUTHORITY_SHA_PATH = AUTHORITY_PATH.with_suffix(".sha256")
|
||||
|
||||
|
||||
class BaselineAuthorityError(RuntimeError):
|
||||
"""Fail-closed error raised for an unavailable or inconsistent authority."""
|
||||
|
||||
|
||||
class BaselineAuthority:
|
||||
def __init__(self, path: Path | str = AUTHORITY_PATH):
|
||||
self.path = Path(path)
|
||||
self.sha_path = self.path.with_suffix(".sha256")
|
||||
|
||||
def _raw(self) -> bytes:
|
||||
try:
|
||||
raw = self.path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise BaselineAuthorityError(f"authority_unavailable:{exc}") from exc
|
||||
if not raw:
|
||||
raise BaselineAuthorityError("authority_empty")
|
||||
return raw
|
||||
|
||||
def read(self) -> dict[str, Any]:
|
||||
raw = self._raw()
|
||||
digest = hashlib.sha256(raw).hexdigest()
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise BaselineAuthorityError("authority_invalid_json") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise BaselineAuthorityError("authority_not_object")
|
||||
expected = str(value.get("authority_sha256", ""))
|
||||
if expected and expected != digest:
|
||||
raise BaselineAuthorityError("authority_embedded_digest_mismatch")
|
||||
if self.sha_path.exists():
|
||||
sidecar = self.sha_path.read_text(encoding="utf-8").strip().split()[0]
|
||||
if sidecar != digest:
|
||||
raise BaselineAuthorityError("authority_sidecar_digest_mismatch")
|
||||
required = {
|
||||
"current_baseline", "previous_baseline", "target_oid", "tag_oid",
|
||||
"evidence_closure_oid", "approval_plan_id", "status", "generation",
|
||||
"source_identity",
|
||||
}
|
||||
missing = sorted(required.difference(value))
|
||||
if missing:
|
||||
raise BaselineAuthorityError("authority_missing:" + ",".join(missing))
|
||||
value = dict(value)
|
||||
value["authority_sha256"] = digest
|
||||
value["authority_path"] = str(self.path)
|
||||
return value
|
||||
|
||||
def read_consistent(self, policy: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
value = self.read()
|
||||
if policy:
|
||||
expected = policy.get("release_binding", policy)
|
||||
for key, field in (("version", "current_baseline"),
|
||||
("previous_version", "previous_baseline"),
|
||||
("target_oid", "target_oid"),
|
||||
("tag_oid", "tag_oid"),
|
||||
("evidence_closure_oid", "evidence_closure_oid"),
|
||||
("approval_plan_id", "approval_plan_id")):
|
||||
wanted = expected.get(key)
|
||||
if wanted is not None and value.get(field) != wanted:
|
||||
raise BaselineAuthorityError(f"authority_binding_mismatch:{field}")
|
||||
return value
|
||||
|
||||
@@ -12,6 +12,7 @@ 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):
|
||||
@@ -61,6 +62,7 @@ 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",
|
||||
@@ -98,8 +100,27 @@ 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")
|
||||
return {
|
||||
result = {
|
||||
"schema": "aios.campaign-release-acceptance.v1",
|
||||
"accepted": True,
|
||||
"binding_replaced": False,
|
||||
@@ -108,6 +129,22 @@ 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:
|
||||
@@ -120,6 +157,7 @@ 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()
|
||||
|
||||
@@ -136,6 +174,7 @@ 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))
|
||||
|
||||
@@ -8,6 +8,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
@@ -23,10 +24,15 @@ try:
|
||||
from authoritative_git import AuthoritativeGitError, run_authoritative_git
|
||||
except ModuleNotFoundError:
|
||||
from .authoritative_git import AuthoritativeGitError, run_authoritative_git
|
||||
try:
|
||||
from release_version import RELEASE_VERSION_RE, validate_release_version
|
||||
except ModuleNotFoundError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from release_version import RELEASE_VERSION_RE, validate_release_version
|
||||
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
VERSION_RE = re.compile(r"^V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$")
|
||||
VERSION_RE = RELEASE_VERSION_RE
|
||||
DEPLOYMENT_EVIDENCE_RE = re.compile(r"^(?:sha256:[0-9a-f]{64}|git:[0-9a-f]{40})$")
|
||||
FINGERPRINT_FIELDS = (
|
||||
"version",
|
||||
@@ -83,9 +89,14 @@ class PublicationManualOwnerReviewRequired(
|
||||
|
||||
|
||||
def validate_version(version: str) -> str:
|
||||
value = str(version or "")
|
||||
if not VERSION_RE.fullmatch(value):
|
||||
raise ValueError("invalid release version")
|
||||
return validate_release_version(version)
|
||||
|
||||
|
||||
def validate_gitea_api_url(api_url: str) -> str:
|
||||
value = str(api_url or "").strip().rstrip("/")
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise ValueError("Gitea API URL must be HTTP(S)")
|
||||
return value
|
||||
|
||||
|
||||
@@ -411,13 +422,11 @@ class GiteaTagClient:
|
||||
timeout: float = 15,
|
||||
token_line: int = 1,
|
||||
):
|
||||
self.api_url = api_url.rstrip("/")
|
||||
self.api_url = validate_gitea_api_url(api_url)
|
||||
self.repository = repository.strip("/")
|
||||
self.token_file = token_file
|
||||
self.timeout = timeout
|
||||
self.token_line = token_line
|
||||
if not self.api_url.startswith(("https://", "http://")):
|
||||
raise ValueError("Gitea API URL must be HTTP(S)")
|
||||
if self.repository.count("/") != 1:
|
||||
raise ValueError("Gitea repository must be owner/name")
|
||||
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
"""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.
|
||||
|
||||
This module is deliberately scoped to the Owner-approved V2R2C1 binding. It
|
||||
uses the existing TagPublisher and metadata adapters; it does not introduce a
|
||||
second Git writer, a second durable job store, or an alternate baseline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
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,
|
||||
PublicationManualOwnerReviewRequired,
|
||||
PublicationPending,
|
||||
PublicationReconciliationRequired,
|
||||
PublicationRequest,
|
||||
TagPublisher,
|
||||
)
|
||||
|
||||
|
||||
VERSION = "V2R2C1"
|
||||
TARGET_OID = "998d30304787e31e5ae607c0bd86b5edb2b0e5ad"
|
||||
EVIDENCE_CLOSURE_OID = "a8b906d1bc24088c19647e98de6a679268f6d0a2"
|
||||
APPROVAL_PLAN_ID = "sha256:c0df52008ae7f64cec5ee2d28947aa7b892066c670ab8295e4549140b1a7500e"
|
||||
REPOSITORY = "aios/aios"
|
||||
SERVICE = "aios-v2r1c7-production.service"
|
||||
PORT = 18927
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class ReleaseBlocked(RuntimeError):
|
||||
"""A release gate failed and no unsafe recovery is permitted."""
|
||||
|
||||
|
||||
class ManualOwnerReviewRequired(ReleaseBlocked):
|
||||
pass
|
||||
|
||||
|
||||
class ReconciliationRequired(ReleaseBlocked):
|
||||
pass
|
||||
|
||||
|
||||
def canonical(value: object) -> bytes:
|
||||
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ReleaseBlocked("artifact must be a 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 atomic_json(path: Path, value: Mapping[str, Any], mode: int = 0o600) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(path.name + ".tmp")
|
||||
temporary.write_bytes(canonical(dict(value)))
|
||||
os.chmod(temporary, mode)
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseBinding:
|
||||
version: str = VERSION
|
||||
target_oid: str = TARGET_OID
|
||||
evidence_closure_oid: str = EVIDENCE_CLOSURE_OID
|
||||
approval_plan_id: str = APPROVAL_PLAN_ID
|
||||
repository: str = REPOSITORY
|
||||
production_service: str = SERVICE
|
||||
production_port: int = PORT
|
||||
|
||||
def validate(self) -> None:
|
||||
if self.version != VERSION or self.repository != REPOSITORY:
|
||||
raise ManualOwnerReviewRequired("release binding is outside V2R2C1 scope")
|
||||
if not OID_RE.fullmatch(self.target_oid) or self.target_oid != TARGET_OID:
|
||||
raise ManualOwnerReviewRequired("V2R2C1 target_oid mismatch")
|
||||
if not OID_RE.fullmatch(self.evidence_closure_oid) or self.evidence_closure_oid != EVIDENCE_CLOSURE_OID:
|
||||
raise ManualOwnerReviewRequired("V2R2C1 evidence closure mismatch")
|
||||
if self.approval_plan_id != APPROVAL_PLAN_ID:
|
||||
raise ManualOwnerReviewRequired("V2R2C1 approval plan mismatch")
|
||||
if self.production_service != SERVICE or int(self.production_port) != PORT:
|
||||
raise ManualOwnerReviewRequired("production service binding mismatch")
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"version": self.version,
|
||||
"target_oid": self.target_oid,
|
||||
"evidence_closure_oid": self.evidence_closure_oid,
|
||||
"approval_plan_id": self.approval_plan_id,
|
||||
"repository": self.repository,
|
||||
"production_service": self.production_service,
|
||||
"production_port": self.production_port,
|
||||
}
|
||||
|
||||
|
||||
class StateLock:
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
self.handle = None
|
||||
|
||||
def __enter__(self) -> "StateLock":
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.handle = self.path.open("a+", encoding="utf-8")
|
||||
fcntl.flock(self.handle.fileno(), fcntl.LOCK_EX)
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
if self.handle is not None:
|
||||
fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
|
||||
self.handle.close()
|
||||
|
||||
|
||||
class ReleaseState:
|
||||
def __init__(self, root: Path, binding: ReleaseBinding):
|
||||
self.root = root
|
||||
self.path = root / "release-state.json"
|
||||
self.binding = binding
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
if self.path.exists():
|
||||
value = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
if value.get("binding") != binding.as_dict():
|
||||
raise ManualOwnerReviewRequired("persisted release binding conflict")
|
||||
self.value = value
|
||||
else:
|
||||
self.value = {
|
||||
"schema": "aios.v2r2c1.governed-production-release-state.v1",
|
||||
"binding": binding.as_dict(),
|
||||
"state": "INITIAL",
|
||||
"completed": {},
|
||||
"history": [],
|
||||
}
|
||||
self.save()
|
||||
|
||||
def save(self) -> None:
|
||||
atomic_json(self.path, self.value)
|
||||
|
||||
def record(self, state: str, step: str, result: Mapping[str, Any]) -> None:
|
||||
safe = json.loads(json.dumps(dict(result)))
|
||||
self.value["state"] = state
|
||||
self.value.setdefault("completed", {})[step] = safe
|
||||
self.value.setdefault("history", []).append(
|
||||
{"at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "state": state, "step": step, "result": safe}
|
||||
)
|
||||
self.save()
|
||||
|
||||
def fail(self, state: str, reason: str) -> None:
|
||||
self.value["state"] = state
|
||||
self.value.setdefault("history", []).append(
|
||||
{"at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "state": state, "reason": str(reason)[:500]}
|
||||
)
|
||||
self.save()
|
||||
|
||||
|
||||
def _git(git_dir: Path, *args: str, check: bool = True) -> str:
|
||||
completed = subprocess.run(
|
||||
["git", f"--git-dir={git_dir}", *args],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=45,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
if check and completed.returncode != 0:
|
||||
raise ReconciliationRequired(completed.stderr.strip() or "authoritative git query failed")
|
||||
return completed.stdout.strip()
|
||||
|
||||
|
||||
def _ancestor(git_dir: Path, ancestor: str, descendant: str) -> bool:
|
||||
return subprocess.run(
|
||||
["git", f"--git-dir={git_dir}", "merge-base", "--is-ancestor", ancestor, descendant],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=45,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
).returncode == 0
|
||||
|
||||
|
||||
def verify_lineage(git_dir: Path, binding: ReleaseBinding) -> dict[str, Any]:
|
||||
binding.validate()
|
||||
if _git(git_dir, "cat-file", "-t", binding.target_oid) != "commit":
|
||||
raise ReleaseBlocked("approved target_oid is not a commit")
|
||||
if _git(git_dir, "cat-file", "-t", binding.evidence_closure_oid) != "commit":
|
||||
raise ReleaseBlocked("evidence closure is not a commit")
|
||||
if not _ancestor(git_dir, binding.target_oid, binding.evidence_closure_oid):
|
||||
raise ManualOwnerReviewRequired("approved target is not an ancestor of evidence closure")
|
||||
return {
|
||||
"target_oid": binding.target_oid,
|
||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||
"target_is_ancestor_of_evidence": True,
|
||||
"main_oid": _git(git_dir, "rev-parse", "refs/heads/main"),
|
||||
}
|
||||
|
||||
|
||||
def verify_owner_approval(value: Mapping[str, Any], binding: ReleaseBinding) -> dict[str, Any]:
|
||||
approval = value.get("owner_approval")
|
||||
if not isinstance(approval, Mapping):
|
||||
raise ReleaseBlocked("OWNER_APPROVAL_RECEIPT_REQUIRED")
|
||||
if approval.get("approved") is not True or approval.get("status") != "APPROVED":
|
||||
raise ReleaseBlocked("OWNER_APPROVAL_NOT_ACCEPTED")
|
||||
if approval.get("approved_by") != "aiman":
|
||||
raise ManualOwnerReviewRequired("OWNER_APPROVAL_ACTOR_MISMATCH")
|
||||
if approval.get("approval_source") != "explicit_owner_approval_in_chat":
|
||||
raise ManualOwnerReviewRequired("OWNER_APPROVAL_SOURCE_MISMATCH")
|
||||
for key, expected in binding.as_dict().items():
|
||||
if key in {"repository", "production_service", "production_port"}:
|
||||
continue
|
||||
if approval.get(key) != expected:
|
||||
raise ManualOwnerReviewRequired("OWNER_APPROVAL_BINDING_MISMATCH:" + key)
|
||||
return dict(approval)
|
||||
|
||||
|
||||
def _payload_file(git_dir: Path, oid: str, path: str) -> bytes:
|
||||
completed = subprocess.run(
|
||||
["git", f"--git-dir={git_dir}", "show", f"{oid}:{path}"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=45,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise ReleaseBlocked("required release source missing:" + path)
|
||||
return completed.stdout
|
||||
|
||||
|
||||
def _commit_epoch(git_dir: Path, oid: str) -> int:
|
||||
return int(_git(git_dir, "show", "-s", "--format=%ct", oid))
|
||||
|
||||
|
||||
def build_production_artifact(
|
||||
git_dir: Path,
|
||||
binding: ReleaseBinding,
|
||||
output_root: Path,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a deterministic executable V2R2C1 health artifact from target_oid."""
|
||||
binding.validate()
|
||||
verify_lineage(git_dir, binding)
|
||||
epoch = _commit_epoch(git_dir, binding.target_oid)
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"aios-{VERSION}-{binding.target_oid[:12]}"
|
||||
output = output_root / (stem + ".tar.zst")
|
||||
with tempfile.TemporaryDirectory(prefix="v2r2c1-artifact-") as temp:
|
||||
root = Path(temp) / "payload"
|
||||
root.mkdir(parents=True)
|
||||
source_paths = {
|
||||
"active-runtime/app/deployment_state.py": "active-runtime/app/deployment_state.py",
|
||||
"active-runtime/app/v2r1c6_privacy_tools.py": "active-runtime/app/v2r1c6_privacy_tools.py",
|
||||
}
|
||||
for destination, source in source_paths.items():
|
||||
target = root / destination
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(_payload_file(git_dir, binding.target_oid, source))
|
||||
generated: dict[str, str] = {
|
||||
"VERSION": VERSION + "\n",
|
||||
"active-runtime/app/v2r2c1_health_service.py": (
|
||||
"from v2r1c6_privacy_tools import main\n\n"
|
||||
"if __name__ == '__main__':\n raise SystemExit(main())\n"
|
||||
),
|
||||
"deploy/aios-v2/systemd/aios-v2r1c7-production.service": (
|
||||
"[Unit]\nDescription=AIOS V2R2C1 governed health contract service\n"
|
||||
"After=network.target\n\n[Service]\nType=simple\n"
|
||||
"User=aios-v2r1c7\nGroup=aios-v2r1c7\n"
|
||||
"WorkingDirectory=/opt/aios/v2r2c1\n"
|
||||
"EnvironmentFile=/etc/aios/aios-v2r2c1.env\n"
|
||||
"ExecStart=/usr/bin/python3 /opt/aios/v2r2c1/active-runtime/app/v2r2c1_health_service.py --serve --host 127.0.0.1 --port ${AIOS_V2R1C7_PORT}\n"
|
||||
"Restart=on-failure\nRestartSec=5\nNoNewPrivileges=true\nPrivateTmp=true\n"
|
||||
"ProtectHome=true\nProtectSystem=strict\nProtectKernelLogs=true\n"
|
||||
"ProtectKernelModules=true\nProtectKernelTunables=true\nProtectControlGroups=true\n"
|
||||
"RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6\nIPAddressDeny=any\n"
|
||||
"IPAddressAllow=localhost\nUMask=0077\n\n[Install]\nWantedBy=multi-user.target\n"
|
||||
),
|
||||
"deploy/aios-v2/v2r2c1-runtime-config.json": json.dumps({
|
||||
"schema": "aios.v2r2c1.production-runtime.v1",
|
||||
"version": VERSION,
|
||||
"target_oid": binding.target_oid,
|
||||
"service": SERVICE,
|
||||
"port": PORT,
|
||||
"health_path": "/health",
|
||||
"deployment_state_source": "environment_and_state_file",
|
||||
}, sort_keys=True, separators=(",", ":")) + "\n",
|
||||
"deploy/aios-v2/v2r2c1-rollback.json": json.dumps({
|
||||
"schema": "aios.v2r2c1.rollback-metadata.v1",
|
||||
"rollback_version": "V2R1C7",
|
||||
"rollback_service": SERVICE,
|
||||
"rollback_target_is_previous_production": True,
|
||||
"automatic_rollback": False,
|
||||
}, sort_keys=True, separators=(",", ":")) + "\n",
|
||||
"scripts/probe_v2r2c1_health.py": (
|
||||
"import json,sys,urllib.request\n"
|
||||
"url=sys.argv[1] if len(sys.argv)>1 else 'http://127.0.0.1:18927/health'\n"
|
||||
"with urllib.request.urlopen(url, timeout=5) as r: print(json.dumps(json.load(r), sort_keys=True))\n"
|
||||
),
|
||||
"scripts/rollback_v2r2c1_release.sh": "#!/bin/sh\nset -eu\nprintf '%s\\n' 'rollback requires governed owner-approved executor'\nexit 2\n",
|
||||
"release-manifests/V2R2C1.json": json.dumps({
|
||||
"schema": "aios.release-source.v2",
|
||||
"version": VERSION,
|
||||
"repository": REPOSITORY,
|
||||
"target_oid": binding.target_oid,
|
||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||
"production_deployed": False,
|
||||
"deployment_state": "STAGING",
|
||||
"artifact_kind": "executable-production-health",
|
||||
}, sort_keys=True, separators=(",", ":")) + "\n",
|
||||
}
|
||||
for rel, text in generated.items():
|
||||
target = root / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(text, encoding="utf-8")
|
||||
if rel.startswith("scripts/"):
|
||||
target.chmod(0o755)
|
||||
metadata = {
|
||||
"schema": "aios.release-artifact-build.v3",
|
||||
"version": VERSION,
|
||||
"repository": REPOSITORY,
|
||||
"source_head_oid": binding.target_oid,
|
||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||
"source_date_epoch": epoch,
|
||||
"clean_source": True,
|
||||
"executable_payload": True,
|
||||
"production_deployed": False,
|
||||
"tag_created": False,
|
||||
"baseline_changed": False,
|
||||
"secrets_included": False,
|
||||
}
|
||||
(root / "BUILD-METADATA.json").write_bytes(canonical(metadata))
|
||||
(root / "ROLLBACK-METADATA.json").write_bytes(canonical(json.loads((root / "deploy/aios-v2/v2r2c1-rollback.json").read_text(encoding="utf-8"))))
|
||||
names = sorted("payload/" + str(path.relative_to(root)).replace(os.sep, "/") for path in root.rglob("*") if path.is_file())
|
||||
(root / "ARTIFACT-MANIFEST.json").write_bytes(canonical({
|
||||
"schema": "aios.release-artifact-manifest.v3",
|
||||
"version": VERSION,
|
||||
"source_head_oid": binding.target_oid,
|
||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||
"repository": REPOSITORY,
|
||||
"files": names + ["payload/ARTIFACT-MANIFEST.json"],
|
||||
"deployable": True,
|
||||
"executable_payload": True,
|
||||
"production_deployed": False,
|
||||
"tag_created": False,
|
||||
"baseline_changed": False,
|
||||
}))
|
||||
tar_path = Path(temp) / "release.tar"
|
||||
with tarfile.open(tar_path, "w") as archive:
|
||||
for path in sorted(root.rglob("*")):
|
||||
relative = str(path.relative_to(root)).replace(os.sep, "/")
|
||||
info = archive.gettarinfo(str(path), arcname="payload/" + relative)
|
||||
info.uid = info.gid = 0
|
||||
info.uname = "root"
|
||||
info.gname = "root"
|
||||
info.mtime = epoch
|
||||
info.mode = 0o755 if path.is_dir() or path.suffix == ".sh" else 0o644
|
||||
if path.is_file():
|
||||
with path.open("rb") as stream:
|
||||
archive.addfile(info, stream)
|
||||
else:
|
||||
archive.addfile(info)
|
||||
subprocess.run(["zstd", "-T1", "-19", "--no-progress", "-q", "-f", str(tar_path), "-o", str(output)], check=True, timeout=120)
|
||||
digest = sha256_file(output)
|
||||
receipt = {
|
||||
"schema": "aios.release-artifact-receipt.v3",
|
||||
"version": VERSION,
|
||||
"repository": REPOSITORY,
|
||||
"source_head_oid": binding.target_oid,
|
||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||
"artifact": str(output),
|
||||
"artifact_sha256": digest,
|
||||
"artifact_size": output.stat().st_size,
|
||||
"deployable": True,
|
||||
"executable_payload": True,
|
||||
"production_deployed": False,
|
||||
"tag_created": False,
|
||||
"baseline_changed": False,
|
||||
}
|
||||
atomic_json(output.with_suffix(output.suffix + ".receipt.json"), receipt, mode=0o640)
|
||||
return receipt
|
||||
|
||||
|
||||
class GovernedProductionReleaseExecutor:
|
||||
def __init__(self, config: Any):
|
||||
self.config = config
|
||||
self.binding = ReleaseBinding()
|
||||
self.binding.validate()
|
||||
self.root = Path(config.state_root) / "v2r2c1-governed-production-release"
|
||||
self.state = ReleaseState(self.root, self.binding)
|
||||
workspace = self.root / "publication-workspace"
|
||||
workspace.mkdir(parents=True, exist_ok=True)
|
||||
self.publisher = TagPublisher(
|
||||
workspace,
|
||||
GitTagBackend(workspace, authoritative_repo=Path(config.authoritative_git_dir)),
|
||||
GiteaTagClient(config.api_base, REPOSITORY, Path(config.token_path)),
|
||||
config.remote_name,
|
||||
state_root=self.root / "tag-publication",
|
||||
)
|
||||
# Release checks must use the same explicitly configured staging baseline
|
||||
# as the Action/model runtime; do not fall back to the publication workspace.
|
||||
self.publisher.baseline_path = Path(
|
||||
os.environ.get("AIOS_GITOPS_BASELINE_AUTHORITY", str(config.baseline_path))
|
||||
)
|
||||
# The immutable runtime package is the source of the version projection;
|
||||
# the mutable state workspace only stores publication state.
|
||||
self.publisher.versions_root = Path(__file__).resolve().parents[1] / "versions"
|
||||
|
||||
def _request(self, request: Mapping[str, Any]) -> PublicationRequest:
|
||||
binding = self.binding
|
||||
for key, expected in binding.as_dict().items():
|
||||
if key in request and request[key] != expected:
|
||||
raise ManualOwnerReviewRequired("release request binding mismatch:" + key)
|
||||
return PublicationRequest(binding.version, binding.target_oid, binding.evidence_closure_oid, binding.approval_plan_id)
|
||||
|
||||
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")
|
||||
owner = verify_owner_approval(request, self.binding)
|
||||
with StateLock(self.root / "release.lock"):
|
||||
current = self.state.value.get("approval")
|
||||
approval_digest = hashlib.sha256(canonical(owner)).hexdigest()
|
||||
record = {
|
||||
"schema": "aios.v2r2c1.tag-approval-request.v1",
|
||||
"binding": self.binding.as_dict(),
|
||||
"owner_approval": owner,
|
||||
"owner_approval_digest_sha256": approval_digest,
|
||||
"execute": False,
|
||||
"recorded_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
}
|
||||
if current and current != record:
|
||||
raise ManualOwnerReviewRequired("persisted Owner approval differs")
|
||||
self.state.value["approval"] = record
|
||||
self.state.record("APPROVAL_REQUEST_RECORDED", "tag_approval_request", record)
|
||||
return {"status": "RECORDED", "execute": False, "binding": self.binding.as_dict(), "owner_approval_digest_sha256": approval_digest}
|
||||
|
||||
def _require_approval(self) -> None:
|
||||
approval = self.state.value.get("approval")
|
||||
if not isinstance(approval, Mapping) or approval.get("binding") != self.binding.as_dict():
|
||||
raise ReleaseBlocked("OWNER_APPROVAL_REQUEST_NOT_RECORDED")
|
||||
|
||||
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")
|
||||
self._require_approval()
|
||||
publication = self._request(request)
|
||||
with StateLock(self.root / "release.lock"):
|
||||
try:
|
||||
verified = self.publisher._verify_publication(publication, allow_push=True, previously_deployed=False)
|
||||
except PublicationManualOwnerReviewRequired as exc:
|
||||
self.state.fail("MANUAL_OWNER_REVIEW_REQUIRED", str(exc))
|
||||
raise ManualOwnerReviewRequired(str(exc)) from exc
|
||||
except PublicationPending as exc:
|
||||
self.state.fail("REMOTE_PUSH_PENDING", str(exc))
|
||||
raise ReconciliationRequired(str(exc)) from exc
|
||||
except PublicationReconciliationRequired as exc:
|
||||
self.state.fail("RECONCILIATION_REQUIRED", str(exc))
|
||||
raise ReconciliationRequired(str(exc)) from exc
|
||||
result = dict(verified)
|
||||
self.state.record("TAG_VERIFIED", "tag", result)
|
||||
return {"status": "REMOTE_VERIFIED", **result}
|
||||
|
||||
def tag_status(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
self._request(request)
|
||||
return self.publisher.get_tag_publication_status(VERSION)
|
||||
|
||||
def build_artifact(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
self._require_approval()
|
||||
self._request(request)
|
||||
receipt = build_production_artifact(Path(self.config.authoritative_git_dir), self.binding, self.root / "artifacts")
|
||||
self.state.record("ARTIFACT_VERIFIED", "production_artifact", receipt)
|
||||
return {"status": "VERIFIED", **receipt}
|
||||
|
||||
def execute_request(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
self._require_approval()
|
||||
publication = self._request(request)
|
||||
tag_state = self.state.value.get("completed", {}).get("tag")
|
||||
if not isinstance(tag_state, Mapping) or tag_state.get("state") not in {"GITEA_API_CONFIRMED", "REMOTE_VERIFIED"}:
|
||||
raise ReleaseBlocked("REMOTE_TAG_PROOF_REQUIRED")
|
||||
artifact = self.state.value.get("completed", {}).get("production_artifact")
|
||||
if not isinstance(artifact, Mapping):
|
||||
artifact = self.build_artifact(request)
|
||||
value = {
|
||||
**self.binding.as_dict(),
|
||||
"tag_oid": tag_state.get("tag_oid") or tag_state.get("gitea_tag_oid"),
|
||||
"artifact_path": artifact["artifact"],
|
||||
"artifact_size": int(artifact["artifact_size"]),
|
||||
"artifact_sha256": artifact["artifact_sha256"],
|
||||
}
|
||||
# The production adapter is privileged and is run through the root
|
||||
# approved-release service boundary by the caller. This handler is
|
||||
# still useful for a root-owned staging invocation and remains
|
||||
# fail-closed when the adapter is not available.
|
||||
import v2r2c1_production_adapter
|
||||
runtime = v2r2c1_production_adapter.execute_request({"schema": "aios.production-executor-request.v1", "operation": "apply", **value})
|
||||
if runtime.get("status") != "ACTIVE_VERIFIED":
|
||||
self.state.fail("RECONCILIATION_REQUIRED", "production health did not verify")
|
||||
raise ReconciliationRequired("production health did not verify")
|
||||
self.state.record("PRODUCTION_VERIFIED", "production", runtime)
|
||||
return {"status": "PRODUCTION_VERIFIED", "runtime": runtime, "artifact": artifact, "tag_oid": value["tag_oid"]}
|
||||
|
||||
def consistency(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
publication = self._request(request)
|
||||
result = self.publisher.get_release_consistency(publication)
|
||||
result["release_binding"] = self.binding.as_dict()
|
||||
result["production_health"] = _health(self.config.health_url, self.binding, self.state.value.get("completed", {}).get("production_artifact", {}))
|
||||
result["registry"] = _read_json(Path(self.config.registry_root) / f"{VERSION}.json")
|
||||
result["baseline"] = _read_json(Path(self.config.baseline_path))
|
||||
return result
|
||||
|
||||
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"
|
||||
self.state.record("ACCEPTED" if result["accepted"] else "RECONCILIATION_REQUIRED", "acceptance", result)
|
||||
return result
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return {"status": "MISSING", "path": str(path)}
|
||||
if not isinstance(value, dict):
|
||||
return {"status": "INVALID", "path": str(path)}
|
||||
return {"status": "PRESENT", "path": str(path), **value}
|
||||
|
||||
|
||||
def _health(url: str, binding: ReleaseBinding, artifact: Mapping[str, Any]) -> dict[str, Any]:
|
||||
import urllib.request
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=5) as response:
|
||||
body = json.loads(response.read().decode("utf-8"))
|
||||
status = response.status
|
||||
except Exception as exc:
|
||||
return {"status": "UNAVAILABLE", "error": type(exc).__name__}
|
||||
expected_artifact = artifact.get("artifact_sha256")
|
||||
exact = (
|
||||
status == 200
|
||||
and body.get("version") == binding.version
|
||||
and body.get("commit_oid") == binding.target_oid
|
||||
and (not expected_artifact or body.get("artifact_sha256") == expected_artifact)
|
||||
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}
|
||||
|
||||
|
||||
|
||||
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)
|
||||
return GovernedProductionReleaseExecutor(config)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("operation", choices=("build-artifact", "status"))
|
||||
parser.add_argument("--git-dir", type=Path, default=Path("/data/aios-gitops/repo.git"))
|
||||
parser.add_argument("--output-root", type=Path, default=Path("/var/lib/aios-gitops/approved-artifacts/V2R2C1"))
|
||||
args = parser.parse_args()
|
||||
binding = ReleaseBinding()
|
||||
if args.operation == "build-artifact":
|
||||
print(json.dumps(build_production_artifact(args.git_dir, binding, args.output_root), sort_keys=True))
|
||||
else:
|
||||
print(json.dumps({"binding": binding.as_dict(), "status": "READY"}, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -34,6 +34,8 @@ from gitea_tag_publication import (
|
||||
TagPublisher,
|
||||
)
|
||||
|
||||
from release_version import validate_release_version
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
|
||||
@@ -44,8 +46,10 @@ def load_request() -> dict[str, Any]:
|
||||
raise ValueError("request_object_required")
|
||||
if value.get("schema") != "aios.production-executor-request.v1":
|
||||
raise ValueError("request_schema_invalid")
|
||||
if not re.fullmatch(r"V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?", str(value.get("version", ""))):
|
||||
raise ValueError("version_invalid")
|
||||
try:
|
||||
validate_release_version(value.get("version", ""))
|
||||
except ValueError as exc:
|
||||
raise ValueError("version_invalid") from exc
|
||||
for name in ("target_oid", "evidence_closure_oid"):
|
||||
if not OID_RE.fullmatch(str(value.get(name, ""))):
|
||||
raise ValueError(f"{name}_invalid")
|
||||
|
||||
@@ -27,6 +27,8 @@ import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from release_version import validate_release_version
|
||||
|
||||
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}$")
|
||||
@@ -86,8 +88,10 @@ def load_request() -> dict[str, Any]:
|
||||
raise ValueError("request_schema_invalid")
|
||||
if value.get("repository_identity") != "aios/aios":
|
||||
raise ValueError("repository_identity_invalid")
|
||||
if not re.fullmatch(r"V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?", str(value.get("version", ""))):
|
||||
raise ValueError("version_invalid")
|
||||
try:
|
||||
validate_release_version(value.get("version", ""))
|
||||
except ValueError as exc:
|
||||
raise ValueError("version_invalid") from exc
|
||||
for name in ("target_oid", "evidence_closure_oid"):
|
||||
if not OID_RE.fullmatch(str(value.get(name, ""))):
|
||||
raise ValueError(f"{name}_invalid")
|
||||
|
||||
@@ -22,11 +22,12 @@ from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from release_version import validate_release_version
|
||||
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
CANDIDATE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{2,127}$")
|
||||
VERSION_RE = re.compile(r"^V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$")
|
||||
SECRET_PATTERNS = (
|
||||
re.compile(r"(?i)(authorization\s*[:=]\s*)(?:bearer|token)\s+\S+"),
|
||||
re.compile(r"(?i)((?:access_)?token\s*[:=]\s*)\S+"),
|
||||
@@ -769,8 +770,10 @@ class TagApprovalEnvelope:
|
||||
def validate(self) -> None:
|
||||
if self.action != "production_tag":
|
||||
raise ApprovalError("approval does not authorize production tag")
|
||||
if not VERSION_RE.fullmatch(self.version):
|
||||
raise ApprovalError("invalid release version")
|
||||
try:
|
||||
validate_release_version(self.version)
|
||||
except ValueError as exc:
|
||||
raise ApprovalError("invalid release version") from exc
|
||||
_validate_oid("tag_oid", self.tag_oid)
|
||||
_validate_oid("target_oid", self.target_oid)
|
||||
if _parse_utc(self.expires_at) <= datetime.now(timezone.utc):
|
||||
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -40,6 +41,8 @@ from silent_release_controller import (
|
||||
|
||||
IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{2,255}$")
|
||||
NONCE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{15,255}$")
|
||||
BUILDER_ATTESTATION_TRUST_DOMAIN = "aios.trusted-builder.attestation.v1"
|
||||
BUILDER_ATTESTATION_PURPOSE = "AIOS_TRUSTED_RELEASE_BUILDER"
|
||||
ALLOWED_CAPABILITIES = frozenset(
|
||||
{
|
||||
"runtime_switch",
|
||||
@@ -62,6 +65,10 @@ class CapabilityAuthorizationError(RuntimeError):
|
||||
"""The signed plan cannot authorize the requested operation."""
|
||||
|
||||
|
||||
class TrustedCandidateBuilderUnavailable(RuntimeError):
|
||||
"""The purpose-bound builder dependency cannot currently serve a build."""
|
||||
|
||||
|
||||
class StaleApprovalError(CapabilityAuthorizationError):
|
||||
"""Live release evidence no longer matches the signed approval."""
|
||||
|
||||
@@ -222,6 +229,10 @@ JOURNAL_GENESIS_HASH = "0" * 64
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
if os.name == "nt":
|
||||
# Windows does not permit opening a directory with os.open. Durable
|
||||
# Linux host execution retains the directory fsync below.
|
||||
return
|
||||
descriptor = os.open(Path(path), os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
@@ -715,6 +726,14 @@ class BuilderAttestation:
|
||||
canary_evidence_sha256: str
|
||||
issued_at: str
|
||||
signature: str
|
||||
trust_domain: str = BUILDER_ATTESTATION_TRUST_DOMAIN
|
||||
builder_key_generation: int | None = None
|
||||
signature_algorithm: str | None = None
|
||||
signature_purpose: str | None = None
|
||||
gitea_evidence_sha256: str = ""
|
||||
rollback_evidence_sha256: str = ""
|
||||
policy_evidence_sha256: str = ""
|
||||
secret_scan_evidence_sha256: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_identifier(
|
||||
@@ -731,7 +750,35 @@ class BuilderAttestation:
|
||||
"canary_evidence_sha256",
|
||||
):
|
||||
_validate_sha(name, getattr(self, name))
|
||||
for name in (
|
||||
"gitea_evidence_sha256",
|
||||
"rollback_evidence_sha256",
|
||||
"policy_evidence_sha256",
|
||||
"secret_scan_evidence_sha256",
|
||||
):
|
||||
if getattr(self, name):
|
||||
_validate_sha(name, getattr(self, name))
|
||||
_parse_utc(self.issued_at)
|
||||
if self.trust_domain != BUILDER_ATTESTATION_TRUST_DOMAIN:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation trust domain is invalid"
|
||||
)
|
||||
metadata = (
|
||||
self.builder_key_generation,
|
||||
self.signature_algorithm,
|
||||
self.signature_purpose,
|
||||
)
|
||||
if any(value is not None for value in metadata):
|
||||
if (
|
||||
not isinstance(self.builder_key_generation, int)
|
||||
or isinstance(self.builder_key_generation, bool)
|
||||
or self.builder_key_generation < 0
|
||||
or self.signature_algorithm != "Ed25519"
|
||||
or self.signature_purpose != BUILDER_ATTESTATION_PURPOSE
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation signing metadata is invalid"
|
||||
)
|
||||
|
||||
@property
|
||||
def canonical_payload(self) -> dict[str, Any]:
|
||||
@@ -754,20 +801,290 @@ class BuilderAttestation:
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: Mapping[str, Any]) -> "BuilderAttestation":
|
||||
def from_dict(
|
||||
cls,
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
require_fingerprint: bool = False,
|
||||
) -> "BuilderAttestation":
|
||||
values = dict(payload)
|
||||
values.pop("fingerprint", None)
|
||||
return cls(**values)
|
||||
supplied_fingerprint = values.pop("fingerprint", None)
|
||||
# Older local records did not spell out the domain. They are only
|
||||
# usable when re-signed by the current builder; their original
|
||||
# signatures cannot validate against this domain-separated payload.
|
||||
values.setdefault("trust_domain", BUILDER_ATTESTATION_TRUST_DOMAIN)
|
||||
value = cls(**values)
|
||||
if supplied_fingerprint is None:
|
||||
if require_fingerprint:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation digest is required"
|
||||
)
|
||||
return value
|
||||
expected = _validate_sha(
|
||||
"builder attestation digest", str(supplied_fingerprint)
|
||||
)
|
||||
if not hmac.compare_digest(expected, value.fingerprint):
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation digest is invalid"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BuilderAttestationKeyRecord:
|
||||
"""One purpose-bound public verification key from the host keyring."""
|
||||
|
||||
key_id: str
|
||||
public_key: bytes
|
||||
algorithm: str
|
||||
purpose: str
|
||||
generation: int
|
||||
not_before: str
|
||||
expires_at: str
|
||||
status: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_identifier("builder_key_id", self.key_id)
|
||||
if self.algorithm != "Ed25519":
|
||||
raise ValueError("unsupported builder signature algorithm")
|
||||
if self.purpose != BUILDER_ATTESTATION_PURPOSE:
|
||||
raise ValueError("builder key purpose is invalid")
|
||||
if not isinstance(self.generation, int) or isinstance(
|
||||
self.generation, bool
|
||||
) or self.generation < 0:
|
||||
raise ValueError("builder key generation is invalid")
|
||||
if self.status not in {"ACTIVE", "REVOKED"}:
|
||||
raise ValueError("builder key status is invalid")
|
||||
if len(self.public_key) != 32:
|
||||
raise ValueError("Ed25519 public key must contain 32 bytes")
|
||||
Ed25519PublicKey.from_public_bytes(self.public_key)
|
||||
if _parse_utc(self.not_before) >= _parse_utc(self.expires_at):
|
||||
raise ValueError("builder key validity window is invalid")
|
||||
|
||||
@property
|
||||
def public_key_sha256(self) -> str:
|
||||
return hashlib.sha256(self.public_key).hexdigest()
|
||||
|
||||
def validate_at(self, instant: datetime) -> None:
|
||||
if self.status != "ACTIVE":
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation key is unavailable or revoked"
|
||||
)
|
||||
if instant.tzinfo is None:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation verification time is invalid"
|
||||
)
|
||||
if not _parse_utc(self.not_before) <= instant <= _parse_utc(
|
||||
self.expires_at
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation key is outside its validity window"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls, payload: Mapping[str, Any]
|
||||
) -> "BuilderAttestationKeyRecord":
|
||||
values = dict(payload)
|
||||
encoded = values.pop("public_key", values.pop("public_key_b64", None))
|
||||
if not isinstance(encoded, str):
|
||||
raise ValueError("builder public key is missing")
|
||||
try:
|
||||
public_key = base64.b64decode(encoded, validate=True)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("builder public key encoding is invalid") from exc
|
||||
supplied_digest = values.pop(
|
||||
"public_key_sha256",
|
||||
values.pop("key_sha256", values.pop("digest", None)),
|
||||
)
|
||||
if not isinstance(supplied_digest, str):
|
||||
raise ValueError("builder public key digest is missing")
|
||||
digest = _validate_sha("builder public key digest", supplied_digest)
|
||||
if not hmac.compare_digest(
|
||||
digest, hashlib.sha256(public_key).hexdigest()
|
||||
):
|
||||
raise ValueError("builder public key digest is invalid")
|
||||
allowed = {
|
||||
"algorithm",
|
||||
"purpose",
|
||||
"key_id",
|
||||
"generation",
|
||||
"not_before",
|
||||
"expires_at",
|
||||
"status",
|
||||
}
|
||||
unknown = set(values).difference(allowed)
|
||||
if unknown:
|
||||
raise ValueError("builder key record contains unknown fields")
|
||||
return cls(public_key=public_key, **values)
|
||||
|
||||
|
||||
class BuilderAttestationKeyRing:
|
||||
"""Validated, public-only keyring for builder attestations.
|
||||
|
||||
The file format deliberately cannot carry Owner approval keys: both the
|
||||
keyring and every record must declare the builder-attestation purpose.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
records: Iterable[BuilderAttestationKeyRecord],
|
||||
*,
|
||||
require_attestation_metadata: bool = False,
|
||||
):
|
||||
self._records: dict[str, BuilderAttestationKeyRecord] = {}
|
||||
self._require_attestation_metadata = require_attestation_metadata
|
||||
for record in records:
|
||||
if record.key_id in self._records:
|
||||
raise ValueError("duplicate builder key id")
|
||||
self._records[record.key_id] = record
|
||||
|
||||
@classmethod
|
||||
def from_public_keys(
|
||||
cls, public_keys: Mapping[str, bytes]
|
||||
) -> "BuilderAttestationKeyRing":
|
||||
"""Compatibility adapter for explicit in-process builder key maps.
|
||||
|
||||
This is intentionally not used for host configuration; host keyrings
|
||||
must use :meth:`from_file` so purpose, digest, validity and status are
|
||||
checked before the API accepts traffic.
|
||||
"""
|
||||
return cls(
|
||||
BuilderAttestationKeyRecord(
|
||||
key_id=_validate_identifier("builder_key_id", key_id),
|
||||
public_key=bytes(public_key),
|
||||
algorithm="Ed25519",
|
||||
purpose=BUILDER_ATTESTATION_PURPOSE,
|
||||
generation=0,
|
||||
not_before="1970-01-01T00:00:00Z",
|
||||
expires_at="9999-12-31T23:59:59Z",
|
||||
status="ACTIVE",
|
||||
)
|
||||
for key_id, public_key in public_keys.items()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: Path) -> "BuilderAttestationKeyRing":
|
||||
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
if payload.get("algorithm") != "Ed25519":
|
||||
raise ValueError("unsupported builder keyring algorithm")
|
||||
if payload.get("purpose") != BUILDER_ATTESTATION_PURPOSE:
|
||||
raise ValueError("builder keyring purpose is invalid")
|
||||
raw_records = payload.get("keys")
|
||||
if not isinstance(raw_records, list) or not raw_records:
|
||||
raise ValueError("builder keyring keys are invalid")
|
||||
allowed = {"algorithm", "purpose", "keys", "keyring_sha256"}
|
||||
if set(payload).difference(allowed):
|
||||
raise ValueError("builder keyring contains unknown fields")
|
||||
if "keyring_sha256" not in payload:
|
||||
raise ValueError("builder keyring digest is missing")
|
||||
expected = _validate_sha(
|
||||
"builder keyring digest", str(payload["keyring_sha256"])
|
||||
)
|
||||
canonical = _canonical_json(
|
||||
{
|
||||
"algorithm": payload["algorithm"],
|
||||
"purpose": payload["purpose"],
|
||||
"keys": raw_records,
|
||||
}
|
||||
)
|
||||
if not hmac.compare_digest(
|
||||
expected, hashlib.sha256(canonical).hexdigest()
|
||||
):
|
||||
raise ValueError("builder keyring digest is invalid")
|
||||
if not all(isinstance(record, Mapping) for record in raw_records):
|
||||
raise ValueError("builder keyring record is invalid")
|
||||
return cls(
|
||||
(
|
||||
BuilderAttestationKeyRecord.from_dict(record)
|
||||
for record in raw_records
|
||||
),
|
||||
require_attestation_metadata=True,
|
||||
)
|
||||
|
||||
def verify(
|
||||
self,
|
||||
key_id: str,
|
||||
signature: bytes,
|
||||
message: bytes,
|
||||
*,
|
||||
at: datetime,
|
||||
generation: int | None,
|
||||
algorithm: str | None,
|
||||
purpose: str | None,
|
||||
) -> BuilderAttestationKeyRecord:
|
||||
record = self._records.get(key_id)
|
||||
if record is None:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation key is not trusted"
|
||||
)
|
||||
record.validate_at(at)
|
||||
metadata = (generation, algorithm, purpose)
|
||||
if self._require_attestation_metadata and any(
|
||||
value is None for value in metadata
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation signing metadata is required"
|
||||
)
|
||||
if any(value is not None for value in metadata) and metadata != (
|
||||
record.generation,
|
||||
record.algorithm,
|
||||
record.purpose,
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation signing metadata does not match key"
|
||||
)
|
||||
try:
|
||||
Ed25519PublicKey.from_public_bytes(record.public_key).verify(
|
||||
signature, message
|
||||
)
|
||||
except InvalidSignature as exc:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation signature is invalid"
|
||||
) from exc
|
||||
return record
|
||||
|
||||
def require_active_identity(
|
||||
self,
|
||||
*,
|
||||
key_id: str,
|
||||
generation: int,
|
||||
algorithm: str,
|
||||
purpose: str,
|
||||
at: datetime | None = None,
|
||||
) -> BuilderAttestationKeyRecord:
|
||||
record = self._records.get(key_id)
|
||||
if record is None:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation key is not trusted"
|
||||
)
|
||||
if (
|
||||
record.generation != generation
|
||||
or record.algorithm != algorithm
|
||||
or record.purpose != purpose
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation key metadata is invalid"
|
||||
)
|
||||
record.validate_at(at or datetime.now(timezone.utc))
|
||||
return record
|
||||
|
||||
|
||||
class BuilderAttestationVerifier:
|
||||
"""Public-key-only verifier; no builder private material is accepted."""
|
||||
|
||||
def __init__(self, public_keys: Mapping[str, bytes]):
|
||||
self.public_keys = {
|
||||
_validate_identifier("builder_key_id", key_id): bytes(value)
|
||||
for key_id, value in public_keys.items()
|
||||
}
|
||||
def __init__(
|
||||
self,
|
||||
public_keys: BuilderAttestationKeyRing | Mapping[str, bytes],
|
||||
):
|
||||
if isinstance(public_keys, BuilderAttestationKeyRing):
|
||||
self.keyring = public_keys
|
||||
elif isinstance(public_keys, Mapping):
|
||||
self.keyring = BuilderAttestationKeyRing.from_public_keys(
|
||||
public_keys
|
||||
)
|
||||
else:
|
||||
raise TypeError("builder verifier requires a builder keyring")
|
||||
|
||||
def verify(
|
||||
self,
|
||||
@@ -793,22 +1110,54 @@ class BuilderAttestationVerifier:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation does not bind candidate evidence"
|
||||
)
|
||||
key = self.public_keys.get(attestation.builder_key_id)
|
||||
if key is None:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation key is not trusted"
|
||||
)
|
||||
try:
|
||||
signature = base64.b64decode(
|
||||
attestation.signature, validate=True
|
||||
)
|
||||
Ed25519PublicKey.from_public_bytes(key).verify(
|
||||
signature, attestation.canonical_bytes()
|
||||
key_record = self.keyring.verify(
|
||||
attestation.builder_key_id,
|
||||
signature,
|
||||
attestation.canonical_bytes(),
|
||||
at=_parse_utc(attestation.issued_at),
|
||||
generation=attestation.builder_key_generation,
|
||||
algorithm=attestation.signature_algorithm,
|
||||
purpose=attestation.signature_purpose,
|
||||
)
|
||||
except (InvalidSignature, ValueError, TypeError) as exc:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation signature is invalid"
|
||||
) from exc
|
||||
if key_record.generation >= 1:
|
||||
expected_gitea = hashlib.sha256(
|
||||
_canonical_json(candidate.gitea_consistency)
|
||||
).hexdigest()
|
||||
expected_rollback = hashlib.sha256(
|
||||
_canonical_json(
|
||||
{
|
||||
"ready": candidate.rollback_ready,
|
||||
"rollback_sha256": release_plan.deployment.rollback_sha256,
|
||||
"source_commit": candidate.source_commit,
|
||||
}
|
||||
)
|
||||
).hexdigest()
|
||||
production_evidence = (
|
||||
attestation.gitea_evidence_sha256,
|
||||
attestation.rollback_evidence_sha256,
|
||||
attestation.policy_evidence_sha256,
|
||||
attestation.secret_scan_evidence_sha256,
|
||||
)
|
||||
if any(not value for value in production_evidence):
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation production evidence is incomplete"
|
||||
)
|
||||
if (
|
||||
attestation.gitea_evidence_sha256 != expected_gitea
|
||||
or attestation.rollback_evidence_sha256 != expected_rollback
|
||||
or attestation.policy_evidence_sha256 != release_plan.policy_sha256
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation production evidence is invalid"
|
||||
)
|
||||
return attestation
|
||||
|
||||
|
||||
@@ -3181,9 +3530,12 @@ class SilentReleaseActionService:
|
||||
builder_attestation_verifier: BuilderAttestationVerifier | None = None,
|
||||
trusted_policy: TrustedReleasePolicy | None = None,
|
||||
orchestrator: ReleaseOrchestrator | None = None,
|
||||
builder_readiness_probe: Callable[[], Mapping[str, Any]] | None = None,
|
||||
allow_legacy_candidate_input: bool = False,
|
||||
expected_repository: str = "",
|
||||
policy_sha256: str = "",
|
||||
max_builder_attempts: int = 3,
|
||||
builder_attempt_timeout_seconds: int = 180,
|
||||
):
|
||||
self.root = Path(root)
|
||||
self.execution_enabled = bool(execution_enabled)
|
||||
@@ -3194,11 +3546,28 @@ class SilentReleaseActionService:
|
||||
self.builder_attestation_verifier = builder_attestation_verifier
|
||||
self.trusted_policy = trusted_policy
|
||||
self.orchestrator = orchestrator
|
||||
self.builder_readiness_probe = builder_readiness_probe
|
||||
self.allow_legacy_candidate_input = bool(
|
||||
allow_legacy_candidate_input
|
||||
)
|
||||
self.expected_repository = expected_repository
|
||||
self.policy_sha256 = policy_sha256
|
||||
if (
|
||||
not isinstance(max_builder_attempts, int)
|
||||
or isinstance(max_builder_attempts, bool)
|
||||
or not 1 <= max_builder_attempts <= 10
|
||||
):
|
||||
raise ValueError("max_builder_attempts must be between 1 and 10")
|
||||
self.max_builder_attempts = max_builder_attempts
|
||||
if (
|
||||
not isinstance(builder_attempt_timeout_seconds, int)
|
||||
or isinstance(builder_attempt_timeout_seconds, bool)
|
||||
or not 1 <= builder_attempt_timeout_seconds <= 3600
|
||||
):
|
||||
raise ValueError(
|
||||
"builder_attempt_timeout_seconds must be between 1 and 3600"
|
||||
)
|
||||
self.builder_attempt_timeout_seconds = builder_attempt_timeout_seconds
|
||||
self.candidates = ReleaseCandidateStore(
|
||||
self.root / "candidates"
|
||||
)
|
||||
@@ -3235,10 +3604,26 @@ class SilentReleaseActionService:
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
|
||||
def get_builder_readiness(self) -> dict[str, Any]:
|
||||
"""Report dependency readiness without conflating it with API liveness."""
|
||||
if self.candidate_builder is None:
|
||||
return {"status": "UNAVAILABLE"}
|
||||
if self.builder_readiness_probe is None:
|
||||
# Explicit in-process test/adaptor construction remains usable;
|
||||
# production wiring provides a probe backed by its credentials.
|
||||
return {"status": "READY"}
|
||||
try:
|
||||
value = dict(self.builder_readiness_probe())
|
||||
except Exception:
|
||||
return {"status": "UNAVAILABLE"}
|
||||
if value.get("status") != "READY":
|
||||
return {"status": "UNAVAILABLE"}
|
||||
return _sanitize(value)
|
||||
|
||||
def create_release_intent(
|
||||
self, payload: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
allowed = {"event_id", "source_commit"}
|
||||
allowed = {"event_id", "source_commit", "version"}
|
||||
unknown = set(payload).difference(allowed)
|
||||
if unknown:
|
||||
raise CapabilityAuthorizationError(
|
||||
@@ -3250,12 +3635,17 @@ class SilentReleaseActionService:
|
||||
source_commit = _validate_oid(
|
||||
"source_commit", str(payload.get("source_commit") or "")
|
||||
)
|
||||
version = str(payload.get("version") or "")
|
||||
if version:
|
||||
_validate_identifier("version", version)
|
||||
intent_id = "intent-" + hashlib.sha256(
|
||||
f"{event_id}\0{source_commit}".encode("utf-8")
|
||||
).hexdigest()[:24]
|
||||
path = _safe_child(self.intents_root, intent_id)
|
||||
was_existing = path.exists()
|
||||
record = {
|
||||
"created_at": utc_now(),
|
||||
"attempt_count": 0,
|
||||
"event_id": event_id,
|
||||
"intent_id": intent_id,
|
||||
"source_commit": source_commit,
|
||||
@@ -3265,42 +3655,153 @@ class SilentReleaseActionService:
|
||||
else "BUILDER_UNAVAILABLE"
|
||||
),
|
||||
}
|
||||
if path.exists():
|
||||
if version:
|
||||
record["version"] = version
|
||||
if was_existing:
|
||||
existing = json.loads(path.read_text(encoding="utf-8"))
|
||||
if (
|
||||
existing.get("event_id") != event_id
|
||||
or existing.get("source_commit") != source_commit
|
||||
or str(existing.get("version") or "") != version
|
||||
):
|
||||
raise SagaConflict(
|
||||
"release intent id is bound to different evidence"
|
||||
)
|
||||
return {
|
||||
**_sanitize(existing),
|
||||
"idempotent": True,
|
||||
"execution_enabled": self.execution_enabled,
|
||||
"shadow_mode": not self.execution_enabled,
|
||||
"side_effects_executed": False,
|
||||
if existing.get("status") == "BUILDING":
|
||||
try:
|
||||
started_at = _parse_utc(
|
||||
str(existing.get("attempt_started_at") or "")
|
||||
)
|
||||
elapsed = (
|
||||
datetime.now(timezone.utc) - started_at
|
||||
).total_seconds()
|
||||
except (TypeError, ValueError):
|
||||
elapsed = self.builder_attempt_timeout_seconds + 1
|
||||
if elapsed <= self.builder_attempt_timeout_seconds:
|
||||
return self._intent_response(existing, idempotent=True)
|
||||
existing.update(
|
||||
{
|
||||
"failure_code": "TRUSTED_BUILDER_CRASH_RECOVERED",
|
||||
"last_recovery_code": "TRUSTED_BUILDER_CRASH_RECOVERED",
|
||||
"recovery_count": int(existing.get("recovery_count") or 0) + 1,
|
||||
"retryable": int(existing.get("attempt_count") or 0)
|
||||
< self.max_builder_attempts,
|
||||
"status": "BUILDER_RETRYABLE_FAILURE",
|
||||
"updated_at": utc_now(),
|
||||
}
|
||||
)
|
||||
_durable_json(path, existing)
|
||||
if (
|
||||
existing.get("status") != "BUILDER_RETRYABLE_FAILURE"
|
||||
or int(existing.get("attempt_count") or 0)
|
||||
>= self.max_builder_attempts
|
||||
):
|
||||
return self._intent_response(existing, idempotent=True)
|
||||
record = dict(existing)
|
||||
if self.candidate_builder is None:
|
||||
record.update(
|
||||
{
|
||||
"retryable": False,
|
||||
"status": "BUILDER_UNAVAILABLE",
|
||||
}
|
||||
)
|
||||
_durable_json(path, record)
|
||||
return self._intent_response(record, idempotent=was_existing)
|
||||
|
||||
record.update(
|
||||
{
|
||||
"attempt_count": int(record.get("attempt_count") or 0) + 1,
|
||||
"attempt_started_at": utc_now(),
|
||||
"retryable": False,
|
||||
"status": "BUILDING",
|
||||
"updated_at": utc_now(),
|
||||
}
|
||||
)
|
||||
_durable_json(path, record)
|
||||
result = dict(record)
|
||||
if self.candidate_builder is not None:
|
||||
try:
|
||||
candidate = self.create_release_candidate(
|
||||
{
|
||||
"event_id": event_id,
|
||||
"intent_id": intent_id,
|
||||
"source_commit": source_commit,
|
||||
**({"version": version} if version else {}),
|
||||
}
|
||||
)
|
||||
result.update(
|
||||
except TrustedCandidateBuilderUnavailable:
|
||||
record.update(
|
||||
{
|
||||
"candidate_id": candidate["candidate_id"],
|
||||
"status": candidate["saga_state"],
|
||||
"failure_code": "TRUSTED_BUILDER_UNAVAILABLE",
|
||||
"retryable": record["attempt_count"]
|
||||
< self.max_builder_attempts,
|
||||
"status": (
|
||||
"BUILDER_RETRYABLE_FAILURE"
|
||||
if record["attempt_count"] < self.max_builder_attempts
|
||||
else "BUILDER_TERMINAL_FAILURE"
|
||||
),
|
||||
}
|
||||
)
|
||||
_durable_json(path, result)
|
||||
_durable_json(path, record)
|
||||
return self._intent_response(record, idempotent=False)
|
||||
except CapabilityAuthorizationError:
|
||||
record.update(
|
||||
{
|
||||
"failure_code": "TRUSTED_BUILDER_REJECTED",
|
||||
"retryable": False,
|
||||
"status": "BUILDER_TERMINAL_FAILURE",
|
||||
}
|
||||
)
|
||||
_durable_json(path, record)
|
||||
return self._intent_response(record, idempotent=False)
|
||||
except (ConnectionError, OSError, TimeoutError):
|
||||
record.update(
|
||||
{
|
||||
"failure_code": "TRUSTED_BUILDER_UNAVAILABLE",
|
||||
"retryable": record["attempt_count"]
|
||||
< self.max_builder_attempts,
|
||||
"status": (
|
||||
"BUILDER_RETRYABLE_FAILURE"
|
||||
if record["attempt_count"] < self.max_builder_attempts
|
||||
else "BUILDER_TERMINAL_FAILURE"
|
||||
),
|
||||
}
|
||||
)
|
||||
_durable_json(path, record)
|
||||
return self._intent_response(record, idempotent=False)
|
||||
except Exception:
|
||||
# The durable intent is recoverable without serializing an
|
||||
# arbitrary exception (which may contain a credential or host
|
||||
# path). Repeated requests consume the small fixed retry budget.
|
||||
record.update(
|
||||
{
|
||||
"failure_code": "TRUSTED_BUILDER_FAILURE",
|
||||
"retryable": record["attempt_count"]
|
||||
< self.max_builder_attempts,
|
||||
"status": (
|
||||
"BUILDER_RETRYABLE_FAILURE"
|
||||
if record["attempt_count"] < self.max_builder_attempts
|
||||
else "BUILDER_TERMINAL_FAILURE"
|
||||
),
|
||||
}
|
||||
)
|
||||
_durable_json(path, record)
|
||||
return self._intent_response(record, idempotent=False)
|
||||
result = dict(record)
|
||||
result.update(
|
||||
{
|
||||
"candidate_id": candidate["candidate_id"],
|
||||
"retryable": False,
|
||||
"status": candidate["saga_state"],
|
||||
}
|
||||
)
|
||||
_durable_json(path, result)
|
||||
return self._intent_response(result, idempotent=False)
|
||||
|
||||
def _intent_response(
|
||||
self, record: Mapping[str, Any], *, idempotent: bool
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
**_sanitize(result),
|
||||
"idempotent": False,
|
||||
**_sanitize(dict(record)),
|
||||
"idempotent": idempotent,
|
||||
"execution_enabled": self.execution_enabled,
|
||||
"shadow_mode": not self.execution_enabled,
|
||||
"side_effects_executed": False,
|
||||
@@ -3321,6 +3822,7 @@ class SilentReleaseActionService:
|
||||
self, payload: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
if self.candidate_builder is not None:
|
||||
values = dict(payload)
|
||||
forbidden_evidence = {
|
||||
"artifact_sha256",
|
||||
"canary",
|
||||
@@ -3333,13 +3835,69 @@ class SilentReleaseActionService:
|
||||
"tests",
|
||||
"tree_oid",
|
||||
}
|
||||
if forbidden_evidence.intersection(payload):
|
||||
if forbidden_evidence.intersection(values):
|
||||
raise CapabilityAuthorizationError(
|
||||
"candidate evidence must come from the trusted builder"
|
||||
)
|
||||
worker_fields = {"candidate_id", "source_commit", "version"}
|
||||
intent_fields = {"event_id", "intent_id", "source_commit", "version"}
|
||||
supplied = set(values)
|
||||
is_worker_request = worker_fields.issubset(supplied)
|
||||
if is_worker_request:
|
||||
if supplied != worker_fields:
|
||||
raise ValueError("release worker request contains unknown fields")
|
||||
_validate_identifier(
|
||||
"candidate_id", str(values.get("candidate_id") or "")
|
||||
)
|
||||
_validate_identifier(
|
||||
"version", str(values.get("version") or "")
|
||||
)
|
||||
else:
|
||||
if not supplied.issubset(intent_fields):
|
||||
raise ValueError("trusted builder intent contains unknown fields")
|
||||
# Preserve the established internal intent contract
|
||||
# (source_commit + version) while allowing newer callers to
|
||||
# add an event/intent identifier. Neither form can supply
|
||||
# acceptance evidence; the host builder owns all collectors.
|
||||
if values.get("event_id"):
|
||||
_validate_identifier(
|
||||
"event_id", str(values["event_id"])
|
||||
)
|
||||
if values.get("intent_id"):
|
||||
_validate_identifier(
|
||||
"intent_id", str(values["intent_id"])
|
||||
)
|
||||
if values.get("version"):
|
||||
_validate_identifier(
|
||||
"version", str(values["version"])
|
||||
)
|
||||
_validate_oid(
|
||||
"source_commit", str(values.get("source_commit") or "")
|
||||
)
|
||||
readiness = self.get_builder_readiness()
|
||||
if readiness.get("status") != "READY":
|
||||
raise TrustedCandidateBuilderUnavailable(
|
||||
"trusted builder dependency is unavailable"
|
||||
)
|
||||
bundle = self.candidate_builder.build(dict(payload)).validate(
|
||||
self.builder_attestation_verifier
|
||||
)
|
||||
requested_source = str(payload.get("source_commit") or "")
|
||||
if (
|
||||
requested_source
|
||||
and bundle.candidate.source_commit != requested_source
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"trusted builder source commit does not match intent"
|
||||
)
|
||||
if (
|
||||
is_worker_request
|
||||
and bundle.candidate.candidate_id
|
||||
!= str(values["candidate_id"])
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"trusted builder candidate id does not match request"
|
||||
)
|
||||
if self.trusted_policy is None:
|
||||
raise CapabilityAuthorizationError(
|
||||
"trusted release policy is not configured"
|
||||
@@ -3457,6 +4015,19 @@ class SilentReleaseActionService:
|
||||
}
|
||||
|
||||
def get_release_saga(self, candidate_id: str) -> dict[str, Any]:
|
||||
# A read of a never-created candidate is a normal empty-state. Do
|
||||
# not turn a missing journal into a durable manual-review snapshot.
|
||||
saga_path = self.sagas._path(candidate_id)
|
||||
journal_path = self.sagas._journal(candidate_id)
|
||||
if not saga_path.exists() and not journal_path.exists():
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"status": "NOT_FOUND",
|
||||
"state": "NOT_FOUND",
|
||||
"execution_enabled": self.execution_enabled,
|
||||
"shadow_mode": not self.execution_enabled,
|
||||
"side_effects_executed": False,
|
||||
}
|
||||
return {
|
||||
**self.sagas.load(candidate_id).to_dict(),
|
||||
"execution_enabled": self.execution_enabled,
|
||||
|
||||
@@ -0,0 +1,905 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/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)
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/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")
|
||||
@@ -0,0 +1,135 @@
|
||||
"""V2R2C1 adapter using the existing V2R1C7 deployment primitives.
|
||||
|
||||
The service identity and loopback port remain stable for a safe cutover. The
|
||||
new release is installed under its own immutable directory and keeps the
|
||||
previous V2R1C7 release available for an explicit, owner-approved rollback.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
import v2r1c7_production_adapter as base
|
||||
|
||||
|
||||
VERSION = "V2R2C1"
|
||||
SERVICE = "aios-v2r1c7-production.service"
|
||||
PORT = 18927
|
||||
RELEASE_ROOT = Path("/opt/aios/releases")
|
||||
CURRENT_LINK = Path("/opt/aios/v2r2c1")
|
||||
ENV_FILE = Path("/etc/aios/aios-v2r2c1.env")
|
||||
STATE_FILE = Path("/var/lib/aios/v2r2c1/deployment-state.json")
|
||||
UNIT_FILE = Path("/etc/systemd/system/aios-v2r1c7-production.service")
|
||||
ROLLBACK_ROOT = Path("/var/lib/aios/v2r2c1/rollback")
|
||||
|
||||
REQUIRED_MEMBERS = {
|
||||
"payload/VERSION",
|
||||
"payload/active-runtime/app/deployment_state.py",
|
||||
"payload/active-runtime/app/v2r1c6_privacy_tools.py",
|
||||
"payload/active-runtime/app/v2r2c1_health_service.py",
|
||||
"payload/deploy/aios-v2/systemd/aios-v2r1c7-production.service",
|
||||
"payload/deploy/aios-v2/v2r2c1-runtime-config.json",
|
||||
"payload/deploy/aios-v2/v2r2c1-rollback.json",
|
||||
"payload/scripts/probe_v2r2c1_health.py",
|
||||
"payload/scripts/rollback_v2r2c1_release.sh",
|
||||
"payload/BUILD-METADATA.json",
|
||||
"payload/ARTIFACT-MANIFEST.json",
|
||||
"payload/ROLLBACK-METADATA.json",
|
||||
}
|
||||
|
||||
|
||||
def _configure_base() -> None:
|
||||
base.VERSION = VERSION
|
||||
base.SERVICE = SERVICE
|
||||
base.PORT = PORT
|
||||
base.REQUIRED_MEMBERS = REQUIRED_MEMBERS
|
||||
|
||||
|
||||
def _args() -> Namespace:
|
||||
return Namespace(
|
||||
release_root=RELEASE_ROOT,
|
||||
current_link=CURRENT_LINK,
|
||||
env_file=ENV_FILE,
|
||||
state_file=STATE_FILE,
|
||||
unit_file=UNIT_FILE,
|
||||
health_timeout=45,
|
||||
)
|
||||
|
||||
|
||||
def load_request() -> dict[str, Any]:
|
||||
_configure_base()
|
||||
value = base.load_request()
|
||||
if value.get("version") != VERSION or value.get("production_service") != SERVICE or int(value.get("production_port", -1)) != PORT:
|
||||
raise ValueError("V2R2C1 production binding mismatch")
|
||||
return value
|
||||
|
||||
|
||||
def _backup(path: Path, name: str) -> None:
|
||||
if path.is_file() and not path.is_symlink():
|
||||
ROLLBACK_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
destination = ROLLBACK_ROOT / name
|
||||
if not destination.exists():
|
||||
shutil.copy2(path, destination)
|
||||
|
||||
|
||||
def execute_request(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
_configure_base()
|
||||
if value.get("schema") != "aios.production-executor-request.v1":
|
||||
raise ValueError("request_schema_invalid")
|
||||
if value.get("operation") == "apply":
|
||||
_backup(UNIT_FILE, "aios-v2r1c7-production.service.previous")
|
||||
_backup(Path("/etc/aios/aios-v2r1c7.env"), "aios-v2r1c7.env.previous")
|
||||
_backup(Path("/var/lib/aios/v2r1c7/deployment-state.json"), "v2r1c7-deployment-state.previous")
|
||||
return base.install_release(_args(), value)
|
||||
if value.get("operation") == "probe":
|
||||
return base.probe(_args(), value)
|
||||
if value.get("operation") == "rollback":
|
||||
return rollback(value)
|
||||
raise ValueError("unsupported_operation")
|
||||
|
||||
|
||||
def rollback(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Explicit rollback only; this adapter never invokes it automatically."""
|
||||
import subprocess
|
||||
|
||||
subprocess.run(["systemctl", "stop", SERVICE], check=False, capture_output=True, text=True, timeout=45)
|
||||
previous_unit = ROLLBACK_ROOT / "aios-v2r1c7-production.service.previous"
|
||||
previous_env = ROLLBACK_ROOT / "aios-v2r1c7.env.previous"
|
||||
previous_state = ROLLBACK_ROOT / "v2r1c7-deployment-state.previous"
|
||||
if not previous_unit.is_file() or not previous_env.is_file():
|
||||
raise RuntimeError("ROLLBACK_RECEIPT_MISSING")
|
||||
UNIT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
ENV_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(previous_unit, UNIT_FILE)
|
||||
shutil.copy2(previous_env, Path("/etc/aios/aios-v2r1c7.env"))
|
||||
if previous_state.is_file():
|
||||
Path("/var/lib/aios/v2r1c7").mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(previous_state, Path("/var/lib/aios/v2r1c7/deployment-state.json"))
|
||||
subprocess.run(["systemctl", "daemon-reload"], check=True, capture_output=True, text=True, timeout=45)
|
||||
subprocess.run(["systemctl", "restart", SERVICE], check=True, capture_output=True, text=True, timeout=45)
|
||||
result = base.health("http://127.0.0.1:18927/health", {
|
||||
"version": "V2R1C7",
|
||||
"target_oid": str(json.loads(Path("/var/lib/aios/v2r1c7/deployment-state.json").read_text(encoding="utf-8")).get("commit_oid")),
|
||||
"artifact_sha256": str(json.loads(Path("/var/lib/aios/v2r1c7/deployment-state.json").read_text(encoding="utf-8")).get("artifact_sha256")),
|
||||
})
|
||||
return {"status": "ROLLED_BACK", "rollback_health": result, "automatic_rollback": False}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
value = load_request()
|
||||
result = execute_request(value)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(json.dumps({"status": "FAIL_CLOSED", "error": type(exc).__name__, "reason": str(exc)}, sort_keys=True))
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,5 @@
|
||||
"""V2R2C2 production health entry point; all identity is plan supplied."""
|
||||
from v2r1c6_privacy_tools import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,92 @@
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_lineage_supersessions (
|
||||
source_task_id TEXT NOT NULL,
|
||||
supersession_ordinal INTEGER NOT NULL CHECK (supersession_ordinal = 1),
|
||||
child_task_id TEXT NOT NULL UNIQUE,
|
||||
source_task_identity_sha256 TEXT NOT NULL,
|
||||
source_metadata_sha256 TEXT NOT NULL,
|
||||
owner_decision_sha256 TEXT NOT NULL UNIQUE,
|
||||
decision_id TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (source_task_id, supersession_ordinal)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_lineage_supersession_receipts (
|
||||
decision_id TEXT PRIMARY KEY,
|
||||
nonce TEXT NOT NULL UNIQUE,
|
||||
source_task_id TEXT NOT NULL,
|
||||
supersession_ordinal INTEGER NOT NULL,
|
||||
child_task_id TEXT NOT NULL UNIQUE,
|
||||
owner_decision_sha256 TEXT NOT NULL UNIQUE,
|
||||
decision_json TEXT NOT NULL,
|
||||
receipt_json TEXT NOT NULL,
|
||||
consumed_at TEXT NOT NULL,
|
||||
FOREIGN KEY (source_task_id, supersession_ordinal)
|
||||
REFERENCES task_lineage_supersessions(source_task_id, supersession_ordinal)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_claim_guard_bindings_v2 (
|
||||
binding_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL UNIQUE,
|
||||
source_task_id TEXT NOT NULL,
|
||||
supersession_ordinal INTEGER NOT NULL,
|
||||
expected_revision INTEGER NOT NULL CHECK (expected_revision >= 1),
|
||||
metadata_sha256 TEXT NOT NULL,
|
||||
required_capabilities_sha256 TEXT NOT NULL,
|
||||
worker_id TEXT NOT NULL,
|
||||
execution_digest TEXT NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
target_branch TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL,
|
||||
policy_json TEXT NOT NULL,
|
||||
policy_sha256 TEXT NOT NULL,
|
||||
binding_json TEXT NOT NULL,
|
||||
binding_sha256 TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (source_task_id, supersession_ordinal)
|
||||
REFERENCES task_lineage_supersessions(source_task_id, supersession_ordinal)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_claim_guard_binding_receipts_v2 (
|
||||
receipt_id TEXT PRIMARY KEY,
|
||||
binding_id TEXT NOT NULL UNIQUE,
|
||||
task_id TEXT NOT NULL UNIQUE,
|
||||
receipt_json TEXT NOT NULL,
|
||||
receipt_sha256 TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (binding_id) REFERENCES task_claim_guard_bindings_v2(binding_id)
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS task_lineage_supersessions_no_update
|
||||
BEFORE UPDATE ON task_lineage_supersessions BEGIN
|
||||
SELECT RAISE(ABORT, 'TASK_LINEAGE_SUPERSESSION_APPEND_ONLY');
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS task_lineage_supersessions_no_delete
|
||||
BEFORE DELETE ON task_lineage_supersessions BEGIN
|
||||
SELECT RAISE(ABORT, 'TASK_LINEAGE_SUPERSESSION_APPEND_ONLY');
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS task_lineage_supersession_receipts_no_update
|
||||
BEFORE UPDATE ON task_lineage_supersession_receipts BEGIN
|
||||
SELECT RAISE(ABORT, 'TASK_LINEAGE_SUPERSESSION_RECEIPT_APPEND_ONLY');
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS task_lineage_supersession_receipts_no_delete
|
||||
BEFORE DELETE ON task_lineage_supersession_receipts BEGIN
|
||||
SELECT RAISE(ABORT, 'TASK_LINEAGE_SUPERSESSION_RECEIPT_APPEND_ONLY');
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS task_claim_guard_bindings_v2_no_update
|
||||
BEFORE UPDATE ON task_claim_guard_bindings_v2 BEGIN
|
||||
SELECT RAISE(ABORT, 'TASK_CLAIM_GUARD_BINDING_APPEND_ONLY');
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS task_claim_guard_bindings_v2_no_delete
|
||||
BEFORE DELETE ON task_claim_guard_bindings_v2 BEGIN
|
||||
SELECT RAISE(ABORT, 'TASK_CLAIM_GUARD_BINDING_APPEND_ONLY');
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS task_claim_guard_binding_receipts_v2_no_update
|
||||
BEFORE UPDATE ON task_claim_guard_binding_receipts_v2 BEGIN
|
||||
SELECT RAISE(ABORT, 'TASK_CLAIM_GUARD_BINDING_RECEIPT_APPEND_ONLY');
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS task_claim_guard_binding_receipts_v2_no_delete
|
||||
BEFORE DELETE ON task_claim_guard_binding_receipts_v2 BEGIN
|
||||
SELECT RAISE(ABORT, 'TASK_CLAIM_GUARD_BINDING_RECEIPT_APPEND_ONLY');
|
||||
END;
|
||||
@@ -0,0 +1,41 @@
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_lineage_supersession_initial_projections (
|
||||
projection_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL UNIQUE,
|
||||
source_task_id TEXT NOT NULL,
|
||||
supersession_ordinal INTEGER NOT NULL CHECK (supersession_ordinal = 1),
|
||||
intent_sha256 TEXT NOT NULL UNIQUE,
|
||||
intent_json TEXT NOT NULL,
|
||||
owner_decision_sha256 TEXT NOT NULL UNIQUE,
|
||||
source_task_identity_sha256 TEXT NOT NULL,
|
||||
source_metadata_sha256 TEXT NOT NULL,
|
||||
source_business_execution_digest TEXT NOT NULL,
|
||||
canonical_child_execution_digest TEXT NOT NULL UNIQUE,
|
||||
external_job_id TEXT NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
target_branch TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL,
|
||||
adapter TEXT NOT NULL,
|
||||
required_capability TEXT NOT NULL,
|
||||
expected_revision INTEGER NOT NULL CHECK (expected_revision = 1),
|
||||
applied_revision INTEGER NOT NULL CHECK (applied_revision = 2),
|
||||
metadata_sha256 TEXT NOT NULL,
|
||||
required_capabilities_sha256 TEXT NOT NULL,
|
||||
projection_event_sha256 TEXT NOT NULL,
|
||||
receipt_json TEXT NOT NULL,
|
||||
receipt_sha256 TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (source_task_id, supersession_ordinal)
|
||||
REFERENCES task_lineage_supersessions(source_task_id, supersession_ordinal)
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS task_lineage_supersession_initial_projections_no_update
|
||||
BEFORE UPDATE ON task_lineage_supersession_initial_projections BEGIN
|
||||
SELECT RAISE(ABORT, 'TASK_LINEAGE_SUPERSESSION_INITIAL_PROJECTION_APPEND_ONLY');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS task_lineage_supersession_initial_projections_no_delete
|
||||
BEFORE DELETE ON task_lineage_supersession_initial_projections BEGIN
|
||||
SELECT RAISE(ABORT, 'TASK_LINEAGE_SUPERSESSION_INITIAL_PROJECTION_APPEND_ONLY');
|
||||
END;
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
[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
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/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__")
|
||||
@@ -0,0 +1,37 @@
|
||||
[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,17 +15,9 @@ 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} \
|
||||
--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 \
|
||||
--release-plan ${AIOS_RELEASE_PLAN} \
|
||||
--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/v2r1c7_production_adapter.py \
|
||||
--deploy-adapter /usr/bin/python3 /usr/libexec/aios-approved-release-executor/current/active-runtime/app/trusted_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,36 @@
|
||||
[Unit]
|
||||
Description=AIOS ChatGPT Bridge v2 staging control plane
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aiman
|
||||
Group=gitops
|
||||
PermissionsStartOnly=true
|
||||
WorkingDirectory=@RUNTIME_ROOT@
|
||||
Environment=AIOS_BRIDGE_SCHEMA_VERSION=aios.chatgpt-gitops-bridge.v2
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED=true
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=AIOS_GITEA_API_BASE=https://g.cnnetmkt.top/git/api/v1
|
||||
Environment=AIOS_GITEA_TOKEN_FILE=/run/credentials/aios-chatgpt-bridge-v2-staging.service/gitea_token
|
||||
Environment=AIOS_GITEA_REMOTE=gitea-http
|
||||
Environment=AIOS_GITOPS_BASELINE_AUTHORITY=/var/lib/aios-gitops/model-api-staging/baseline/current.json
|
||||
Environment=AIOS_CONTROL_PLANE_TARGET_VERSION=V2R2C1
|
||||
Environment=AIOS_CONTROL_PLANE_TARGET_OID=@SOURCE_OID@
|
||||
Environment=AIOS_CONTROL_PLANE_ARTIFACT_SHA256=@ARTIFACT_SHA256@
|
||||
Environment=PYTHONPATH=@RUNTIME_ROOT@:@RUNTIME_ROOT@/app:@RUNTIME_ROOT@/runtime-agent-os
|
||||
Environment=AIOS_DEPLOYMENT_STATE=STAGING
|
||||
LoadCredential=gitea_token:/run/credentials/aios-governance-executor.service/gitea-governance-executor-token
|
||||
ExecStartPre=/usr/bin/install -d -o aiman -g gitops -m 0750 /var/lib/aios-gitops/bridge-v2-staging
|
||||
ExecStartPre=/usr/bin/setfacl -m u:aiman:--x /usr/local/libexec
|
||||
ExecStart=/usr/bin/python3 -B @RUNTIME_ROOT@/app/aios_chatgpt_bridge_v2_server.py
|
||||
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
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,14 @@
|
||||
# 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
|
||||
@@ -0,0 +1,51 @@
|
||||
[Unit]
|
||||
Description=AIOS GitOps Model API V2R2C1 immutable staging runtime
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aiman
|
||||
Group=gitops
|
||||
WorkingDirectory=@RUNTIME_ROOT@
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
Environment=AIOS_GITOPS_ROOT=/var/lib/aios-gitops
|
||||
Environment=AIOS_GITOPS_WORKSPACE=@RUNTIME_ROOT@
|
||||
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_WRITES_ENABLED=false
|
||||
Environment=AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED=false
|
||||
Environment=AIOS_GITOPS_JOB_CONTROLLER_ENABLED=false
|
||||
Environment=AIOS_GITOPS_WORKSPACE_ROLE=runtime-anchor
|
||||
Environment=AIOS_GITOPS_RUNTIME_OID=@SOURCE_OID@
|
||||
Environment=AIOS_GITOPS_ARTIFACT_SHA256=@ARTIFACT_SHA256@
|
||||
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_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
|
||||
ExecStart=/usr/bin/python3 @RUNTIME_ROOT@/app/aios_model_api_staging.py
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
ReadOnlyPaths=@RUNTIME_ROOT@ /data/aios-gitops/repo.git /var/lib/aios-gitops-runtime-anchor/baseline
|
||||
ReadWritePaths=/var/lib/aios-gitops/model-api-staging
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,44 @@
|
||||
[Unit]
|
||||
Description=AIOS trusted candidate builder
|
||||
Requires=aios-trusted-candidate-builder.socket
|
||||
After=aios-trusted-candidate-builder.socket
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
UMask=0077
|
||||
LoadCredentialEncrypted=signing_private_key:/etc/credstore.encrypted/aios-trusted-candidate-builder/signing_private_key
|
||||
LoadCredentialEncrypted=builder_hmac:/etc/credstore.encrypted/aios-trusted-candidate-builder/builder_hmac
|
||||
Environment=AIOS_TRUSTED_BUILDER_POLICY=/opt/aios/trusted-candidate-builder/current/config/gitops/trusted-candidate-builder-policy.json
|
||||
Environment=AIOS_TRUSTED_BUILDER_GIT_DIR=/data/aios-gitops/repo.git
|
||||
Environment=AIOS_TRUSTED_BUILDER_EVIDENCE_ROOT=/var/lib/aios-trusted-candidate-builder/evidence
|
||||
Environment=AIOS_TRUSTED_BUILDER_NONCE_LEDGER=/var/lib/aios-trusted-candidate-builder/nonce-ledger.json
|
||||
Environment=AIOS_TRUSTED_BUILDER_KEY_ID=builder-attestation-ed25519-g1
|
||||
Environment=AIOS_TRUSTED_BUILDER_KEY_GENERATION=1
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/trusted-candidate-builder/current/app/aios_trusted_candidate_builder.py --socket-fd 3 --policy ${AIOS_TRUSTED_BUILDER_POLICY}
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
StateDirectory=aios-trusted-candidate-builder
|
||||
StateDirectoryMode=0700
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateNetwork=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectControlGroups=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictSUIDSGID=true
|
||||
SystemCallArchitectures=native
|
||||
CapabilityBoundingSet=
|
||||
ReadOnlyPaths=/opt/aios/trusted-candidate-builder /data/aios-gitops/repo.git /var/lib/aios-trusted-candidate-builder/evidence
|
||||
ReadWritePaths=/var/lib/aios-trusted-candidate-builder
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=AIOS trusted candidate builder AF_UNIX socket
|
||||
|
||||
[Socket]
|
||||
ListenStream=/run/aios-trusted-candidate-builder.sock
|
||||
SocketUser=root
|
||||
SocketGroup=gitops
|
||||
SocketMode=0660
|
||||
RemoveOnStop=yes
|
||||
Accept=no
|
||||
|
||||
[Install]
|
||||
WantedBy=sockets.target
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"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,76 @@
|
||||
# ADR: Owner-final governed merge authorization v2
|
||||
|
||||
Date: 2026-09-07
|
||||
|
||||
Status: Accepted for the P0 Owner-final authorization repair
|
||||
|
||||
## Context
|
||||
|
||||
AIOS is a single-Owner system. A signer, verifier, required check, branch
|
||||
protection rule, or merge executor may prove or execute an Owner decision, but
|
||||
must not become an independent policy authority that can permanently override
|
||||
an exact, valid Owner authorization.
|
||||
|
||||
The legacy approval seam also names the proof implementation as the Owner
|
||||
identity. That conflates the policy subject with a key or adapter and makes a
|
||||
default signer outage look like a higher-level veto.
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce the versioned `aios.owner-merge-approval.v2` artifact and make it
|
||||
the policy input to a governed merge admission. Its canonical payload binds:
|
||||
|
||||
- repository, pull-request number and exact pull-request head OID;
|
||||
- the expected `main` OID used as the CAS base;
|
||||
- `merge.pull-request` as the only operation;
|
||||
- required-check contract digest, policy version/digest and audit lineage
|
||||
digest;
|
||||
- generated approval ID and nonce, issued/not-before/expiry times; and
|
||||
- a distinct Owner policy identity plus the separate proof key identity.
|
||||
|
||||
The structured Owner authority creates the complete canonical payload from an
|
||||
immutable merge policy. It accepts no arbitrary caller bytes and has no
|
||||
generic signing operation. Changing PR head, CAS base, policy, repository,
|
||||
operation, or approval expiry invalidates the approval; a new policy-bound
|
||||
approval is required.
|
||||
|
||||
An issuance ledger replays the same still-valid artifact for idempotent client
|
||||
delivery, retains expired artifacts for audit, and issues a fresh approval
|
||||
with a new nonce after expiry. It must never turn an expired approval into a
|
||||
permanent signing deadlock.
|
||||
|
||||
Proof verification is public-key based. A recovery verifier validates an
|
||||
already-issued v2 approval against the registered Owner root without calling
|
||||
the default signing service. It is not a second policy principal and cannot
|
||||
mint, broaden, or replace an Owner approval. Absence of every trusted proof
|
||||
fails closed.
|
||||
|
||||
## Governing merge admission
|
||||
|
||||
The admission service consumes a valid approval only after independent live
|
||||
technical evidence satisfies the exact required-check contract. It then
|
||||
durably claims the approval nonce before requesting the protected merge. A
|
||||
duplicate or uncertain nonce is never retried as another side effect. The
|
||||
executor remains responsible for branch protection and CAS; its result is
|
||||
accepted only after Authority, Gitea, and repository API OID reconciliation.
|
||||
|
||||
Owner approval never writes a check result and never converts failed CI,
|
||||
security, provenance, repository-boundary, or OID-integrity evidence into
|
||||
PASS. Failure of those gates remains fail-closed. A post-side-effect
|
||||
reconciliation failure is recorded as unknown/reconciliation-required, not
|
||||
reported as a successful merge or blindly retried.
|
||||
|
||||
## Consequences
|
||||
|
||||
The model distinguishes three roles:
|
||||
|
||||
```text
|
||||
Owner approval decides whether this exact merge is allowed
|
||||
signer/verifier proves that exact Owner decision and its binding
|
||||
technical gates/CAS establish whether the merge can safely execute
|
||||
merge executor performs the protected operation and records outcome
|
||||
```
|
||||
|
||||
The v2 contract is additive and versioned. It does not reinterpret legacy
|
||||
approvals or relax existing branch protection, CI, security, provenance,
|
||||
tenant-boundary, nonce, expiry, audit, or reconciliation controls.
|
||||
@@ -0,0 +1,79 @@
|
||||
# ADR: Bounded Immutable Repository Discovery
|
||||
|
||||
- Status: Implemented locally; pending governed publication and live runtime
|
||||
deployment
|
||||
- Date: 2026-09-02
|
||||
- Scope: AIOS Repository Discovery P0
|
||||
|
||||
## Context
|
||||
|
||||
Repository discovery was needed to inspect unknown paths and compare exact
|
||||
repository snapshots, while the ordinary SSH route was not an authenticated
|
||||
execution plane. The discovery surface must not turn a readable runtime into a
|
||||
general-purpose shell, grep, checkout, or Git writer. It also must remain safe
|
||||
when a branch or tag moves between requests and when a diff is large.
|
||||
|
||||
## Decision
|
||||
|
||||
Implement a repository-scoped, read-only module behind the formal Bridge and
|
||||
Actions read routes:
|
||||
|
||||
1. Verify the exact `aios/aios` identity before every request.
|
||||
2. Resolve `HEAD`, branch, tag, or full commit input to one full commit OID;
|
||||
reject ambiguous short names and revision expressions.
|
||||
3. Invoke only fixed read-only Git subcommands (`rev-parse`, `ls-tree`,
|
||||
`cat-file`, and `diff`) with `shell=False`, disabled prompts, and bounded
|
||||
output. Stream NUL-delimited tree and diff records rather than collecting a
|
||||
complete tree or patch.
|
||||
4. Enforce normalized relative paths, repository allowlisting, depth/page/
|
||||
match/byte limits, binary-source exclusion, and no patch response.
|
||||
5. Sign pagination cursors with an HMAC over the operation, repository,
|
||||
resolved OID, complete normalized query, and offset. A cursor for another
|
||||
snapshot or query is invalid.
|
||||
6. Return resolved commit identity for every read and blob identity for file
|
||||
reads. Keep the four operations read-only and non-consequential at the
|
||||
Bridge contract boundary.
|
||||
7. Keep the mutating implementation workflow separate: Task and TaskPrincipal
|
||||
admission, claim/heartbeat, WriterLease, CAS, and `commitFilesGoverned` are
|
||||
still mandatory for source changes.
|
||||
|
||||
The canonical runtime paths are `/v2/repository/*`; the Actions adapter maps
|
||||
the same contract to `/api/repository/*`. The OpenAPI document records the
|
||||
mapping without copying a Path Item or duplicating operation IDs.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
- Retrying arbitrary SSH credentials or inventing a new network profile would
|
||||
bypass the supported execution-plane boundary.
|
||||
- User-controlled `grep`, shell commands, checkout paths, or direct bare-repo
|
||||
reads would make the repository and host boundary non-deterministic.
|
||||
- Reusing the write route would introduce unnecessary Task/lease/credential
|
||||
authority into a read-only request and make a read endpoint a write probe.
|
||||
- Returning a full patch or collecting all Git output before pagination would
|
||||
violate the bounded-output requirement for large repositories.
|
||||
|
||||
## Consequences
|
||||
|
||||
The API callers must carry the returned OID/cursor and must not assume that a
|
||||
floating ref remains unchanged. Search may return fewer results when its byte
|
||||
bound is exhausted, and a diff is an index rather than a patch. Cursor secret
|
||||
configuration becomes a required runtime dependency for paginated operations.
|
||||
|
||||
The isolated implementation is testable against a disposable bare repository.
|
||||
Windows can compile and run the portable discovery tests, but importing the
|
||||
full Bridge/Actions runtime requires POSIX `fcntl`; Linux HTTP and locking
|
||||
integration remains a separate verification item.
|
||||
|
||||
At the time of this decision, live HTTPS authority/runtime readback was
|
||||
available, but the live OpenAPI/capability surface did not advertise these four
|
||||
discovery operations and no authorized P0 Task envelope was present in the
|
||||
execution context. Therefore this ADR does not claim a governed commit,
|
||||
remote publication, or deployed runtime.
|
||||
|
||||
## Verification gate
|
||||
|
||||
This decision is complete for local implementation only after Python compile,
|
||||
discovery unit/large-diff tests, and Bridge/OpenAPI/Actions drift tests pass.
|
||||
It becomes live only after a formal Task, TaskPrincipal, lease, governed
|
||||
commit, and independent authority/remote/API OID readback prove the exact same
|
||||
commit, with all frozen write and staging invariants unchanged.
|
||||
@@ -0,0 +1,102 @@
|
||||
# ADR: P0-0 Task Admission Bootstrap without weakening TaskPrincipal
|
||||
|
||||
Date: 2026-09-03
|
||||
Status: Candidate for semantic port
|
||||
|
||||
## Context
|
||||
|
||||
The live control plane can expose governed writer operations while still being
|
||||
unable to create the Agent Task/TaskLease required by TaskPrincipal. This is a
|
||||
bootstrap deadlock, not a reason to enable global repository writes.
|
||||
|
||||
The safety invariant is:
|
||||
|
||||
```text
|
||||
Owner / durable delegation
|
||||
-> Task admission grant
|
||||
-> real Agent Task
|
||||
-> claim + heartbeat + TaskLease
|
||||
-> TaskPrincipal
|
||||
-> WriterLease
|
||||
-> governed commit
|
||||
```
|
||||
|
||||
`GovernedTaskBootstrap.create()` may create a GitOps binding/Job. It is not, by
|
||||
itself, evidence of an Agent Task claim or TaskLease.
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce a narrow Task Admission Bootstrap substrate with three operations:
|
||||
|
||||
- `getTaskAdmissionEnvelope`
|
||||
- `submitTaskAdmissionAuthorization`
|
||||
- `getTaskAdmissionStatus`
|
||||
|
||||
The envelope normalizes and binds repository, source OID, target branch,
|
||||
generation, execution digest, capabilities, file allowlist, risk, policy digest,
|
||||
expiry and nonce.
|
||||
|
||||
Authorization is accepted only from:
|
||||
|
||||
1. an already-verified `OwnerDelegationEpoch`; or
|
||||
2. an Owner signature over the exact canonical envelope.
|
||||
|
||||
There is deliberately no permissive default authorization backend.
|
||||
|
||||
The derived `TaskAdmissionGrant` can only narrow the parent authorization.
|
||||
Production, tag, baseline, main, global-write, force-push, runtime-anchor and
|
||||
arbitrary-shell capabilities are explicitly rejected.
|
||||
|
||||
The grant is passed to the existing Agent Task control plane via an injected
|
||||
`TaskAdmissionPort`. This component never writes the Task database.
|
||||
|
||||
The production adapter accepts only a service-managed official Task transport;
|
||||
it never returns its control credential. It first reconciles the exact task
|
||||
identity, then POSTs the official `/v1/tasks` contract only if absent, and
|
||||
reconciles an ambiguous response before reporting `UNKNOWN`. Task receipts use
|
||||
actual `QUEUED`, `CLAIMED`, or `RUNNING` state, never a synthetic ready state.
|
||||
|
||||
Durable epochs are resolved through an injected provider that receives the
|
||||
authenticated caller identity from trusted middleware. A body-supplied epoch
|
||||
identifier is only a lookup key. The existing durable preparation authorization
|
||||
does not become file-write or TaskPrincipal authority.
|
||||
|
||||
TaskPrincipal is not used to bootstrap the Task. It remains downstream of a
|
||||
real claim/TaskLease.
|
||||
|
||||
## Crash / replay semantics
|
||||
|
||||
Envelope creation is idempotent by exact request digest.
|
||||
|
||||
Authorization/admission is durable and exact replay returns the same Task
|
||||
receipt. A SQLite `BEGIN IMMEDIATE` claim persists the immutable grant and its
|
||||
authorization-proof digest before the Task side effect. Cross-instance callers
|
||||
observe the durable envelope winner; a different proof cannot replace the
|
||||
stored grant. The live Task API retains the second exact-identity boundary.
|
||||
|
||||
A crash after authorization but before Task API admission must resume the same
|
||||
envelope/grant identity. It must not create a replacement task or widen scope.
|
||||
|
||||
## Integration rule for current live authority
|
||||
|
||||
The source snapshot used for this candidate is not proven byte-identical to the
|
||||
2026-09-03 live authority. Therefore the code is a semantic-port candidate.
|
||||
|
||||
The live integration must:
|
||||
|
||||
- preserve existing TaskPrincipal verification;
|
||||
- adapt `TaskAdmissionPort` to the actual Agent Task API;
|
||||
- adapt `OwnerAuthorizationBackend` to the actual trusted Owner/delegation
|
||||
verifier;
|
||||
- expose admission routes outside the TaskPrincipal gate, because the
|
||||
cryptographic/delegation proof is the bootstrap authority;
|
||||
- never turn the runtime anchor into a writer;
|
||||
- never use Gitea/SSH/downstream credentials as Owner authority;
|
||||
- publish OpenAPI/capability metadata only when handlers are actually wired.
|
||||
|
||||
## Consequences
|
||||
|
||||
The control plane can become unattended after one durable Owner delegation
|
||||
epoch, while retaining fail-closed boundaries. If no valid delegation exists,
|
||||
the only legitimate stop is an already-generated canonical signing envelope
|
||||
awaiting the Owner's cryptographic signature.
|
||||
@@ -0,0 +1,38 @@
|
||||
# ADR: Owner Scoped Task Lineage Supersession P0
|
||||
|
||||
## 决策
|
||||
|
||||
`TASK-SINGLEHOST-LINEAGE-SUPERSESSION-P0-20260905` 新增一个只服务于
|
||||
`TASK-SINGLEHOST-TRUSTROOT-P0-20260904` 到
|
||||
`TASK-SINGLEHOST-TRUSTROOT-P0-20260904-S1` 的控制面 capability。
|
||||
它创建 append-only child lineage;绝不重写 source Task、metadata、revision、attempt、claim 或 lease。
|
||||
|
||||
## 安全边界
|
||||
|
||||
请求只携带 Owner decision 和 source 身份断言。控制面复用正式 Task identity helper
|
||||
与现有 Owner verifier,严格校验 namespace、principal、当前 Owner key、短 TTL、nonce、
|
||||
decision ID、exact-CAS 字段以及唯一 child ID/ordinal。未知字段、任意 child payload、通配
|
||||
Task/repository/branch、旧 signer 或 replay 均 fail-closed。
|
||||
|
||||
child payload 由 canonical initial-execution profile 在服务端构造:`QUEUED`、revision 1、
|
||||
attempt 0、无 claim/lease、空初始 capabilities 和 pristine metadata。child 普通 event 链
|
||||
只允许 `TASK_CREATED`;lineage/decision/receipt 审计必须写入独立 append-only 表。
|
||||
|
||||
## 一致性和重放
|
||||
|
||||
单一 `BEGIN IMMEDIATE` 事务重读 source exact-CAS、检查 source+ordinal/child/decision/nonce
|
||||
唯一性、插入 child、lineage、decision 和 receipt,并整体提交。任一步失败必须回滚,不能
|
||||
留下半创建 child。只有 decision digest 完全相同的请求可返回同一 receipt;相同唯一键而
|
||||
digest 不同必须拒绝。append-only 表禁止 UPDATE 和 DELETE。
|
||||
|
||||
## ClaimGuard
|
||||
|
||||
source 的 frozen ClaimGuard artifact 永不修改。child 的 Task identity 和 projected metadata
|
||||
identity 必然不同,必须使用新建的版本化、append-only child binding/receipt,精确绑定
|
||||
child task、projected revision/metadata/capabilities、worker、execution digest、repository、
|
||||
branch、generation 和 external job。dispatch 只识别 source 与 exact child;缺失或重复 binding
|
||||
一律拒绝。不得使用 latest、通配符或 caller 指定路径。
|
||||
|
||||
## 验收
|
||||
|
||||
live invocation 前必须在 isolated SQLite 完成 source exact state 到 canonical projection、
|
||||
@@ -240,7 +240,222 @@ components:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
candidate_id: {type: string}
|
||||
RepositoryTreeEntry:
|
||||
type: object
|
||||
required: [path, type, object_oid]
|
||||
properties:
|
||||
path: {type: string, minLength: 1, maxLength: 512}
|
||||
type: {type: string, enum: [blob, tree, commit]}
|
||||
object_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
mode: {type: string}
|
||||
RepositoryTreeResponse:
|
||||
type: object
|
||||
required: [repository, resolved_oid, entries, truncated, next_cursor]
|
||||
properties:
|
||||
repository: {type: string, const: aios/aios}
|
||||
resolved_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
entries: {type: array, items: {$ref: '#/components/schemas/RepositoryTreeEntry'}}
|
||||
truncated: {type: boolean}
|
||||
next_cursor: {type: string, nullable: true, maxLength: 4096}
|
||||
RepositorySearchMatch:
|
||||
type: object
|
||||
required: [path, line_start, line_end, blob_oid, snippet]
|
||||
properties:
|
||||
path: {type: string, minLength: 1, maxLength: 512}
|
||||
line_start: {type: integer, minimum: 1}
|
||||
line_end: {type: integer, minimum: 1}
|
||||
blob_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
snippet: {type: string, maxLength: 512}
|
||||
RepositorySearchResponse:
|
||||
type: object
|
||||
required: [repository, resolved_oid, matches, scanned_bytes, max_bytes, truncated, next_cursor]
|
||||
properties:
|
||||
repository: {type: string, const: aios/aios}
|
||||
resolved_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
matches: {type: array, items: {$ref: '#/components/schemas/RepositorySearchMatch'}}
|
||||
scanned_bytes: {type: integer, minimum: 0}
|
||||
max_bytes: {type: integer, minimum: 1, maximum: 1048576}
|
||||
binary_blobs_skipped: {type: integer, minimum: 0}
|
||||
truncated: {type: boolean}
|
||||
next_cursor: {type: string, nullable: true, maxLength: 4096}
|
||||
RepositoryDiffEntry:
|
||||
type: object
|
||||
required: [path, status, additions, deletions, old_path]
|
||||
properties:
|
||||
path: {type: string, minLength: 1, maxLength: 512}
|
||||
status: {type: string, pattern: '^[ACDMRTUX][0-9]*$'}
|
||||
additions: {type: integer, nullable: true, minimum: 0}
|
||||
deletions: {type: integer, nullable: true, minimum: 0}
|
||||
old_path: {type: string, nullable: true, maxLength: 512}
|
||||
RepositoryDiffIndexResponse:
|
||||
type: object
|
||||
required: [repository, resolved_from_oid, resolved_to_oid, entries, truncated, next_cursor]
|
||||
properties:
|
||||
repository: {type: string, const: aios/aios}
|
||||
resolved_from_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
resolved_to_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
entries: {type: array, items: {$ref: '#/components/schemas/RepositoryDiffEntry'}}
|
||||
truncated: {type: boolean}
|
||||
next_cursor: {type: string, nullable: true, maxLength: 4096}
|
||||
RepositoryFileResponse:
|
||||
type: object
|
||||
required: [repository, resolved_oid, blob_oid, path, content, content_base64, content_bytes, truncated]
|
||||
properties:
|
||||
repository: {type: string, const: aios/aios}
|
||||
resolved_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
blob_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
path: {type: string, minLength: 1, maxLength: 512}
|
||||
content: {type: string}
|
||||
content_base64: {type: string, format: byte}
|
||||
content_bytes: {type: integer, minimum: 0}
|
||||
truncated: {type: boolean}
|
||||
paths:
|
||||
/v2/repository/tree:
|
||||
get:
|
||||
operationId: listRepositoryTree
|
||||
summary: List one bounded page of an immutable repository tree
|
||||
security: []
|
||||
x-openai-isConsequential: false
|
||||
x-aios-read-only: true
|
||||
x-aios-bounded: true
|
||||
x-aios-repository-scoped: true
|
||||
x-aios-no-shell: true
|
||||
x-aios-legacy-path: /api/repository/tree
|
||||
parameters:
|
||||
- {in: query, name: repository, required: true, schema: {type: string, const: aios/aios}}
|
||||
- {in: query, name: ref, required: true, schema: {type: string, minLength: 1, maxLength: 240}}
|
||||
- {in: query, name: prefix, schema: {type: string, maxLength: 512}}
|
||||
- {in: query, name: depth, schema: {type: integer, minimum: 1, maximum: 32, default: 8}}
|
||||
- {in: query, name: limit, required: true, schema: {type: integer, minimum: 1, maximum: 1000}}
|
||||
- {in: query, name: cursor, schema: {type: string, maxLength: 4096}}
|
||||
responses:
|
||||
'200': {description: Immutable tree page, content: {application/json: {schema: {$ref: '#/components/schemas/RepositoryTreeResponse'}}}}
|
||||
'400': {description: Invalid repository, ref, path, bounds, or cursor}
|
||||
'403': {description: Repository identity is not allowed}
|
||||
'404': {description: Repository ref or path not found}
|
||||
'503': {description: Authoritative repository or discovery configuration unavailable}
|
||||
/v2/repository/search:
|
||||
get:
|
||||
operationId: searchRepositorySource
|
||||
summary: Search bounded source blobs at one immutable repository revision
|
||||
security: []
|
||||
x-openai-isConsequential: false
|
||||
x-aios-read-only: true
|
||||
x-aios-bounded: true
|
||||
x-aios-repository-scoped: true
|
||||
x-aios-no-shell: true
|
||||
x-aios-legacy-path: /api/repository/search
|
||||
parameters:
|
||||
- {in: query, name: repository, required: true, schema: {type: string, const: aios/aios}}
|
||||
- {in: query, name: ref, required: true, schema: {type: string, minLength: 1, maxLength: 240}}
|
||||
- {in: query, name: query, required: true, schema: {type: string, minLength: 1, maxLength: 512}}
|
||||
- {in: query, name: path_globs, style: form, explode: true, schema: {type: array, maxItems: 16, items: {type: string, maxLength: 512}}}
|
||||
- {in: query, name: max_matches, required: true, schema: {type: integer, minimum: 1, maximum: 1000}}
|
||||
- {in: query, name: max_bytes, required: true, schema: {type: integer, minimum: 1, maximum: 1048576}}
|
||||
- {in: query, name: cursor, schema: {type: string, maxLength: 4096}}
|
||||
responses:
|
||||
'200': {description: Bounded source matches with blob identity, content: {application/json: {schema: {$ref: '#/components/schemas/RepositorySearchResponse'}}}}
|
||||
'400': {description: Invalid repository, ref, query, path glob, bounds, or cursor}
|
||||
'403': {description: Repository identity is not allowed}
|
||||
'404': {description: Repository ref not found}
|
||||
'503': {description: Authoritative repository or discovery configuration unavailable}
|
||||
/v2/repository/diff-index:
|
||||
get:
|
||||
operationId: getDiffIndex
|
||||
summary: List a bounded immutable diff index without returning a patch
|
||||
security: []
|
||||
x-openai-isConsequential: false
|
||||
x-aios-read-only: true
|
||||
x-aios-bounded: true
|
||||
x-aios-repository-scoped: true
|
||||
x-aios-no-shell: true
|
||||
x-aios-legacy-path: /api/repository/diff-index
|
||||
parameters:
|
||||
- {in: query, name: repository, required: true, schema: {type: string, const: aios/aios}}
|
||||
- {in: query, name: from, required: true, schema: {type: string, minLength: 1, maxLength: 240}}
|
||||
- {in: query, name: to, required: true, schema: {type: string, minLength: 1, maxLength: 240}}
|
||||
- {in: query, name: path_prefix, schema: {type: string, maxLength: 512}}
|
||||
- {in: query, name: limit, required: true, schema: {type: integer, minimum: 1, maximum: 1000}}
|
||||
- {in: query, name: cursor, schema: {type: string, maxLength: 4096}}
|
||||
responses:
|
||||
'200': {description: Bounded diff index with immutable endpoints, content: {application/json: {schema: {$ref: '#/components/schemas/RepositoryDiffIndexResponse'}}}}
|
||||
'400': {description: Invalid repository, revision, path prefix, bounds, or cursor}
|
||||
'403': {description: Repository identity is not allowed}
|
||||
'404': {description: Repository revision not found}
|
||||
'503': {description: Authoritative repository or discovery configuration unavailable}
|
||||
/v2/repository/file:
|
||||
get:
|
||||
operationId: readRepositoryFile
|
||||
summary: Read one bounded file from an immutable repository revision
|
||||
security: []
|
||||
x-openai-isConsequential: false
|
||||
x-aios-read-only: true
|
||||
x-aios-bounded: true
|
||||
x-aios-repository-scoped: true
|
||||
x-aios-no-shell: true
|
||||
x-aios-legacy-path: /api/repository/file
|
||||
parameters:
|
||||
- {in: query, name: repository, required: true, schema: {type: string, const: aios/aios}}
|
||||
- {in: query, name: ref, required: true, schema: {type: string, minLength: 1, maxLength: 240}}
|
||||
- {in: query, name: path, required: true, schema: {type: string, minLength: 1, maxLength: 512}}
|
||||
- {in: query, name: max_bytes, schema: {type: integer, minimum: 1, maximum: 1048576, default: 262144}}
|
||||
responses:
|
||||
'200': {description: Bounded file content with resolved commit and blob identity, content: {application/json: {schema: {$ref: '#/components/schemas/RepositoryFileResponse'}}}}
|
||||
'400': {description: Invalid repository, ref, path, or bounds}
|
||||
'403': {description: Repository identity is not allowed}
|
||||
'404': {description: Repository ref or file not found}
|
||||
'503': {description: Authoritative repository or discovery configuration unavailable}
|
||||
/v2/commit-files:
|
||||
post:
|
||||
operationId: commitFilesGoverned
|
||||
summary: Submit one atomic governed commit containing multiple files
|
||||
security: [{taskPrincipalAuth: []}]
|
||||
x-aios-task-principal: Bridge verifies a fresh aios.task-principal-assertion.v1 and exact task/job/repository/branch/generation/revision/attempt/execution-digest/capability binding; caller JSON cannot provide a principal and missing verification is denied.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- task_id
|
||||
- job_id
|
||||
- repository
|
||||
- expected_branch_oid
|
||||
- target_branch
|
||||
- generation
|
||||
- idempotency_key
|
||||
- files
|
||||
- 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}
|
||||
files:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [path, content_base64]
|
||||
properties:
|
||||
path: {type: string, minLength: 1, maxLength: 512}
|
||||
content_base64: {type: string}
|
||||
message: {type: string, minLength: 1, maxLength: 200}
|
||||
responses:
|
||||
'200': {description: Governed commit job and verified branch result}
|
||||
'202': {description: Existing external Job alias requires authoritative reconciliation; no replacement Job is created}
|
||||
'401': {description: No verified Task principal is available}
|
||||
'403': {description: Verified Task principal lacks exact task, external Job, or capability scope}
|
||||
'400': {description: Contract or policy rejection}
|
||||
'409': {description: Branch CAS or idempotency conflict}
|
||||
'423': {description: Narrow P0 Task-principal mutation gate is frozen; assertion is not consumed}
|
||||
/createReleaseIntent:
|
||||
post:
|
||||
operationId: createReleaseIntent
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# AIOS Task Admission Bootstrap OpenAPI fragment.
|
||||
# Integrate into the canonical Bridge OpenAPI only when the corresponding live
|
||||
# handlers and authorization backend are configured. This fragment does not
|
||||
# enable legacy repository mutation or change TaskPrincipal/WriterLease gates.
|
||||
|
||||
components:
|
||||
schemas:
|
||||
TaskAdmissionEnvelopeRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- idempotency_key
|
||||
- task_id
|
||||
- repository
|
||||
- authority_base_oid
|
||||
- target_branch
|
||||
- generation
|
||||
- execution_digest
|
||||
- requested_capabilities
|
||||
- file_allowlist
|
||||
- risk
|
||||
properties:
|
||||
idempotency_key: {type: string, minLength: 8, maxLength: 256}
|
||||
task_id: {type: string, minLength: 3, maxLength: 180}
|
||||
repository: {type: string, enum: [aios/aios]}
|
||||
authority_base_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
target_branch: {type: string, pattern: '^agent/.+'}
|
||||
generation: {type: integer, minimum: 1}
|
||||
execution_digest: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
requested_capabilities:
|
||||
type: array
|
||||
minItems: 1
|
||||
uniqueItems: true
|
||||
items: {type: string}
|
||||
file_allowlist:
|
||||
type: array
|
||||
minItems: 1
|
||||
uniqueItems: true
|
||||
items: {type: string}
|
||||
risk: {type: string, enum: [low, medium]}
|
||||
expires_in_seconds:
|
||||
type: integer
|
||||
minimum: 60
|
||||
maximum: 900
|
||||
|
||||
TaskAdmissionAuthorizationRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [envelope_id, authorization_kind]
|
||||
properties:
|
||||
envelope_id: {type: string}
|
||||
authorization_kind:
|
||||
type: string
|
||||
enum: [delegation_epoch, owner_signature]
|
||||
delegation_epoch_id: {type: string}
|
||||
owner_key_id: {type: string}
|
||||
signature_base64: {type: string}
|
||||
|
||||
paths:
|
||||
/v2/task-admission/envelope:
|
||||
post:
|
||||
operationId: getTaskAdmissionEnvelope
|
||||
summary: Build and persist a canonical narrow Task admission envelope
|
||||
description: >
|
||||
No Git/Task mutation occurs. The server normalizes repository, branch,
|
||||
capabilities, file allowlist, risk, policy digest, expiry and nonce.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/TaskAdmissionEnvelopeRequest'}
|
||||
responses:
|
||||
'200': {description: Canonical envelope and signing payload}
|
||||
'403': {description: Requested scope exceeds bootstrap policy}
|
||||
'409': {description: Idempotency key conflicts with a different request}
|
||||
|
||||
/v2/task-admission/authorize:
|
||||
post:
|
||||
operationId: submitTaskAdmissionAuthorization
|
||||
summary: Verify durable delegation or Owner signature and admit one exact Task
|
||||
description: >
|
||||
This operation does not accept a general-purpose bearer as authority.
|
||||
Authorization is proven by a verified OwnerDelegationEpoch or by the
|
||||
Owner signature over the exact canonical envelope. The resulting grant
|
||||
can only narrow scope and is handed to the existing Agent Task API.
|
||||
A delegation identifier in caller JSON is insufficient: the backend
|
||||
binds the verified parent epoch to the authenticated service caller.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/TaskAdmissionAuthorizationRequest'}
|
||||
responses:
|
||||
'200': {description: Exact QUEUED, CLAIMED, or RUNNING Task receipt or idempotent replay}
|
||||
'403': {description: Invalid, expired, revoked or widened authorization}
|
||||
'409': {description: Conflicting idempotent admission}
|
||||
|
||||
/v2/task-admission/status:
|
||||
get:
|
||||
operationId: getTaskAdmissionStatus
|
||||
summary: Read durable Task-admission state
|
||||
parameters:
|
||||
- in: query
|
||||
name: envelope_id
|
||||
required: true
|
||||
schema: {type: string}
|
||||
responses:
|
||||
'200': {description: Pending or admitted receipt}
|
||||
'404': {description: Envelope not found}
|
||||
@@ -0,0 +1,117 @@
|
||||
# Repository Discovery P0 Runbook
|
||||
|
||||
This runbook covers the read-only repository discovery surface and the
|
||||
governed workflow used to publish a change to it. It does not authorize a
|
||||
production deployment, a protected-ref update, a tag operation, or a runtime
|
||||
mode change.
|
||||
|
||||
## Safety invariants
|
||||
|
||||
Before any operation, record the repository identity and the exact authority
|
||||
OID. The only repository identity in this contract is `aios/aios`. A floating
|
||||
ref is resolved once; all subsequent reads in the response are bound to the
|
||||
returned full `resolved_oid` (or `resolved_from_oid` and `resolved_to_oid` for
|
||||
a diff) and, for files, the returned `blob_oid`.
|
||||
|
||||
The following invariants remain frozen for this P0:
|
||||
|
||||
```text
|
||||
AIOS_GITOPS_WRITES_ENABLED=false
|
||||
legacy_global_writes_enabled=false
|
||||
18893 runtime_mode=IMMUTABLE_STAGING
|
||||
direct bare-repository writes/pushes=forbidden
|
||||
direct Task DB writes=forbidden
|
||||
```
|
||||
|
||||
Discovery is a read-only operation. It does not acquire a WriterLease and it
|
||||
does not create a Task. Any implementation change still has to use the formal
|
||||
Task → TaskPrincipal → TaskLease/claim/heartbeat → WriterLease →
|
||||
`commitFilesGoverned` path. A request to discovery must never be used as a
|
||||
write-capability probe.
|
||||
|
||||
## Execution-plane preflight
|
||||
|
||||
1. Prefer the formal HTTPS control plane at the configured public API/Bridge
|
||||
endpoint. If raw SSH fails before authentication, preserve that evidence
|
||||
and do not retry arbitrary usernames, keys, ports, or shell commands.
|
||||
2. Read runtime, capability, snapshot, and OpenAPI facts. Confirm that the
|
||||
authority identity and OID are the expected ones, and record whether the
|
||||
discovery routes are actually deployed on that plane.
|
||||
3. Classify the available plane as A (network-enabled Codex profile), B
|
||||
(attested online worker), C (formal HTTPS control plane), or D (existing
|
||||
executor/coordinator). Select only a supported plane; do not invent a new
|
||||
SSH or credential path.
|
||||
4. Treat a readable runtime or a candidate branch as evidence of availability,
|
||||
not as authorization to write.
|
||||
|
||||
The durable discovery record is
|
||||
`execution-plane-discovery.json`. It must not be included in the governed
|
||||
source change unless the task explicitly allowlists it.
|
||||
|
||||
## Read operations
|
||||
|
||||
The canonical Bridge routes are:
|
||||
|
||||
| Operation | Method | Required query fields | Optional query fields |
|
||||
| --- | --- | --- | --- |
|
||||
| `listRepositoryTree` | `GET /v2/repository/tree` | `repository`, `ref`, `limit` | `prefix`, `depth`, `cursor` |
|
||||
| `searchRepositorySource` | `GET /v2/repository/search` | `repository`, `ref`, `query`, `max_matches`, `max_bytes` | `path_globs`, `cursor` |
|
||||
| `getDiffIndex` | `GET /v2/repository/diff-index` | `repository`, `from`, `to`, `limit` | `path_prefix`, `cursor` |
|
||||
| `readRepositoryFile` | `GET /v2/repository/file` | `repository`, `ref`, `path` | `max_bytes` |
|
||||
|
||||
The Actions adapter exposes the corresponding read-only legacy paths under
|
||||
`/api/repository/*`; the OpenAPI document records each mapping with
|
||||
`x-aios-legacy-path`.
|
||||
|
||||
Every request is bounded. Repository identity is allowlisted, refs are
|
||||
limited to a branch/tag/full commit form, paths are normalized POSIX-relative
|
||||
paths, and traversal, option-like paths, shell metacharacters, and ambiguous
|
||||
refs fail closed. Search skips binary blobs and enforces its cumulative byte
|
||||
bound. Tree, search, and diff responses are paginated. A cursor is an HMAC-
|
||||
bound record containing the operation, repository, resolved OID, complete
|
||||
query, and offset; never edit or manually construct one.
|
||||
|
||||
The diff endpoint returns an index only: status, path, optional old path, and
|
||||
bounded additions/deletions. It never returns a patch body. The file endpoint
|
||||
returns bounded text plus `content_base64`, `resolved_oid`, and `blob_oid`.
|
||||
|
||||
## Publishing an implementation change
|
||||
|
||||
1. Preserve the existing dirty worktree. Create a new isolated worktree at
|
||||
the exact authority OID and use a task-scoped branch.
|
||||
2. Run discovery unit, pagination, cursor, identity, traversal, binary,
|
||||
max-byte, immutable-head, and large-diff tests. Run the Bridge/OpenAPI/
|
||||
Actions drift tests and compile checks.
|
||||
3. Obtain a formal governed Task and keep its exact task, job, generation,
|
||||
idempotency, branch, and base-OID identities. Claim and heartbeat through
|
||||
the supported control plane; do not edit the Task DB or copy a credential.
|
||||
4. Acquire the Task-bound principal and WriterLease. Submit the allowlisted
|
||||
source files through `commitFilesGoverned`, then reconcile the Job if the
|
||||
result is uncertain.
|
||||
5. Independently read back the authority Git ref, the remote Git API ref, and
|
||||
the control-plane projection. Completion requires three-way equality to the
|
||||
governed commit OID, plus the required deployment/acceptance/restart/
|
||||
official-verification evidence for any release claim.
|
||||
|
||||
If the discovery routes are absent from the live OpenAPI/runtime surface, stop
|
||||
at local validation. Report the implementation and tests as local evidence and
|
||||
mark governed Task, WriterLease, governed commit, and post-commit readback as
|
||||
`NOT_VERIFIED`.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
- `REPOSITORY_NOT_ALLOWED`, identity mismatch, invalid path/ref, invalid
|
||||
cursor, or ambiguous ref is a client or security rejection; do not broaden
|
||||
the allowlist.
|
||||
- An unavailable authority repository or missing cursor secret is a runtime
|
||||
configuration failure; do not disable cursor signing or fall back to a
|
||||
workspace checkout.
|
||||
- A timeout, oversized output, or large diff is bounded failure. Narrow the
|
||||
prefix/page or repair the existing supported runtime, never return an
|
||||
unbounded patch.
|
||||
- On Windows, a missing `fcntl` means POSIX integration is unverified. Keep
|
||||
the Python compile and portable unit evidence, then rerun the HTTP and file
|
||||
locking probes on the supported Linux runtime.
|
||||
|
||||
No recovery step in this runbook resets, cleans, overwrites, force-pushes, or
|
||||
auto-commits a user worktree.
|
||||
@@ -0,0 +1,102 @@
|
||||
# Task Admission Bootstrap P0 Runbook
|
||||
|
||||
## Goal
|
||||
|
||||
Repair the Task-admission bootstrap chain without enabling legacy/global writes.
|
||||
|
||||
## Preconditions
|
||||
|
||||
Re-read live authority, runtime mode, OpenAPI, capabilities and current Task
|
||||
API. Do not assume the package source paths are current.
|
||||
|
||||
Required invariants:
|
||||
|
||||
```text
|
||||
AIOS_GITOPS_WRITES_ENABLED=false
|
||||
legacy_global_writes_enabled=false
|
||||
18893=IMMUTABLE_STAGING
|
||||
main/tag/baseline/production writes=false
|
||||
```
|
||||
|
||||
## Semantic-port sequence
|
||||
|
||||
1. Locate the live Agent Task create/admission implementation.
|
||||
2. Locate the current Owner/delegation signature verifier.
|
||||
3. Port `aios_task_admission_bootstrap.py` as the common substrate.
|
||||
4. Implement `TaskAdmissionPort.admit()` with the official Agent Task API.
|
||||
5. Implement `OwnerAuthorizationBackend` with the current trusted verifier.
|
||||
6. Wire the three task-admission operations into the existing Bridge.
|
||||
7. Keep TaskPrincipal issuance downstream of real Task claim/TaskLease.
|
||||
8. Merge the OpenAPI fragment only after the live handlers are wired.
|
||||
9. Run impacted Linux/POSIX, TaskPrincipal, Bridge and writer regression.
|
||||
10. Create the bootstrap-repair governed commit through the existing sanctioned
|
||||
recovery path.
|
||||
11. Use the repaired admission path to create the Repository Discovery P0 Task.
|
||||
12. Claim/heartbeat -> TaskLease -> TaskPrincipal -> WriterLease.
|
||||
13. Commit only the existing Repository Discovery allowlist through
|
||||
`commitFilesGoverned`.
|
||||
14. Reconcile ambiguous outcomes before any retry.
|
||||
Admission uses a per-envelope, crash-releasing SQLite lock under the
|
||||
admission state path's `.locks` directory. It persists the immutable
|
||||
grant before an effect, then the official Task API GET reconciles any
|
||||
crashed or ambiguous effect before a POST is permitted.
|
||||
15. Replays revalidate the supplied proof, policy, expiry and revocation before
|
||||
reading a receipt. A different otherwise-valid proof conflicts with the
|
||||
persisted authorization digest; it never becomes a harmless receipt replay.
|
||||
16. The Task API adapter must receive a server-resolved trusted execution plan
|
||||
for the signed execution digest. It supplies `metadata.execution` with
|
||||
the exact repository, branch, generation, digest and nonempty external job
|
||||
identifier. Do not derive a job identity from request JSON or generate one.
|
||||
17. Require Authority / remote Git / API OID equality and idempotent replay.
|
||||
18. Publish immutable staging artifact and run Repository Discovery live E2E.
|
||||
|
||||
## Never do
|
||||
|
||||
- direct Task DB inserts/updates;
|
||||
- direct bare-repo/ref changes;
|
||||
- raw Git push as a substitute for governed publication;
|
||||
- borrow SSH/Gitea/service credentials as Owner authorization;
|
||||
- disable TaskPrincipal verification;
|
||||
- issue TaskPrincipal without a live TaskLease;
|
||||
- set global writes true;
|
||||
- turn 18893 into a writer;
|
||||
- include production/tag/baseline/main permissions in a task grant.
|
||||
|
||||
## Acceptance
|
||||
|
||||
Bootstrap repair:
|
||||
|
||||
```text
|
||||
ENVELOPE_EXACT_REPLAY=PASS
|
||||
SCOPE_NARROWING=PASS
|
||||
DELEGATION_REVOKE_EXPIRY=PASS
|
||||
OWNER_SIGNATURE_VERIFY=PASS
|
||||
CONCURRENT_ADMISSION_SINGLE_TASK=PASS
|
||||
TASK_DB_DIRECT_WRITE=false
|
||||
TASKPRINCIPAL_BYPASS=false
|
||||
GLOBAL_WRITES=false
|
||||
CRASH_AFTER_EFFECT_REPLAY=PASS
|
||||
CRASH_BEFORE_EFFECT_REPLAY=PASS
|
||||
CHANGED_VALID_PROOF_CONFLICT=PASS
|
||||
TASK_API_EXACT_IDENTITY=PASS
|
||||
```
|
||||
|
||||
These are implementation and test acceptance conditions only. They are not
|
||||
evidence of a live claim, governed publication, remote OID equality, or staging
|
||||
E2E execution; obtain those independently through the sanctioned live runbook.
|
||||
|
||||
Repository Discovery publication:
|
||||
|
||||
```text
|
||||
TASK_CREATED=PASS
|
||||
TASK_CLAIMED=PASS
|
||||
TASK_HEARTBEAT=PASS
|
||||
TASK_LEASE=PASS
|
||||
TASK_PRINCIPAL=PASS
|
||||
WRITER_LEASE=PASS
|
||||
COMMIT_FILES_GOVERNED=PASS
|
||||
AUTHORITY_REMOTE_API_OID_EQUAL=PASS
|
||||
IDEMPOTENCY_REPLAY=PASS
|
||||
STAGING_LIVE_E2E=PASS
|
||||
TASK_VERIFIED=SUCCEEDED
|
||||
```
|
||||
@@ -0,0 +1,32 @@
|
||||
# Task Lineage Supersession P0 Runbook
|
||||
|
||||
## 范围锁定
|
||||
|
||||
只允许 source `TASK-SINGLEHOST-TRUSTROOT-P0-20260904`、ordinal `1` 与 child
|
||||
`TASK-SINGLEHOST-TRUSTROOT-P0-20260904-S1`。每次 live 操作前从 Task API/DB 的只读正式路径
|
||||
重读 source:必须是 `EXPIRED`、attempt 0、无 claim/lease,并重新计算正式 identity digest。
|
||||
同时确认 repository、branch、generation、execution digest、external job 与 source metadata
|
||||
digest 均为 decision 的 exact 值,确认当前 Local Owner Authority 和 global/legacy writes 状态。
|
||||
|
||||
## 实施和测试
|
||||
|
||||
1. 在专用 staging worktree 完成 controller、API wrapper、migration、child ClaimGuard binding、
|
||||
ADR/runbook 和 isolated SQLite tests。
|
||||
2. 覆盖错误 signer/namespace、旧 signer、所有 source CAS 字段漂移、nonce/replay、TTL、
|
||||
child/payload injection、existing child/lineage、projection/ClaimGuard compatibility 的失败路径。
|
||||
3. 验证失败后 source 的字节等价快照、child 不存在、没有 lineage/receipt/event 残留。
|
||||
4. 在 backup/isolated DB 跑 complete dry-run:supersession -> child rev1 -> Initial Projection ->
|
||||
需要时 Supervisor Projection -> immutable child ClaimGuard binding -> guarded claim attempt 1。
|
||||
|
||||
## Staging 激活与 live 操作
|
||||
|
||||
仅在测试与 dry-run 全部 PASS 后,将新 API overlay 放在 Task API `PYTHONPATH` 最前并切换到
|
||||
lineage wrapper;daemon-reload/restart 仅限非生产 Task API/projection/ClaimGuard 服务。验证
|
||||
health 及固定 supersession route 后,再次重读 live source 并通过当前 Owner authority 签发一份
|
||||
短 TTL、one-time decision。只调用一次正式 endpoint;不得用 sqlite、maintenance shell、
|
||||
direct Git ref 或任意签名接口替代。
|
||||
|
||||
## 后续和停止条件
|
||||
|
||||
成功后只沿 child lineage:Initial Projection、必要的 Supervisor Projection、新 immutable
|
||||
ClaimGuard binding、worker `p0-task-principal-v15-20260830` 的 guarded claim、governed execution。
|
||||
@@ -0,0 +1,35 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Bridge v2.1 Durable Reconciliation Bootstrap
|
||||
|
||||
This is the planning-only bootstrap artifact for
|
||||
`TASK-P0-BRIDGE-V21-DURABLE-RECONCILIATION-20260809`.
|
||||
|
||||
The task is bound to the exact base commit
|
||||
`3f670c387692b8865a10d5b419468d8a0b64d7a4` and the development branch
|
||||
`agent/p0-bridge-v21-durable-reconciliation-20260809`.
|
||||
|
||||
The first governed commit records the design boundary and acceptance contract
|
||||
only. It does not implement the v2.1 body, mutate main, create tags, change
|
||||
Baseline, or touch production. Future work must preserve absent-ref CAS,
|
||||
observation-first recovery, durable JobStore state, exact Authority/Gitea/API
|
||||
reconciliation, and the existing secret-scanning and credential-projection
|
||||
boundaries.
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
# ChatGPT Bridge v2 Control Plane Convergence Design
|
||||
|
||||
Date: 2026-08-09
|
||||
Task: `TASK-P0-CHATGPT-BRIDGE-V2-CONTROL-PLANE-CONVERGENCE-20260809`
|
||||
Repository: `aios/aios`
|
||||
Approved development base: `2e8fa22989808a448065d1d76b5b18948774a637`
|
||||
Target branch: `agent/p0-chatgpt-bridge-v2-control-plane-convergence-20260809`
|
||||
|
||||
## Scope and authority
|
||||
|
||||
This design converges the ChatGPT GitOps Bridge, the existing Authorized Task Writer, the continuation lease protocol, the Durable GitOps Job Controller, and immutable control-plane runtime identity.
|
||||
|
||||
The development base is exact and must never be replaced with a dynamically resolved `HEAD`, `latest`, or `main`. If Authority `refs/heads/main` differs from the approved OID before the first governed writer push, execution fails with `BASE_OID_CHANGED`.
|
||||
|
||||
This task does not authorize production changes. The following remain immutable:
|
||||
|
||||
- Production Baseline `V2R1C7`.
|
||||
- Production target OID `c7adece1cc8beffe7c718d53c775898b45d6c7f8`.
|
||||
- Production tag OID `82792347937c0df59a8e02d6162775252bcad2e2`.
|
||||
- Production Registry, services, deployment state, runtime, tags, and release evidence.
|
||||
- Authority `main`.
|
||||
|
||||
Force push, history rewrite, direct main push, production mutation, tag mutation, baseline mutation, and secret commits are always denied.
|
||||
|
||||
## Immutable control-plane runtime identity
|
||||
|
||||
A control-plane release is installed into a versioned immutable directory equivalent to:
|
||||
|
||||
```text
|
||||
/opt/aios/gitops-control-plane/<release-id-or-oid>/
|
||||
```
|
||||
|
||||
The release manifest binds:
|
||||
|
||||
```text
|
||||
control_plane_release_version
|
||||
control_plane_target_oid
|
||||
artifact_sha256
|
||||
runtime_path
|
||||
bridge_schema_version
|
||||
repository_identity
|
||||
authoritative_git_dir
|
||||
deployment_state
|
||||
```
|
||||
|
||||
The validator is independent from the mutable Git workspace. It rejects execution when the runtime path is a workspace/worktree, the observed runtime commit or artifact digest differs from the manifest, the repository identity is not `aios/aios`, the authoritative Git directory is absent or mismatched, or the Bridge schema version is absent or unsupported.
|
||||
|
||||
Every failure is fail closed with `CONTROL_PLANE_RUNTIME_DRIFT`. No fallback to a mutable workspace is permitted. The installer and systemd template are staging-safe; this task may install and restart only a staging/test service.
|
||||
|
||||
## Bridge v2 adapter
|
||||
|
||||
The Bridge schema identity is `aios.chatgpt-gitops-bridge.v2`. It is independent from the production baseline, control-plane release version, and runtime release commit.
|
||||
|
||||
The Bridge exposes:
|
||||
|
||||
- `getRuntimeStatus`
|
||||
- `getGovernedWriteCapabilities`
|
||||
- `createGovernedTask`
|
||||
- `acquireWriterLease`
|
||||
- `commitFileGoverned`
|
||||
- `commitFilesGoverned`
|
||||
- `getGitOpsJob`
|
||||
- `resumeGitOpsJob`
|
||||
- `reconcileGitOpsJob`
|
||||
|
||||
The adapter reuses existing mechanisms and does not create a second writer, lease protocol, branch state store, job database, or direct Git mutation path.
|
||||
|
||||
## Governed task and lease flow
|
||||
|
||||
`createGovernedTask` binds an exact task ID, repository, approved base OID, non-main branch, risk, and description. Existing branches are read back and bound to their observed full OID. Idempotent replay returns the original binding; conflicting replay fails closed.
|
||||
|
||||
`acquireWriterLease` uses `WriterLeaseRequestV2`. Continuation uses:
|
||||
|
||||
```text
|
||||
branch_mode=CONTINUE_EXISTING
|
||||
expected_branch_oid=<full OID>
|
||||
generation=<monotonic integer>
|
||||
create_target_branch=false
|
||||
worktree_mode=detached_at_expected_head
|
||||
cas_ref_update_required=true
|
||||
```
|
||||
|
||||
Credentials remain server-side and never appear in responses, Git, durable job metadata, or logs.
|
||||
|
||||
## Governed commit flow
|
||||
|
||||
`commitFileGoverned` and `commitFilesGoverned` submit work to the existing `GitOpsJobController` and `JobStore`. The server computes payload digests. Raw file content is stored only in the content-addressed payload domain. Secret scanning, protected-path policy, repository binding, lease validation, and branch authorization run before side effects.
|
||||
|
||||
`commitFilesGoverned` is one atomic Git commit and enforces bounded file count, per-file size, and total payload size. Any failure leaves no partial commit or ref update.
|
||||
|
||||
All ref updates use compare-and-swap. Stale `expected_branch_oid` returns conflict and never triggers automatic rebase. After push, Authority and remote Gitea refs are independently read and must equal the resulting commit OID.
|
||||
|
||||
## Durable job lifecycle
|
||||
|
||||
Job reads return only non-sensitive metadata: `job_id`, `state`, `revision`, `side_effect_state`, `retryable`, `binding`, and `receipt_summary`.
|
||||
|
||||
Terminal jobs cannot be changed by ordinary resume. Resume requires `job_id` and `expected_revision` and uses revision CAS. Reconciliation is observation-first: for `UNKNOWN`, `RECONCILIATION_REQUIRED`, or `REMOTE_PUSH_PENDING`, local, Authority, and remote refs are read before state changes. A blind repeated push is never reconciliation.
|
||||
|
||||
## Legacy behavior
|
||||
|
||||
Legacy writes remain disabled while governed writes are enabled:
|
||||
|
||||
```text
|
||||
legacy_writes_enabled=false
|
||||
governed_writes_enabled=true
|
||||
```
|
||||
|
||||
The global `AIOS_GITOPS_WRITES_ENABLED` flag is never enabled as the solution. Legacy operations remain temporarily visible for compatibility and diagnostics, but return:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "LEGACY_WRITE_DISABLED",
|
||||
"replacement": "createGovernedTask",
|
||||
"governed_writes_available": true
|
||||
}
|
||||
```
|
||||
|
||||
## OpenAPI convergence
|
||||
|
||||
`docs/gitops/openapi-chatgpt-actions.yaml` is the canonical Bridge v2 schema and integrates the Authorized Task Writer fragment. The canonical CAS field is `expected_branch_oid`. `expected_head_oid` may be accepted only at an explicit deprecated adapter boundary.
|
||||
|
||||
Contract tests require exact equality between OpenAPI required fields, runtime parser required fields, and generated GPT Action schema required fields.
|
||||
|
||||
## Acceptance
|
||||
|
||||
Automated coverage includes runtime path/OID/digest/repository drift, exact task/branch/OID/lease/CAS behavior, job DB failure, restart during remote verification, remote timeout, response loss after remote success, idempotency replay/conflict, concurrent branch movement, unauthorized or main branch writes, protected paths, secret-like payloads, disabled governed writer, missing/expired lease, wrong repository identity, and observation-first reconciliation.
|
||||
|
||||
The staging sequence is:
|
||||
|
||||
```text
|
||||
getRuntimeStatus
|
||||
getGovernedWriteCapabilities
|
||||
createGovernedTask
|
||||
acquireWriterLease
|
||||
commitFilesGoverned
|
||||
getGitOpsJob
|
||||
verify Authority branch ref
|
||||
verify remote Gitea branch ref
|
||||
restart staging control-plane service
|
||||
getRuntimeStatus
|
||||
acquire continuation lease
|
||||
second governed commit
|
||||
verify remote ref again
|
||||
```
|
||||
|
||||
Completion requires all tests, staging restart continuation, Authority/remote OID equality, unchanged production invariants, no force push, no tag rewrite, no direct main push, and no secret commit.
|
||||
|
||||
## Implementation boundaries
|
||||
|
||||
Implementation is limited to the Bridge, existing writer and job-controller adapters, runtime identity/installer, staging systemd template, OpenAPI documents, tests, and engineering evidence. Hermes Adapter integration and production cutover are outside this task and require separate approval.
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
umask 027
|
||||
|
||||
ROOT="/data/aios-gitops"
|
||||
NGINX_SITE="/etc/nginx/sites-enabled/aios-user-portal.conf"
|
||||
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
BACKUP_DIR="$ROOT/backups/action-surface-final-$STAMP"
|
||||
LOG="$ROOT/logs/action-surface-final-$STAMP.log"
|
||||
|
||||
mkdir -p "$BACKUP_DIR" "$ROOT/logs"
|
||||
exec > >(tee -a "$LOG") 2>&1
|
||||
|
||||
sudo cp -a "$NGINX_SITE" "$BACKUP_DIR/aios-user-portal.conf"
|
||||
SITE_BACKUP="$BACKUP_DIR/aios-user-portal.conf"
|
||||
|
||||
python3 - "$SITE_BACKUP" "$BACKUP_DIR/aios-user-portal.conf.next" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
source, output = map(Path, sys.argv[1:])
|
||||
text = source.read_text(encoding="utf-8")
|
||||
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 {
|
||||
proxy_pass http://127.0.0.1:18890/api/governed-write-capabilities;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
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;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header X-AIOS-Token $http_x_aios_token;
|
||||
}
|
||||
location = /api/control-plane-snapshot {
|
||||
proxy_pass http://127.0.0.1:18890/api/control-plane-snapshot;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
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;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header X-AIOS-Token $http_x_aios_token;
|
||||
}
|
||||
location = /api/online-agent-task {
|
||||
proxy_pass http://127.0.0.1:18890/api/online-agent-task;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
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;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header X-AIOS-Token $http_x_aios_token;
|
||||
}
|
||||
location = /api/online-agent-task-events {
|
||||
proxy_pass http://127.0.0.1:18890/api/online-agent-task-events;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
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;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header X-AIOS-Token $http_x_aios_token;
|
||||
}
|
||||
'''
|
||||
needle = " location /api/ {"
|
||||
if text.count(needle) != 1:
|
||||
raise SystemExit("expected exactly one generic /api/ location")
|
||||
text = text.replace(needle, block + "\n" + needle, 1)
|
||||
output.write_text(text, encoding="utf-8")
|
||||
PY
|
||||
|
||||
sudo cp "$BACKUP_DIR/aios-user-portal.conf.next" "$NGINX_SITE"
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
|
||||
printf 'ACTION_SURFACE_FINAL_OK backup=%s log=%s\n' "$BACKUP_DIR" "$LOG"
|
||||
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
addopts = -ra --import-mode=importlib
|
||||
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"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,21 @@
|
||||
"""Canonical AIOS release-version parser shared by runtime and release tools."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
|
||||
# The suffix preserves already-supported annotated release names while the
|
||||
# canonical VnRnCn namespace remains accepted exactly.
|
||||
RELEASE_VERSION_PATTERN = r"^V[0-9]+R[0-9]+C[0-9]+(?:-[A-Za-z0-9_.-]+)?$"
|
||||
RELEASE_VERSION_RE = re.compile(RELEASE_VERSION_PATTERN)
|
||||
|
||||
|
||||
def parse_release_version(version: object) -> str:
|
||||
value = str(version or "").strip()
|
||||
if not RELEASE_VERSION_RE.fullmatch(value):
|
||||
raise ValueError("invalid release version")
|
||||
return value
|
||||
|
||||
|
||||
def validate_release_version(version: object) -> str:
|
||||
return parse_release_version(version)
|
||||
@@ -0,0 +1,243 @@
|
||||
{
|
||||
"authority_proof": {
|
||||
"branch_oid": "2c6293160ed567a2098715edbb296741577c5bff",
|
||||
"commit_visible": true,
|
||||
"main_oid": "2e8fa22989808a448065d1d76b5b18948774a637",
|
||||
"v2r2c1_tag_oid": "998d30304787e31e5ae607c0bd86b5edb2b0e5ad"
|
||||
},
|
||||
"baseline_authority": {
|
||||
"artifact_sha256": "492338b895b152a55015fd92ea1d9dba2314894ab00c0ba219a78f3cef544b8a",
|
||||
"authority_sha256": "d8e23bad49bdb3e4192ba708c3dfe7fc5b7cdc6a513c2f87acd6412a51867231",
|
||||
"current_baseline": "V2R2C1",
|
||||
"raw_byte_sidecar_verification": "PASS",
|
||||
"target_oid": "998d30304787e31e5ae607c0bd86b5edb2b0e5ad"
|
||||
},
|
||||
"control_plane_source_oid": "b781129bd5a8b53c05ab648361424d2800166ee4",
|
||||
"diff_stat": "active-runtime/app/aios_authority_git_executor.py | 24 +\n active-runtime/app/aios_chatgpt_actions_api.py | 48 +-\n active-runtime/app/aios_chatgpt_bridge_v2.py | 167 ++++\n .../app/aios_chatgpt_bridge_v2_server.py | 431 +++++++++\n active-runtime/app/aios_continuation_executor.py | 594 ++++++++++++\n active-runtime/app/aios_continuation_lease.py | 6 +\n active-runtime/app/aios_control_plane_snapshot.py | 26 +\n active-runtime/app/aios_credential_broker.py | 78 ++\n active-runtime/app/aios_gitea_publisher.py | 93 ++\n active-runtime/app/aios_gitops_job_controller.py | 16 +-\n .../app/aios_gitops_reconciliation_core.py | 80 ++\n active-runtime/app/aios_governed_policy.py | 59 ++\n active-runtime/app/aios_governed_task_bootstrap.py | 84 ++\n active-runtime/app/aios_model_api_staging.py | 1022 ++++++++++++++++++++\n .../app/authorized_task_writer_provisioning.py | 4 +-\n active-runtime/app/baseline_authority.py | 84 ++\n active-runtime/app/governed_production_release.py | 568 +++++++++++\n active-runtime/app/v2r2c1_production_adapter.py | 135 +++\n .../systemd/aios-chatgpt-bridge-v2-staging.service | 23 +\n .../aios-gitops-model-api-v2r2c1-staging.service | 51 +\n ...09-bridge-v2.1-durable-reconciliation-design.md | 15 +\n ...t-bridge-v2-control-plane-convergence-design.md | 150 +++\n pytest.ini | 4 +\n scripts/build_v2r2c1_model_api_staging.py | 145 +++\n .../aios_release_controller/manifest.py | 4 +-\n .../test_v2r1c6_release_consistency.py | 4 +-\n .../aios_v2_candidate/test_v2r1c6_release_ready.py | 16 +-\n tests/conftest.py | 65 ++\n tests/test_bridge_v2_governed_http_bootstrap.py | 90 ++\n tests/test_governed_local_writer.py | 6 +-\n tests/test_model_api_immutable_staging.py | 35 +\n 31 files changed, 4108 insertions(+), 19 deletions(-)",
|
||||
"evidence_closure_oid": "a8b906d1bc24088c19647e98de6a679268f6d0a2",
|
||||
"forbidden_operations": [
|
||||
"direct_main_push",
|
||||
"main_merge_without_owner_approval",
|
||||
"force_push",
|
||||
"tag_mutation",
|
||||
"baseline_mutation",
|
||||
"registry_mutation",
|
||||
"production_restart",
|
||||
"production_cutover",
|
||||
"live_model_api_cutover",
|
||||
"history_rewrite",
|
||||
"secret_commit"
|
||||
],
|
||||
"governance": {
|
||||
"baseline_modified": false,
|
||||
"direct_main_push": false,
|
||||
"force_push_used": false,
|
||||
"history_rewritten": false,
|
||||
"live_model_api_cutover": false,
|
||||
"main_merged": false,
|
||||
"owner_approval_respected": true,
|
||||
"production_changed": false,
|
||||
"production_restarted": false,
|
||||
"registry_modified": false,
|
||||
"secrets_committed": false,
|
||||
"tag_rewritten": false
|
||||
},
|
||||
"included_path_count": 31,
|
||||
"integration_branch": "agent/v2r2c1-post-release-mainline-convergence-20260809",
|
||||
"integration_head_oid": "2c6293160ed567a2098715edbb296741577c5bff",
|
||||
"logical_commits": [
|
||||
{
|
||||
"message": "feat(v2r2c1): converge durable control-plane core",
|
||||
"oid": "40b685d1c70c767c7a79a2b184348e58e3e319ae",
|
||||
"parent_oid": "2e8fa22989808a448065d1d76b5b18948774a637"
|
||||
},
|
||||
{
|
||||
"message": "docs(v2r2c1): converge durable runtime and design sources",
|
||||
"oid": "5f616311bab38b0dac296ed8b0e69660e031a20e",
|
||||
"parent_oid": "40b685d1c70c767c7a79a2b184348e58e3e319ae"
|
||||
},
|
||||
{
|
||||
"message": "feat(v2r2c1): integrate governed release overlay",
|
||||
"oid": "57780e316e320a5128cc2c9f12adbb19ae57aef5",
|
||||
"parent_oid": "5f616311bab38b0dac296ed8b0e69660e031a20e"
|
||||
},
|
||||
{
|
||||
"message": "feat(v2r2c1): wire governed release operations into durable bridge",
|
||||
"oid": "e41b8d323a3e7a40440f5d32221f524074bea5fe",
|
||||
"parent_oid": "57780e316e320a5128cc2c9f12adbb19ae57aef5"
|
||||
},
|
||||
{
|
||||
"message": "test: repair canonical pytest repository entrypoint",
|
||||
"oid": "b20cb8130e4c68267d5d85b02eed51cb8d4f41c0",
|
||||
"parent_oid": "e41b8d323a3e7a40440f5d32221f524074bea5fe"
|
||||
},
|
||||
{
|
||||
"message": "test: make whole-suite collection deterministic",
|
||||
"oid": "c6a1693efaede9dace9b929d884a09e162a02c89",
|
||||
"parent_oid": "b20cb8130e4c68267d5d85b02eed51cb8d4f41c0"
|
||||
},
|
||||
{
|
||||
"message": "test: preserve legacy suite imports under canonical collection",
|
||||
"oid": "91f2ea0591787d3dbf8c2e27ab0460629d86900e",
|
||||
"parent_oid": "c6a1693efaede9dace9b929d884a09e162a02c89"
|
||||
},
|
||||
{
|
||||
"message": "test: expose repository test helpers to canonical collection",
|
||||
"oid": "85897d3f6d708271dff16767265f9d14b2f29801",
|
||||
"parent_oid": "91f2ea0591787d3dbf8c2e27ab0460629d86900e"
|
||||
},
|
||||
{
|
||||
"message": "fix(v2r2c1): close slash-branch and suite contract regressions",
|
||||
"oid": "f1110d24f30d8441f2df04e0774ee230b95c2e02",
|
||||
"parent_oid": "85897d3f6d708271dff16767265f9d14b2f29801"
|
||||
},
|
||||
{
|
||||
"message": "test: align writer regression with durable idempotent replay",
|
||||
"oid": "c10d2a5dfcc394dde7a7022952d7b423725747a3",
|
||||
"parent_oid": "f1110d24f30d8441f2df04e0774ee230b95c2e02"
|
||||
},
|
||||
{
|
||||
"message": "feat(v2r2c1): source baseline authority for model-api staging",
|
||||
"oid": "f969aedaf61501a866435c712be01ff2cfe43fb1",
|
||||
"parent_oid": "c10d2a5dfcc394dde7a7022952d7b423725747a3"
|
||||
},
|
||||
{
|
||||
"message": "feat(v2r2c1): add immutable model api staging runtime",
|
||||
"oid": "4ab70bfdb4d34aa9d1dabc4230a3367eaa93cc9b",
|
||||
"parent_oid": "f969aedaf61501a866435c712be01ff2cfe43fb1"
|
||||
},
|
||||
{
|
||||
"message": "fix(v2r2c1): avoid builder secret-scan false positives",
|
||||
"oid": "7c4e86bfc1f39e643e672968d1fdedbd72ace56d",
|
||||
"parent_oid": "4ab70bfdb4d34aa9d1dabc4230a3367eaa93cc9b"
|
||||
},
|
||||
{
|
||||
"message": "fix(v2r2c1): isolate model api staging port",
|
||||
"oid": "7564363a30bfa7fa81ac5c7c545ec704984ec543",
|
||||
"parent_oid": "7c4e86bfc1f39e643e672968d1fdedbd72ace56d"
|
||||
},
|
||||
{
|
||||
"message": "fix(v2r2c1): bind baseline authority and openapi staging path",
|
||||
"oid": "2c6293160ed567a2098715edbb296741577c5bff",
|
||||
"parent_oid": "7564363a30bfa7fa81ac5c7c545ec704984ec543"
|
||||
}
|
||||
],
|
||||
"main_base_oid": "2e8fa22989808a448065d1d76b5b18948774a637",
|
||||
"mandatory_gates": {
|
||||
"baseline_registry_readback_staging_pass": true,
|
||||
"durable_source_path_manifest_complete": true,
|
||||
"existing_v2r2c1_suite_pass": true,
|
||||
"governed_release_suite_pass": true,
|
||||
"integration_authority_git_api_equal": true,
|
||||
"integration_branch_governed": true,
|
||||
"logical_slice_commits_remote_verified": true,
|
||||
"main_base_exact_before_branch_create": true,
|
||||
"model_api_immutable_runtime_staging_pass": true,
|
||||
"owner_merge_request_generated_execute_false": true,
|
||||
"production_unchanged": true,
|
||||
"pytest_collection_entrypoints_fixed": true,
|
||||
"pytest_tests_results_recorded": true
|
||||
},
|
||||
"model_api_staging": {
|
||||
"artifact_sha256": "492338b895b152a55015fd92ea1d9dba2314894ab00c0ba219a78f3cef544b8a",
|
||||
"baseline": {
|
||||
"authority_sha256": "d8e23bad49bdb3e4192ba708c3dfe7fc5b7cdc6a513c2f87acd6412a51867231",
|
||||
"current_baseline": "V2R2C1",
|
||||
"deployment_state": "ACTIVE",
|
||||
"evidence_closure_oid": "a8b906d1bc24088c19647e98de6a679268f6d0a2",
|
||||
"generation": 4,
|
||||
"previous_baseline": "V2R1C7",
|
||||
"production_deployed": true,
|
||||
"status": "PRODUCTION_DEPLOYED",
|
||||
"tag_oid": "07fc2746b688f5f243f29b39de39f32edc3dd9d2",
|
||||
"target_oid": "998d30304787e31e5ae607c0bd86b5edb2b0e5ad"
|
||||
},
|
||||
"baseline_status": 200,
|
||||
"deployment_state": "STAGING",
|
||||
"health": {
|
||||
"artifact_sha256": "492338b895b152a55015fd92ea1d9dba2314894ab00c0ba219a78f3cef544b8a",
|
||||
"commit_oid": "2c6293160ed567a2098715edbb296741577c5bff",
|
||||
"deployment_state": "STAGING",
|
||||
"governed_release_routing": "bridge-v2",
|
||||
"legacy_global_writes_enabled": false,
|
||||
"production_deployed": false,
|
||||
"runtime_mode": "IMMUTABLE_STAGING",
|
||||
"status": "ok",
|
||||
"version": "V2R2C1"
|
||||
},
|
||||
"health_status": 200,
|
||||
"legacy_write_probe": "423_BLOCKED",
|
||||
"port": 18893,
|
||||
"restart_acceptance": "PASS",
|
||||
"runtime_root": "/opt/aios/gitops-model-api/2c6293160ed567a2098715edbb296741577c5bff/runtime",
|
||||
"source_oid": "2c6293160ed567a2098715edbb296741577c5bff",
|
||||
"systemd": "MainPID=788782\nExecStart={ path=/usr/bin/python3 ; argv[]=/usr/bin/python3 /opt/aios/gitops-model-api/2c6293160ed567a2098715edbb296741577c5bff/runtime/app/aios_model_api_staging.py ; ignore_errors=no ; start_time=[Sun 2026-08-09 19:11:19 CST] ; stop_time=[n/a] ; pid=788782 ; code=(null) ; status=0/0 }\nActiveState=active\nSubState=running",
|
||||
"unit": "aios-gitops-model-api-v2r2c1-staging.service",
|
||||
"versions": {
|
||||
"authoritative_head_oid": "2e8fa22989808a448065d1d76b5b18948774a637",
|
||||
"baseline_generation": 4,
|
||||
"baseline_sha256": "d8e23bad49bdb3e4192ba708c3dfe7fc5b7cdc6a513c2f87acd6412a51867231",
|
||||
"baseline_version": "V2R2C1",
|
||||
"query_error": null
|
||||
},
|
||||
"versions_status": 200
|
||||
},
|
||||
"owner_merge_request": {
|
||||
"control_plane_source_oid": "b781129bd5a8b53c05ab648361424d2800166ee4",
|
||||
"execute": false,
|
||||
"integration_head_oid": "b2cf510e11b712bb99f20ded81459b6524257394",
|
||||
"main_expected_old_oid": "2e8fa22989808a448065d1d76b5b18948774a637",
|
||||
"release_target_oid": "998d30304787e31e5ae607c0bd86b5edb2b0e5ad",
|
||||
"report_commit_oid": "b2cf510e11b712bb99f20ded81459b6524257394",
|
||||
"status": "OWNER_APPROVAL_REQUIRED"
|
||||
},
|
||||
"placeholder_fields": [],
|
||||
"production": {
|
||||
"baseline_mutation": false,
|
||||
"health": {
|
||||
"artifact_sha256": "ab4ae257346c82863f05abf5e309381a1c86612957608c3a4f4ab5cc9233f052",
|
||||
"commit_oid": "998d30304787e31e5ae607c0bd86b5edb2b0e5ad",
|
||||
"deployment_state": "ACTIVE",
|
||||
"health_timestamp": "2026-08-09T11:21:16.872581+00:00",
|
||||
"production_deployed": true,
|
||||
"status": "ok",
|
||||
"version": "V2R2C1"
|
||||
},
|
||||
"health_status": 200,
|
||||
"live_model_api_show": "MainPID=2816107\nActiveState=active\nSubState=running",
|
||||
"live_model_api_unchanged": true,
|
||||
"production_cutover": false,
|
||||
"production_restart": false,
|
||||
"registry_mutation": false,
|
||||
"shadow_service_unchanged": true,
|
||||
"shadow_show": "MainPID=1505658\nActiveState=active\nSubState=running",
|
||||
"tag_mutation": false
|
||||
},
|
||||
"release_target_oid": "998d30304787e31e5ae607c0bd86b5edb2b0e5ad",
|
||||
"remote_proof": {
|
||||
"api_branch_oid": "2c6293160ed567a2098715edbb296741577c5bff",
|
||||
"api_verification": "paginated_exact_name",
|
||||
"commit_visible": true,
|
||||
"git_remote_oid": "2c6293160ed567a2098715edbb296741577c5bff",
|
||||
"main_api_oid": "2e8fa22989808a448065d1d76b5b18948774a637",
|
||||
"main_git_oid": "2e8fa22989808a448065d1d76b5b18948774a637"
|
||||
},
|
||||
"report_commit_parent_oid": "2c6293160ed567a2098715edbb296741577c5bff",
|
||||
"repository": "aios/aios",
|
||||
"schema": "aios.v2r2c1.post-release-mainline-convergence.acceptance.v1",
|
||||
"status": "V2R2C1_MAINLINE_CONVERGENCE_READY_FOR_OWNER_MERGE",
|
||||
"success_status": "V2R2C1_MAINLINE_CONVERGENCE_READY_FOR_OWNER_MERGE",
|
||||
"task_id": "TASK-P0-V2R2C1-POST-RELEASE-MAINLINE-CONVERGENCE-20260809",
|
||||
"tests": {
|
||||
"collection": "PASS",
|
||||
"command": "pytest tests",
|
||||
"failed": 0,
|
||||
"passed": 709,
|
||||
"skipped": 2,
|
||||
"skipped_tests": [
|
||||
"tests/test_gitea_tag_publication_live.py (live opt-in)",
|
||||
"tests/test_signer_contract_compatibility.py (live integration opt-in)"
|
||||
],
|
||||
"subtests": 154
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
# V2R2C1 post-release mainline convergence diff summary
|
||||
|
||||
- task: `TASK-P0-V2R2C1-POST-RELEASE-MAINLINE-CONVERGENCE-20260809`
|
||||
- repository: `aios/aios`
|
||||
- main base: `2e8fa22989808a448065d1d76b5b18948774a637`
|
||||
- release target: `998d30304787e31e5ae607c0bd86b5edb2b0e5ad`
|
||||
- control-plane source: `b781129bd5a8b53c05ab648361424d2800166ee4`
|
||||
- implementation integration head before this report commit: `2c6293160ed567a2098715edbb296741577c5bff`
|
||||
- branch: `agent/v2r2c1-post-release-mainline-convergence-20260809`
|
||||
|
||||
## Logical governed commits
|
||||
|
||||
| OID | Parent | Message |
|
||||
|---|---|---|
|
||||
| `40b685d1c70c767c7a79a2b184348e58e3e319ae` | `2e8fa22989808a448065d1d76b5b18948774a637` | feat(v2r2c1): converge durable control-plane core |
|
||||
| `5f616311bab38b0dac296ed8b0e69660e031a20e` | `40b685d1c70c767c7a79a2b184348e58e3e319ae` | docs(v2r2c1): converge durable runtime and design sources |
|
||||
| `57780e316e320a5128cc2c9f12adbb19ae57aef5` | `5f616311bab38b0dac296ed8b0e69660e031a20e` | feat(v2r2c1): integrate governed release overlay |
|
||||
| `e41b8d323a3e7a40440f5d32221f524074bea5fe` | `57780e316e320a5128cc2c9f12adbb19ae57aef5` | feat(v2r2c1): wire governed release operations into durable bridge |
|
||||
| `b20cb8130e4c68267d5d85b02eed51cb8d4f41c0` | `e41b8d323a3e7a40440f5d32221f524074bea5fe` | test: repair canonical pytest repository entrypoint |
|
||||
| `c6a1693efaede9dace9b929d884a09e162a02c89` | `b20cb8130e4c68267d5d85b02eed51cb8d4f41c0` | test: make whole-suite collection deterministic |
|
||||
| `91f2ea0591787d3dbf8c2e27ab0460629d86900e` | `c6a1693efaede9dace9b929d884a09e162a02c89` | test: preserve legacy suite imports under canonical collection |
|
||||
| `85897d3f6d708271dff16767265f9d14b2f29801` | `91f2ea0591787d3dbf8c2e27ab0460629d86900e` | test: expose repository test helpers to canonical collection |
|
||||
| `f1110d24f30d8441f2df04e0774ee230b95c2e02` | `85897d3f6d708271dff16767265f9d14b2f29801` | fix(v2r2c1): close slash-branch and suite contract regressions |
|
||||
| `c10d2a5dfcc394dde7a7022952d7b423725747a3` | `f1110d24f30d8441f2df04e0774ee230b95c2e02` | test: align writer regression with durable idempotent replay |
|
||||
| `f969aedaf61501a866435c712be01ff2cfe43fb1` | `c10d2a5dfcc394dde7a7022952d7b423725747a3` | feat(v2r2c1): source baseline authority for model-api staging |
|
||||
| `4ab70bfdb4d34aa9d1dabc4230a3367eaa93cc9b` | `f969aedaf61501a866435c712be01ff2cfe43fb1` | feat(v2r2c1): add immutable model api staging runtime |
|
||||
| `7c4e86bfc1f39e643e672968d1fdedbd72ace56d` | `4ab70bfdb4d34aa9d1dabc4230a3367eaa93cc9b` | fix(v2r2c1): avoid builder secret-scan false positives |
|
||||
| `7564363a30bfa7fa81ac5c7c545ec704984ec543` | `7c4e86bfc1f39e643e672968d1fdedbd72ace56d` | fix(v2r2c1): isolate model api staging port |
|
||||
| `2c6293160ed567a2098715edbb296741577c5bff` | `7564363a30bfa7fa81ac5c7c545ec704984ec543` | fix(v2r2c1): bind baseline authority and openapi staging path |
|
||||
|
||||
## Exact diff stat
|
||||
|
||||
```text
|
||||
active-runtime/app/aios_authority_git_executor.py | 24 +
|
||||
active-runtime/app/aios_chatgpt_actions_api.py | 48 +-
|
||||
active-runtime/app/aios_chatgpt_bridge_v2.py | 167 ++++
|
||||
.../app/aios_chatgpt_bridge_v2_server.py | 431 +++++++++
|
||||
active-runtime/app/aios_continuation_executor.py | 594 ++++++++++++
|
||||
active-runtime/app/aios_continuation_lease.py | 6 +
|
||||
active-runtime/app/aios_control_plane_snapshot.py | 26 +
|
||||
active-runtime/app/aios_credential_broker.py | 78 ++
|
||||
active-runtime/app/aios_gitea_publisher.py | 93 ++
|
||||
active-runtime/app/aios_gitops_job_controller.py | 16 +-
|
||||
.../app/aios_gitops_reconciliation_core.py | 80 ++
|
||||
active-runtime/app/aios_governed_policy.py | 59 ++
|
||||
active-runtime/app/aios_governed_task_bootstrap.py | 84 ++
|
||||
active-runtime/app/aios_model_api_staging.py | 1022 ++++++++++++++++++++
|
||||
.../app/authorized_task_writer_provisioning.py | 4 +-
|
||||
active-runtime/app/baseline_authority.py | 84 ++
|
||||
active-runtime/app/governed_production_release.py | 568 +++++++++++
|
||||
active-runtime/app/v2r2c1_production_adapter.py | 135 +++
|
||||
.../systemd/aios-chatgpt-bridge-v2-staging.service | 23 +
|
||||
.../aios-gitops-model-api-v2r2c1-staging.service | 51 +
|
||||
...09-bridge-v2.1-durable-reconciliation-design.md | 15 +
|
||||
...t-bridge-v2-control-plane-convergence-design.md | 150 +++
|
||||
pytest.ini | 4 +
|
||||
scripts/build_v2r2c1_model_api_staging.py | 145 +++
|
||||
.../aios_release_controller/manifest.py | 4 +-
|
||||
.../test_v2r1c6_release_consistency.py | 4 +-
|
||||
.../aios_v2_candidate/test_v2r1c6_release_ready.py | 16 +-
|
||||
tests/conftest.py | 65 ++
|
||||
tests/test_bridge_v2_governed_http_bootstrap.py | 90 ++
|
||||
tests/test_governed_local_writer.py | 6 +-
|
||||
tests/test_model_api_immutable_staging.py | 35 +
|
||||
31 files changed, 4108 insertions(+), 19 deletions(-)
|
||||
```
|
||||
|
||||
## Included durable source domains
|
||||
|
||||
- Bridge v2 adapter/server and governed HTTP contracts.
|
||||
- Durable reconciliation core, continuation executor/lease, Authority Git CAS adapter, job controller compatibility.
|
||||
- Gitea publisher/verifier, credential broker, governed policy, control-plane snapshot, release routing.
|
||||
- Canonical pytest entrypoint/conftest and regression coverage.
|
||||
- Source-controlled immutable model-api staging runtime, builder and systemd template.
|
||||
- Selected design specifications required for durable mainline context.
|
||||
|
||||
## Explicitly excluded
|
||||
|
||||
- `main` ref mutation or merge without Owner approval.
|
||||
- Production service, Registry, Baseline body/sidecar, production tags and mutable `/var/lib` state.
|
||||
- Installed `/opt` artifacts, temporary staging transition state and task-local receipts/credentials.
|
||||
- Production cutover/restart, force push, history rewrite and plaintext secrets.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pytest tests`: **709 passed, 2 skipped, 154 subtests, 0 failed**.
|
||||
- Staging model-api: immutable OID/digest bound, port 18893, health/base/ver/OpenAPI readback PASS, restart recovery PASS.
|
||||
- Authority/Gitea Git/Gitea API commit equality and commit visibility PASS.
|
||||
- Production remains V2R2C1 and was not restarted or cut over.
|
||||
@@ -0,0 +1,254 @@
|
||||
{
|
||||
"control_plane_source_oid": "b781129bd5a8b53c05ab648361424d2800166ee4",
|
||||
"diff_stat": "active-runtime/app/aios_authority_git_executor.py | 24 +\n active-runtime/app/aios_chatgpt_actions_api.py | 48 +-\n active-runtime/app/aios_chatgpt_bridge_v2.py | 167 ++++\n .../app/aios_chatgpt_bridge_v2_server.py | 431 +++++++++\n active-runtime/app/aios_continuation_executor.py | 594 ++++++++++++\n active-runtime/app/aios_continuation_lease.py | 6 +\n active-runtime/app/aios_control_plane_snapshot.py | 26 +\n active-runtime/app/aios_credential_broker.py | 78 ++\n active-runtime/app/aios_gitea_publisher.py | 93 ++\n active-runtime/app/aios_gitops_job_controller.py | 16 +-\n .../app/aios_gitops_reconciliation_core.py | 80 ++\n active-runtime/app/aios_governed_policy.py | 59 ++\n active-runtime/app/aios_governed_task_bootstrap.py | 84 ++\n active-runtime/app/aios_model_api_staging.py | 1022 ++++++++++++++++++++\n .../app/authorized_task_writer_provisioning.py | 4 +-\n active-runtime/app/baseline_authority.py | 84 ++\n active-runtime/app/governed_production_release.py | 568 +++++++++++\n active-runtime/app/v2r2c1_production_adapter.py | 135 +++\n .../systemd/aios-chatgpt-bridge-v2-staging.service | 23 +\n .../aios-gitops-model-api-v2r2c1-staging.service | 51 +\n ...09-bridge-v2.1-durable-reconciliation-design.md | 15 +\n ...t-bridge-v2-control-plane-convergence-design.md | 150 +++\n pytest.ini | 4 +\n scripts/build_v2r2c1_model_api_staging.py | 145 +++\n .../aios_release_controller/manifest.py | 4 +-\n .../test_v2r1c6_release_consistency.py | 4 +-\n .../aios_v2_candidate/test_v2r1c6_release_ready.py | 16 +-\n tests/conftest.py | 65 ++\n tests/test_bridge_v2_governed_http_bootstrap.py | 90 ++\n tests/test_governed_local_writer.py | 6 +-\n tests/test_model_api_immutable_staging.py | 35 +\n 31 files changed, 4108 insertions(+), 19 deletions(-)",
|
||||
"evidence_closure_oid": "a8b906d1bc24088c19647e98de6a679268f6d0a2",
|
||||
"excluded_paths": [
|
||||
{
|
||||
"path": "refs/heads/main",
|
||||
"reason": "main merge requires separate Owner approval; unchanged"
|
||||
},
|
||||
{
|
||||
"path": "refs/tags/*",
|
||||
"reason": "formal tags are outside engineering convergence scope; unchanged"
|
||||
},
|
||||
{
|
||||
"path": "baseline/current.json and sidecar",
|
||||
"reason": "production mutable authority and sidecar; read-only verified"
|
||||
},
|
||||
{
|
||||
"path": "/var/lib/aios-gitops/**",
|
||||
"reason": "host runtime state and receipts excluded from repository source"
|
||||
},
|
||||
{
|
||||
"path": "/opt/aios/**",
|
||||
"reason": "installed immutable staging artifacts excluded; digest recorded in reports"
|
||||
},
|
||||
{
|
||||
"path": "credentials and secrets",
|
||||
"reason": "availability-only; no plaintext credential committed"
|
||||
},
|
||||
{
|
||||
"path": "production runtime and Registry",
|
||||
"reason": "production cutover/registry mutation forbidden"
|
||||
}
|
||||
],
|
||||
"included_paths": [
|
||||
{
|
||||
"path": "active-runtime/app/aios_authority_git_executor.py",
|
||||
"status": "M"
|
||||
},
|
||||
{
|
||||
"path": "active-runtime/app/aios_chatgpt_actions_api.py",
|
||||
"status": "M"
|
||||
},
|
||||
{
|
||||
"path": "active-runtime/app/aios_chatgpt_bridge_v2.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "active-runtime/app/aios_chatgpt_bridge_v2_server.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "active-runtime/app/aios_continuation_executor.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "active-runtime/app/aios_continuation_lease.py",
|
||||
"status": "M"
|
||||
},
|
||||
{
|
||||
"path": "active-runtime/app/aios_control_plane_snapshot.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "active-runtime/app/aios_credential_broker.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "active-runtime/app/aios_gitea_publisher.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "active-runtime/app/aios_gitops_job_controller.py",
|
||||
"status": "M"
|
||||
},
|
||||
{
|
||||
"path": "active-runtime/app/aios_gitops_reconciliation_core.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "active-runtime/app/aios_governed_policy.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "active-runtime/app/aios_governed_task_bootstrap.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "active-runtime/app/aios_model_api_staging.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "active-runtime/app/authorized_task_writer_provisioning.py",
|
||||
"status": "M"
|
||||
},
|
||||
{
|
||||
"path": "active-runtime/app/baseline_authority.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "active-runtime/app/governed_production_release.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "active-runtime/app/v2r2c1_production_adapter.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "deploy/systemd/aios-chatgpt-bridge-v2-staging.service",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "deploy/systemd/aios-gitops-model-api-v2r2c1-staging.service",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "docs/superpowers/specs/2026-08-09-bridge-v2.1-durable-reconciliation-design.md",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "docs/superpowers/specs/2026-08-09-chatgpt-bridge-v2-control-plane-convergence-design.md",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "pytest.ini",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "scripts/build_v2r2c1_model_api_staging.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "services/aios-release-controller/aios_release_controller/manifest.py",
|
||||
"status": "M"
|
||||
},
|
||||
{
|
||||
"path": "tests/aios_v2_candidate/test_v2r1c6_release_consistency.py",
|
||||
"status": "M"
|
||||
},
|
||||
{
|
||||
"path": "tests/aios_v2_candidate/test_v2r1c6_release_ready.py",
|
||||
"status": "M"
|
||||
},
|
||||
{
|
||||
"path": "tests/conftest.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "tests/test_bridge_v2_governed_http_bootstrap.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "tests/test_governed_local_writer.py",
|
||||
"status": "M"
|
||||
},
|
||||
{
|
||||
"path": "tests/test_model_api_immutable_staging.py",
|
||||
"status": "A"
|
||||
}
|
||||
],
|
||||
"integration_branch": "agent/v2r2c1-post-release-mainline-convergence-20260809",
|
||||
"integration_head_oid": "2c6293160ed567a2098715edbb296741577c5bff",
|
||||
"logical_commits": [
|
||||
{
|
||||
"message": "feat(v2r2c1): converge durable control-plane core",
|
||||
"oid": "40b685d1c70c767c7a79a2b184348e58e3e319ae",
|
||||
"parent_oid": "2e8fa22989808a448065d1d76b5b18948774a637"
|
||||
},
|
||||
{
|
||||
"message": "docs(v2r2c1): converge durable runtime and design sources",
|
||||
"oid": "5f616311bab38b0dac296ed8b0e69660e031a20e",
|
||||
"parent_oid": "40b685d1c70c767c7a79a2b184348e58e3e319ae"
|
||||
},
|
||||
{
|
||||
"message": "feat(v2r2c1): integrate governed release overlay",
|
||||
"oid": "57780e316e320a5128cc2c9f12adbb19ae57aef5",
|
||||
"parent_oid": "5f616311bab38b0dac296ed8b0e69660e031a20e"
|
||||
},
|
||||
{
|
||||
"message": "feat(v2r2c1): wire governed release operations into durable bridge",
|
||||
"oid": "e41b8d323a3e7a40440f5d32221f524074bea5fe",
|
||||
"parent_oid": "57780e316e320a5128cc2c9f12adbb19ae57aef5"
|
||||
},
|
||||
{
|
||||
"message": "test: repair canonical pytest repository entrypoint",
|
||||
"oid": "b20cb8130e4c68267d5d85b02eed51cb8d4f41c0",
|
||||
"parent_oid": "e41b8d323a3e7a40440f5d32221f524074bea5fe"
|
||||
},
|
||||
{
|
||||
"message": "test: make whole-suite collection deterministic",
|
||||
"oid": "c6a1693efaede9dace9b929d884a09e162a02c89",
|
||||
"parent_oid": "b20cb8130e4c68267d5d85b02eed51cb8d4f41c0"
|
||||
},
|
||||
{
|
||||
"message": "test: preserve legacy suite imports under canonical collection",
|
||||
"oid": "91f2ea0591787d3dbf8c2e27ab0460629d86900e",
|
||||
"parent_oid": "c6a1693efaede9dace9b929d884a09e162a02c89"
|
||||
},
|
||||
{
|
||||
"message": "test: expose repository test helpers to canonical collection",
|
||||
"oid": "85897d3f6d708271dff16767265f9d14b2f29801",
|
||||
"parent_oid": "91f2ea0591787d3dbf8c2e27ab0460629d86900e"
|
||||
},
|
||||
{
|
||||
"message": "fix(v2r2c1): close slash-branch and suite contract regressions",
|
||||
"oid": "f1110d24f30d8441f2df04e0774ee230b95c2e02",
|
||||
"parent_oid": "85897d3f6d708271dff16767265f9d14b2f29801"
|
||||
},
|
||||
{
|
||||
"message": "test: align writer regression with durable idempotent replay",
|
||||
"oid": "c10d2a5dfcc394dde7a7022952d7b423725747a3",
|
||||
"parent_oid": "f1110d24f30d8441f2df04e0774ee230b95c2e02"
|
||||
},
|
||||
{
|
||||
"message": "feat(v2r2c1): source baseline authority for model-api staging",
|
||||
"oid": "f969aedaf61501a866435c712be01ff2cfe43fb1",
|
||||
"parent_oid": "c10d2a5dfcc394dde7a7022952d7b423725747a3"
|
||||
},
|
||||
{
|
||||
"message": "feat(v2r2c1): add immutable model api staging runtime",
|
||||
"oid": "4ab70bfdb4d34aa9d1dabc4230a3367eaa93cc9b",
|
||||
"parent_oid": "f969aedaf61501a866435c712be01ff2cfe43fb1"
|
||||
},
|
||||
{
|
||||
"message": "fix(v2r2c1): avoid builder secret-scan false positives",
|
||||
"oid": "7c4e86bfc1f39e643e672968d1fdedbd72ace56d",
|
||||
"parent_oid": "4ab70bfdb4d34aa9d1dabc4230a3367eaa93cc9b"
|
||||
},
|
||||
{
|
||||
"message": "fix(v2r2c1): isolate model api staging port",
|
||||
"oid": "7564363a30bfa7fa81ac5c7c545ec704984ec543",
|
||||
"parent_oid": "7c4e86bfc1f39e643e672968d1fdedbd72ace56d"
|
||||
},
|
||||
{
|
||||
"message": "fix(v2r2c1): bind baseline authority and openapi staging path",
|
||||
"oid": "2c6293160ed567a2098715edbb296741577c5bff",
|
||||
"parent_oid": "7564363a30bfa7fa81ac5c7c545ec704984ec543"
|
||||
}
|
||||
],
|
||||
"main_base_oid": "2e8fa22989808a448065d1d76b5b18948774a637",
|
||||
"release_target_oid": "998d30304787e31e5ae607c0bd86b5edb2b0e5ad",
|
||||
"remote_proof": {
|
||||
"api_branch_oid": "2c6293160ed567a2098715edbb296741577c5bff",
|
||||
"api_verification": "paginated_exact_name",
|
||||
"commit_visible": true,
|
||||
"git_remote_oid": "2c6293160ed567a2098715edbb296741577c5bff",
|
||||
"main_api_oid": "2e8fa22989808a448065d1d76b5b18948774a637",
|
||||
"main_git_oid": "2e8fa22989808a448065d1d76b5b18948774a637"
|
||||
},
|
||||
"report_commit_parent_oid": "2c6293160ed567a2098715edbb296741577c5bff",
|
||||
"repository": "aios/aios",
|
||||
"schema": "aios.v2r2c1.post-release-mainline-convergence.path-manifest.v1",
|
||||
"task_id": "TASK-P0-V2R2C1-POST-RELEASE-MAINLINE-CONVERGENCE-20260809"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"artifact_sha256": "492338b895b152a55015fd92ea1d9dba2314894ab00c0ba219a78f3cef544b8a",
|
||||
"baseline_authority_path": "/var/lib/aios-gitops-runtime-anchor/baseline/current.json",
|
||||
"checks": {
|
||||
"artifact_sha256": "PASS",
|
||||
"baseline_registry_readback": "PASS",
|
||||
"health_contract": "PASS",
|
||||
"legacy_write_rejection": "PASS",
|
||||
"live_service_unchanged": "PASS",
|
||||
"manifest_binding": "PASS",
|
||||
"openapi_readback": "PASS",
|
||||
"restart_recovery": "PASS"
|
||||
},
|
||||
"current_live_port": 18890,
|
||||
"current_live_service": "aios-gitops-model-api.service",
|
||||
"deployment_state": "STAGING",
|
||||
"execute": false,
|
||||
"governed_release_routing": "bridge-v2",
|
||||
"legacy_global_writes_enabled": false,
|
||||
"live_cutover": false,
|
||||
"owner_actions_required": [
|
||||
"review exact source OID and artifact SHA",
|
||||
"approve a separate future live model-api cutover",
|
||||
"approve any production service transition"
|
||||
],
|
||||
"production_restart": false,
|
||||
"repository_identity": "aios/aios",
|
||||
"runtime_mode": "IMMUTABLE_STAGING",
|
||||
"schema": "aios.v2r2c1.model-api-runtime-activation-plan.v1",
|
||||
"source_oid": "2c6293160ed567a2098715edbb296741577c5bff",
|
||||
"staging_port": 18893,
|
||||
"staging_runtime_root": "/opt/aios/gitops-model-api/2c6293160ed567a2098715edbb296741577c5bff/runtime",
|
||||
"staging_service": "aios-gitops-model-api-v2r2c1-staging.service",
|
||||
"status": "OWNER_APPROVAL_REQUIRED",
|
||||
"target_version": "V2R2C1",
|
||||
"task_id": "TASK-P0-V2R2C1-POST-RELEASE-MAINLINE-CONVERGENCE-20260809"
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
# Action Contract Regression Remediation — 2026-08-25
|
||||
|
||||
## Scope
|
||||
|
||||
Governed task: `fix-action-contracts-20260825-1407`
|
||||
Base: `3244f0f17f8845592ac94d585f5ebd65693c9f29`
|
||||
Target branch: `agent/fix-action-contracts-20260825-1407`
|
||||
|
||||
## Verified regressions
|
||||
|
||||
1. `getReleaseConsistency` and `runReleaseAcceptance` fail before evaluation with:
|
||||
`Gitea API URL must be HTTP(S)`.
|
||||
V2R2C1 release evidence at `a8b906d1bc24088c19647e98de6a679268f6d0a2`
|
||||
proves Gitea API readback passed for target `998d30304787e31e5ae607c0bd86b5edb2b0e5ad`.
|
||||
Therefore current staging/contract overlay configuration has regressed.
|
||||
|
||||
2. `getReleaseSaga` fails with:
|
||||
`Permission denied: '/var/lib/aios-gitops/silent-release'`.
|
||||
Runtime service identity must have read/traverse access to the durable release state root.
|
||||
Fix ownership/group/ACL at deployment time; do not broaden to world-writable permissions.
|
||||
|
||||
3. `getTagPublicationStatus` rejects the authoritative approved version `V2R2C1`
|
||||
as `invalid release version`, while `getGovernedWriteCapabilities` returns the same
|
||||
version as the approved release scope. The two Action contracts are internally inconsistent.
|
||||
Release-version validation must accept the canonical AIOS `^V[0-9]+R[0-9]+C[0-9]+$`
|
||||
namespace (or, preferably, use one shared validator for all release Actions).
|
||||
|
||||
4. `commitFilesGoverned` is unusable from the published Action schema.
|
||||
Runtime capability metadata declares required fields:
|
||||
`task_id, repository, expected_branch_oid, target_branch, generation, idempotency_key, files, message`,
|
||||
but the exposed tool contract has no `files` input. Regenerate/publish the Action schema from
|
||||
the same operation contract source and add a contract-parity test.
|
||||
|
||||
5. `reconcileGitOpsJob` returned `DURABLE_BINDING_INVALID` for a completed governed
|
||||
single-file job that was otherwise read back as `COMPLETED`.
|
||||
Reconciliation of a valid terminal job should be idempotent/read-only and must not require
|
||||
a stale or absent active lease. Add a regression case for terminal completed jobs.
|
||||
|
||||
6. `getDiff` returns `ResponseTooLargeError` / `GIT_OUTPUT_LIMIT_EXCEEDED` for large diffs.
|
||||
Add server-side bounded output: path/stat summary plus truncation metadata, or pagination.
|
||||
The Action must never fail merely because a valid repository diff is large.
|
||||
|
||||
## Required implementation changes
|
||||
|
||||
- Centralize release-version parsing and reuse it in tag status, candidate, consistency, and acceptance.
|
||||
- Validate/normalize Gitea API URL at service startup and fail health checks with a configuration-specific diagnostic.
|
||||
- Ensure the deployment unit projects a valid HTTP(S) Gitea API base URL into the staging overlay.
|
||||
- Correct durable release-state directory ownership/ACL for the runtime service account.
|
||||
- Generate the external Action schema from the runtime operation contract; parity-test required fields.
|
||||
- Make terminal reconciliation idempotent and independent of expired active writer leases.
|
||||
- Bound diff output and return `truncated=true` with deterministic metadata rather than transport failure.
|
||||
|
||||
## Acceptance gates
|
||||
|
||||
All must pass on the same deployed overlay:
|
||||
|
||||
- `getReleaseConsistency(V2R2C1, 998d303..., a8b906d..., approved_plan)` returns a structured result, not URL error.
|
||||
- `runReleaseAcceptance(...)` reaches policy evaluation, not URL error.
|
||||
- `getTagPublicationStatus("V2R2C1")` returns structured publication state.
|
||||
- `getReleaseSaga(nonexistent_candidate)` returns NOT_FOUND/empty-state semantics, never filesystem permission failure.
|
||||
- Published `commitFilesGoverned` schema exposes `files` and a two-file isolated branch commit succeeds.
|
||||
- Reconcile of a completed governed job is idempotent.
|
||||
- Large diff returns bounded/truncated data rather than transport exception.
|
||||
- Existing protected-ref controls remain unchanged: no direct main/tag/baseline/production write.
|
||||
|
||||
## Proven stable reference
|
||||
|
||||
V2R2C1 evidence closure (`a8b906d1bc24088c19647e98de6a679268f6d0a2`) records:
|
||||
- Gitea Git/API readback PASS
|
||||
- staging runtime PASS
|
||||
- restart recovery PASS
|
||||
- 59 mandatory tests passed / 0 failed
|
||||
|
||||
This evidence should be used as the compatibility baseline when repairing the current
|
||||
`contract-overlay-v2` / `scheme-a-323b615b...` staging layer.
|
||||
@@ -0,0 +1,80 @@
|
||||
# AIOS Action contract repair evidence — 2026-08-25
|
||||
|
||||
## Disposition
|
||||
|
||||
Final disposition: `NEEDS_REWORK`.
|
||||
|
||||
The contract repair is deployed to staging and the focused acceptance checks are green. The task is not eligible for `SUCCEEDED`: `runReleaseAcceptance` returns structured policy output with `accepted=false` because the existing publication state for protected tag `V2R2C1` is `REMOTE_PUSH_PENDING`, and the repository-wide suite still has the two pre-existing signer/event-bus failures recorded below. The online Task API row is still `QUEUED`; it was not changed by direct database manipulation or an unauthorized synthetic claim/result.
|
||||
|
||||
No production release, `main`, release tag, baseline registry, or production service was directly modified.
|
||||
|
||||
## Scope and pre-change facts
|
||||
|
||||
- Repository: `aios/aios`; governed branch: `agent/fix-action-contracts-20260825-1407`.
|
||||
- Initial task branch HEAD: `35eec9afe1432e8466d60040b2fb1049bb41242a`.
|
||||
- Stable reference: `V2R2C1`; source commit `998d30304787e31e5ae607c0bd86b5edb2b0e5ad`; evidence closure `a8b906d1bc24088c19647e98de6a679268f6d0a2`.
|
||||
- Pre-change refs: authority `main=3244f0f17f8845592ac94d585f5ebd65693c9f29`, tag `V2R2C1=07fc2746b688f5f243f29b39de39f32edc3dd9d2`; these remained unchanged.
|
||||
- Staging services run as `User=aiman`, `Group=gitops`. The actual staging Gitea API base is `https://g.cnnetmkt.top/git/api/v1`, supplied by systemd environment rather than business-code host constants.
|
||||
- Pre-change full test baseline: `707 passed, 2 skipped, 154 subtests passed, 2 failed`. The failures were the stale `/tmp/aios-agent-events-test` event-bus fixture and the real signer self-test.
|
||||
|
||||
## Root causes and repairs
|
||||
|
||||
1. Gitea URL validation occurred at release-operation time while the staging overlay's API environment was not consistently projected into all runtime paths. The canonical HTTP(S) validator is now called during model and Bridge startup; `getReleaseConsistency` and `runReleaseAcceptance` use the actual staging environment URL.
|
||||
|
||||
2. `silent-release` was not consistently writable by the real service identity, and the credential helper parent directory blocked `aiman` traversal. The persisted staging systemd overlay creates the state directory as `aiman:gitops` mode `0750` and grants only `u:aiman:--x` on `/usr/local/libexec`; no world-writable mode was used. The staging bridge also has the minimum repository write path required by the governed AuthorityGitExecutor/CAS flow.
|
||||
|
||||
3. Release version parsing was split across callers and rejected `V2R2C1`. A shared canonical parser now accepts `^V[0-9]+R[0-9]+C[0-9]+$` plus the existing bounded suffix form, and is used by capabilities, release candidate, consistency, acceptance, tag publication, and release/tag queries.
|
||||
|
||||
4. The runtime operation contract required `files` but the published Action schema omitted it. `commitFilesGoverned` now has one contract source and published schema projection, with parity tests. A real governed two-file commit completed on isolated branch `agent/action-contract-integration-20260825-1407`; authority, Git remote, and Gitea API all read back OID `0345cd8c70967940bc8e9132dff7d6ea11efa304`.
|
||||
|
||||
5. `reconcileGitOpsJob` reconstructed active lease state before checking terminal state. Terminal `COMPLETED`, `FAILED`, and `CANCELLED` jobs now short-circuit to idempotent read-back; active jobs retain strict lease validation. The two-file job reconciled as `COMPLETED` and a second reconcile returned the same receipt.
|
||||
|
||||
6. `getDiff` returned unbounded patch data and could fail the transport. It now returns identities, changed paths, stats, total bytes, bounded patch output, patch limit, and `truncated`. The large smoke returned `total_size_bytes=350222`, `stdout_bytes=262144`, `patch_limit_bytes=262144`, `truncated=true`; no `ResponseTooLargeError` or `GIT_OUTPUT_LIMIT_EXCEEDED` escaped.
|
||||
|
||||
During live integration, two additional staging-only contract defects were found and repaired: CREATE_NEW branch normalization/CAS recovery, and release executor projection of the configured staging baseline plus immutable runtime `versions/V2R2C1.json`. The latter removed the missing publication-workspace baseline/version errors without writing production state.
|
||||
|
||||
## Source, test, and operational changes
|
||||
|
||||
Source commits on the governed branch include the six contract repairs, focused regression tests, `versions/V2R2C1.json`, governed CREATE_NEW/reconcile recovery, and final runtime projection commits. The final source before this evidence commit is `2ec49005bde44f6981b14351500847d594e0276c`.
|
||||
|
||||
The runbook update is in `runbooks/agent-fix-action-contracts-20260825.md`. This report is the required durable evidence artifact. Existing untracked `runtime/agent-observability/` and `runtime/task-center/` directories were preserved and were not staged.
|
||||
|
||||
Tests run on the final source:
|
||||
|
||||
```text
|
||||
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
|
||||
264 passed, 28 subtests passed in 4.78s
|
||||
|
||||
PYTHONPATH=active-runtime/app python3 -m pytest -q tests
|
||||
717 passed, 2 skipped, 154 subtests passed, 2 failed in 33.98s
|
||||
```
|
||||
|
||||
The full-suite failures remain the known event-bus fixture failure and the real signer adapter self-test (`returncode=42`); no new mandatory-suite regression was observed. Focused contract/reconcile tests and the exact mandatory suite passed.
|
||||
|
||||
## Staging deployment identity
|
||||
|
||||
- Final deployed source OID at the last completed runtime smoke: `2ec49005bde44f6981b14351500847d594e0276c`.
|
||||
- Final deployed artifact: `/opt/aios/gitops-model-api/2ec49005bde44f6981b14351500847d594e0276c-action-contract-repair/runtime`.
|
||||
- Artifact SHA256: `f67aed6cc0cce1f08197485c3eea204d3ef4d3989f44dc98e8ea2691e5886cbf`.
|
||||
- Deployment state: `STAGING`; production-deployed flag: `false`.
|
||||
- A lexically highest staging-only systemd drop-in was installed for each affected service, followed by `daemon-reload` and restart. `systemctl is-active` returned `active` for both services, and `systemctl show` confirmed the final OID, artifact SHA, API URL, and baseline authority.
|
||||
|
||||
## Runtime smoke evidence
|
||||
|
||||
- Model health returned `status=ok`, `version=V2R2C1`, final commit/artifact identity, `deployment_state=STAGING`, and `production_deployed=false`.
|
||||
- Bridge/API readback was available; the bridge's `/health` path is not exposed by this runtime and returned `404`, while its governed endpoints were healthy.
|
||||
- `getReleaseConsistency("V2R2C1", ...)` returned structured `status=PASS`, with Gitea listing/peeled target and tag object matching the stable reference.
|
||||
- `runReleaseAcceptance(...)` entered policy evaluation and returned structured `accepted=false`; it did not fail URL validation. The independent pre-existing tag publication state is `REMOTE_PUSH_PENDING`, with the remote rejection explicitly reporting protected tag `V2R2C1`.
|
||||
- `getTagPublicationStatus("V2R2C1")` returned structured `REMOTE_PUSH_PENDING`, not `invalid release version`.
|
||||
- `getReleaseSaga` for a nonexistent candidate returned HTTP `404` with `status=NOT_FOUND`, `state=NOT_FOUND`, `execution_enabled=false`, and no filesystem permission error.
|
||||
- `commitFilesGoverned` published schema includes required `files`, with per-file `path` and `content_base64`.
|
||||
- Two-file governed commit: branch `agent/action-contract-integration-20260825-1407`, OID `0345cd8c70967940bc8e9132dff7d6ea11efa304` at authority/Git remote/API; protected-ref flags remained false.
|
||||
- Terminal reconcile: `COMPLETED` read-back was idempotent across two calls; focused tests cover `FAILED` and `CANCELLED` terminal semantics.
|
||||
- Small diff returned bounded non-truncated output; empty diff returned zero paths and zero bytes; large diff returned bounded/truncated output as recorded above.
|
||||
- Main, protected tag, baseline registry, and production OIDs were re-read after deployment and remained unchanged.
|
||||
|
||||
## Restart persistence and remaining risk
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,217 @@
|
||||
# Agent Execution Contract — Action Contract Regression Repair
|
||||
|
||||
## Objective
|
||||
|
||||
Fully repair the current AIOS Action runtime/contract overlay regressions on the single server, without modifying protected refs directly and without introducing new infrastructure. The task is not complete until the deployed staging/action overlay passes all acceptance gates.
|
||||
|
||||
## Authority and scope
|
||||
|
||||
- Repository: `aios/aios`
|
||||
- Base at task creation: `3244f0f17f8845592ac94d585f5ebd65693c9f29`
|
||||
- Governed task: `fix-action-contracts-20260825-1407`
|
||||
- Target branch: `agent/fix-action-contracts-20260825-1407`
|
||||
- Protected refs must not be directly written: `main`, release tags, baseline registry, production runtime.
|
||||
- No new SaaS, database, agent framework, or monitoring platform may be introduced.
|
||||
|
||||
## Non-interactive rules
|
||||
|
||||
The Agent must run the whole flow unnattended:
|
||||
|
||||
1. Collect facts.
|
||||
2. Determine root causes.
|
||||
3. Make the smallest correct changes.
|
||||
4. Run unit/integration/contract tests.
|
||||
5. Deploy to staging only.
|
||||
6. Restart only the affected non-production services.
|
||||
7. Run the full Action smoke suite.
|
||||
8. On failure, automatically collect logs, diagnose, fix, and retry.
|
||||
9. If a change causes a new regression, automatically roll back the staging change to the previous known-good configuration and record the failure.
|
||||
10. Stop only if an owner decision is required for a high-risk, irreversible, or production-boundary action.
|
||||
|
||||
## Fact collection — required before editing
|
||||
|
||||
Record all of the following in the final evidence bundle:
|
||||
|
||||
- `id`, `hostname`, `uname -a`, `date --iso-seconds`
|
||||
- `git rev-parse --show HEAD` for authority worktree and running overlay source
|
||||
- `git status --porcelaine=v2`
|
||||
- `git remote -v`
|
||||
- current systemd service unit(s), drop-ins, Environment/EnvironmentFile sources for GitOps/Bredge/release services
|
||||
- `systemctl show <affected-service>` including `User=`, `Group=`, `Environment=`, `EnvironmentFile=`, `ExecStart=`
|
||||
- resolved Gitea API base URL after env/config expansion; redact secrets but not scheme/host
|
||||
- `stat -c, `namei -l`, `getfacl`, als -ld on `/var/lib/aios-gitops/silent-release` and its parents
|
||||
- find Action OpenAPI/schema generator and runtime operation definitions for `commitFilesGoverned`
|
||||
- find the release version validator(s) and all callers of `getTagPublicationStatus`
|
||||
- find `reconcileGitOpsJob` implementation and durable binding validation logic
|
||||
- find `getDiff implementation and transport output limits
|
||||
|
||||
Do not edit any file until the actual implementation and configuration sources have been located.
|
||||
|
||||
## Repair 1 — Gitea API URL
|
||||
|
||||
Symptoms:
|
||||
|
||||
- `getReleaseConsistency` → `Gitea API URL must be HTTP(S)`
|
||||
- `runReleaseAcceptance` → `Gitea API URL must be HTTP(ÊX
|
||||
|
||||
Required behavior:
|
||||
|
||||
- Determine which env/config key is projected into the current contract overlay.
|
||||
- Compare the deployed overlay with V2R2C1 known-good configuration/deployment manifests.
|
||||
- Restore a valid `HTTP://` or `HTTPS://` Gitea API base URL.
|
||||
- Add startup config validation so invalid schemes fail at health/startup with a config-specific message, not during a release check.
|
||||
- Never hard-code a different Gitea host if the existing deployment already has a single authoritative source.
|
||||
|
||||
## Repair 2 — durable release state permissions
|
||||
|
||||
Symptom:
|
||||
|
||||
- `getReleaseSaga` → `Permission denied: '/var/lib/aios-gitops/silent-release'`
|
||||
|
||||
Required behavior:
|
||||
|
||||
- Identify the actual runtime service User/Group.
|
||||
- Grant only the required traverse/read/write permissions for the durable state directories the service must use.
|
||||
- Prefer ownership + group mode or a specific ACL; keep the directory non-world-writable.
|
||||
- Persist the permission in deployment/install logic so restart/reboot does not regress.
|
||||
- Verify create/read/update of a staging-only saga state file under the service identity.
|
||||
|
||||
## Repair 3 — release version contract
|
||||
|
||||
Symptom:
|
||||
|
||||
- `getTagPublicationStatus("V2R2C1")` → `invalid release version`
|
||||
- The same runtime exposes `V2R2C1` as `approved_release_version`.
|
||||
|
||||
Required behavior:
|
||||
|
||||
- Locate all version validators; consolidate onto one canonical parser.
|
||||
- Accept the AIOS canonical namespace `^V[0-9]+R[0-9]+C[0-9]+$`.
|
||||
- Preserve any additional already-supported legacy formats unless they are security-sensitive.
|
||||
- Add contract tests proving that the version accepted by capabilities is accepted by status, candidate, consistency, acceptance, and tag publication.
|
||||
|
||||
## Repair 4 — `commitFilesGoverned` schema parity
|
||||
|
||||
Symptom:
|
||||
|
||||
- Runtime capability metadata requires `files`.
|
||||
- Published Action tool schema does not expose `files`.
|
||||
- Call reaches Bridge and fails `BRIDGE_REQUEST_FIELDS_MISSING`.
|
||||
|
||||
Required behavior:
|
||||
|
||||
- Find the canonical runtime operation contract.
|
||||
- Generate the external tool schema from the same source of truth; do not manually copy a parallel schema.
|
||||
- `files` must be required and must represent a list of file objects with at least `path` and `content_base64`.
|
||||
- Add a parity test that compares runtime required fields with published Action required fields.
|
||||
- Add a real governed commit test that writes two test files to an isolated `agent/` ibranch and verifies remote OID.
|
||||
|
||||
## Repair 5 — terminal reconciliation
|
||||
|
||||
Symptom:
|
||||
|
||||
- A governed job read back as `COMPLETED' but `reconcileGitOpsJob` failed `DURABLE_BINDING_INVALID`.
|
||||
|
||||
Required behavior:
|
||||
|
||||
- Reconciling a terminal successful job must be idempotent.
|
||||
- An expired writer lease must not invalidate a durable binding for a job that is already terminal.
|
||||
- Active-job reconciliation may require a live lease; terminal read-back must not.
|
||||
- Add cases for COMPLETED, FAILED, CANCELLED / equivalent terminal states.
|
||||
|
||||
## Repair 6 — bounded `getDiff` output
|
||||
|
||||
Symptom:
|
||||
|
||||
- Large valid diffs fail with `ResponseTooLargeError` or `GIT_OUTPUT_LIMIT_EXCEEDED`.
|
||||
|
||||
Required behavior:
|
||||
|
||||
- Do not load unbounded diff text into the Action response.
|
||||
- Return bounded summary data: commit identities, changed paths/stats, total bytes, and `truncated: true` when the full patch exceeds the safe limit.
|
||||
- If pagination already exists in the contract use it; otherwise prefer deterministic truncation over a breaking schema change.
|
||||
- Add tests for small, empty, and oversize diffs.
|
||||
|
||||
## Staging deployment procedure
|
||||
|
||||
1. Run the existing mandatory test suite before modification; record baseline.
|
||||
2. Apply code and config changes only on the governed branch/worktree.
|
||||
3. Run focused regression tests.
|
||||
4. Run the full mandatory suite.
|
||||
5. Build the staging contract overlay using the repository's existing build/deploy path.
|
||||
6. Deploy to staging/non-production only.
|
||||
7. If systemd units are changed: `systemctl daemon-reload`, then restart only the affected staging services.
|
||||
8. Wait for the existing health endpoint/runtime status to become healthy.
|
||||
9. Run the acceptance suite below.
|
||||
|
||||
If any step fails:
|
||||
- collect `journalctl` logs and relevant app logs;
|
||||
- diagnose root cause;
|
||||
- automatically repair and retry when non-destructive;
|
||||
- if a new change breaks staging, restore the previous staging artifact/config and restart the affected service;
|
||||
- never silently continue after a failed gate.
|
||||
|
||||
## Acceptance suite
|
||||
|
||||
All of the following must pass on the same deployed staging overlay:
|
||||
|
||||
- Runtime status is `active` and healthy after restart.
|
||||
- Gitea Git/API readback works from the runtime service identity.
|
||||
- `getReleaseConsistency(V2R2C1, 998d30304787e31e5ae607c0bd86b5edb2b0e5ad, a8b906d1bc24088c19647e98de6a679268f6d0a2, approved_plan) returns a structured result and reaches evaluation.
|
||||
- `runReleaseAcceptance` for the same identities reaches policy evaluation, not URL validation error.
|
||||
- `getTagPublicationStatus("V2R2C1")` returns structured state, not `invalid release version`.
|
||||
- `getReleaseSaga` for a nonexistent candidate returns NOT_FOUND/empty-state semantics, not filesystem permission failure.
|
||||
- `commitFilesGoverned` exposes its required `files` parameter in the published Action schema.
|
||||
- A real two-file governed commit to an isolated `agent/` branch succeeds and remote branch OID matches.
|
||||
- Resuming a COMPLETED job returns the existing `JOB_TERMINAL` semantics or idempotent success as designed.
|
||||
- Reconciling a COMPLETED governed job is successful/idempotent and does not require an active lease.
|
||||
- Small/empty `getDiff still works.
|
||||
- Large `getDiff` returns bounded output with truncation metadata instead of a transport exception.
|
||||
- Existing protected-ref controls remain unchange: no direct main/tag/baseline/production write.
|
||||
|
||||
## Restart / reboot persistence
|
||||
|
||||
After the first full pass:
|
||||
|
||||
- Restart the staging Action/Bridge/release services one more time without changing files.
|
||||
- Rerun the acceptance suite.
|
||||
- Permissions, Gitea URL, and schema must still be correct after restart.
|
||||
- If the repository already has a safe staging reboot test, run it as well.
|
||||
|
||||
## Evidence and completion
|
||||
|
||||
Create a single durable report in the repository, e.g. `reports/action-contract-repair-evidence-20260825.md`, containing:
|
||||
|
||||
- pre-change facts
|
||||
- root cause for each regression
|
||||
- files changed and why
|
||||
- config/permission changes
|
||||
- test commands and exact results
|
||||
- staging deployment identity (commit/artifact/config digest)
|
||||
- restart-persistence results
|
||||
- final Action smoke results
|
||||
- any remaining risks
|
||||
|
||||
Commit all source, test, deployment config, and runbook changes to the governed task branch. Do not mark the task SUCCEEDED until all acceptance gates pass on the actually deployed staging overlay.
|
||||
|
||||
## Automatic rollback conditions
|
||||
|
||||
Automatically roll back the staging deployment if any of the following occur:
|
||||
|
||||
- service fails to become healthy after restart
|
||||
- protected-ref write boundary is broken
|
||||
- Gitea readback that worked before the change stops working
|
||||
- durable saga state cannot be read or recovered
|
||||
- mandatory test suite regresses below the pre-change baseline
|
||||
|
||||
Do not automatically roll back an existing production release. Rollback here refers only to the staging overlay/service configuration changes made by this task.
|
||||
|
||||
## 2026-08-25 execution record
|
||||
|
||||
The repair branch was deployed through the existing immutable staging builder. The final source code before the evidence commit was `2ec49005bde44f6981b14351500847d594e0276c`; the deployed artifact was built from that source with SHA256 `f67aed6cc0cce1f08197485c3eea204d3ef4d3989f44dc98e8ea2691e5886cbf`. The final staging drop-ins use a lexically highest `zzzz...-999.conf` layer so they override earlier diagnostic overlays.
|
||||
|
||||
The runtime configuration is staging-only: `User=aiman`, `Group=gitops`, API base `https://g.cnnetmkt.top/git/api/v1`, baseline authority `/var/lib/aios-gitops/model-api-staging/baseline/current.json`, and read/write access to the staging state plus the authoritative bare repository required by the governed Writer/CAS path. No `main`, protected tag, baseline registry, or production write is enabled.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,311 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
SCHEMA = 'aios.v2r2c1.model-api-staging-manifest.v1'
|
||||
REQUIRED_APP = (
|
||||
'aios_model_api_staging.py',
|
||||
'aios_gitops_write_guard.py',
|
||||
'authoritative_git.py',
|
||||
'baseline_authority.py',
|
||||
'gitea_tag_publication.py',
|
||||
'silent_release_controller.py',
|
||||
'silent_release_hardening.py',
|
||||
'aios_gitops_job_controller.py',
|
||||
'aios_chatgpt_bridge_v2.py',
|
||||
'aios_chatgpt_bridge_v2_server.py',
|
||||
'governed_production_release.py',
|
||||
'v2r2c1_production_adapter.py',
|
||||
'v2r1c7_production_adapter.py',
|
||||
'aios_governed_task_bootstrap.py',
|
||||
'aios_continuation_executor.py',
|
||||
'aios_continuation_lease.py',
|
||||
'aios_authority_git_executor.py',
|
||||
'aios_gitops_reconciliation_core.py',
|
||||
'authorized_task_writer_provisioning.py',
|
||||
'signer_contract_compatibility.py',
|
||||
)
|
||||
OPTIONAL_APP = (
|
||||
'aios_chatgpt_actions_api.py',
|
||||
'aios_continuation_executor.py',
|
||||
'aios_continuation_lease.py',
|
||||
'aios_control_plane_snapshot.py',
|
||||
)
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open('rb') as fh:
|
||||
for chunk in iter(lambda: fh.read(1024 * 1024), b''):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
def private_marker(path: Path) -> bool:
|
||||
import re
|
||||
text = path.read_text(encoding='utf-8', errors='ignore')
|
||||
patterns = (
|
||||
r'-----BEGIN [A-Z ]*PRIVATE KEY-----',
|
||||
r'(?<![A-Za-z0-9])sk-[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])',
|
||||
r'(?<![A-Za-z0-9])ghp_[A-Za-z0-9]{20,}(?![A-Za-z0-9])',
|
||||
r'(?i)aws_secret_access_key\s*[:=]\s*[A-Za-z0-9/+=]{16,}',
|
||||
)
|
||||
return any(re.search(pattern, text) for pattern in patterns)
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--source-root', required=True, type=Path)
|
||||
ap.add_argument('--output-root', required=True, type=Path)
|
||||
ap.add_argument('--source-oid', required=True)
|
||||
args = ap.parse_args()
|
||||
oid = args.source_oid.lower()
|
||||
if len(oid) != 40 or any(c not in '0123456789abcdef' for c in oid):
|
||||
raise SystemExit('source oid must be exact 40 hex')
|
||||
source = args.source_root.resolve(strict=True)
|
||||
output = args.output_root.resolve()
|
||||
app = source / 'active-runtime' / 'app'
|
||||
sys.path.insert(0, str(app))
|
||||
from aios_chatgpt_bridge_v2 import render_openapi_contract_fragment
|
||||
|
||||
runtime = source / 'runtime' / 'agent-os'
|
||||
docs = source / 'docs' / 'gitops'
|
||||
if not app.is_dir():
|
||||
raise SystemExit('active-runtime/app missing')
|
||||
paths = []
|
||||
root_version = source / 'release_version.py'
|
||||
if not root_version.is_file():
|
||||
raise SystemExit('required source dependency missing: release_version.py')
|
||||
paths.append((Path('release_version.py'), root_version))
|
||||
for name in REQUIRED_APP:
|
||||
path = app / name
|
||||
if not path.is_file():
|
||||
raise SystemExit(f'required app dependency missing: {name}')
|
||||
paths.append((Path('app') / name, path))
|
||||
for name in OPTIONAL_APP:
|
||||
path = app / name
|
||||
if path.is_file():
|
||||
paths.append((Path('app') / name, path))
|
||||
for rel in ('orchestrator/__init__.py', 'orchestrator/action_monitor.py'):
|
||||
path = runtime / rel
|
||||
if not path.is_file():
|
||||
raise SystemExit(f'required runtime dependency missing: {rel}')
|
||||
paths.append((Path('runtime-agent-os') / rel, path))
|
||||
openapi = docs / 'openapi-chatgpt-actions.yaml'
|
||||
if openapi.is_file():
|
||||
if ' /v2/commit-files:' not in openapi.read_text(encoding='utf-8'):
|
||||
raise SystemExit('published OpenAPI is missing generated commitFilesGoverned contract')
|
||||
paths.append((Path('docs') / 'gitops' / 'openapi-chatgpt-actions.yaml', openapi))
|
||||
for folder in ('config/model_registry', 'config/model_priority', 'patches', 'versions'):
|
||||
root = source / folder
|
||||
if root.is_dir():
|
||||
for path in sorted(root.rglob('*')):
|
||||
if path.is_file() and path.suffix.lower() in {'.json', '.yaml', '.yml', '.md'}:
|
||||
paths.append((Path(folder) / path.relative_to(root), path))
|
||||
for rel, path in paths:
|
||||
if private_marker(path):
|
||||
raise SystemExit(f'private marker rejected: {rel}')
|
||||
included = [str(rel).replace('\\', '/') for rel, _ in paths]
|
||||
manifest = {
|
||||
'schema': SCHEMA,
|
||||
'target_version': 'V2R2C1',
|
||||
'source_oid': oid,
|
||||
'repository_identity': 'aios/aios',
|
||||
'deployment_state': 'STAGING',
|
||||
'runtime_mode': 'IMMUTABLE_STAGING',
|
||||
'runtime_entrypoint': 'app/aios_model_api_staging.py',
|
||||
'legacy_global_writes_enabled': False,
|
||||
'governed_release_routing': 'bridge-v2',
|
||||
'artifact_sha256': 'provided-by-build-receipt-and-install-manifest',
|
||||
'included_paths': included,
|
||||
}
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
archive = output / f'aios-v2r2c1-model-api-{oid}.tar.gz'
|
||||
with tempfile.TemporaryDirectory(prefix='v2r2c1-model-api-', dir=str(output)) as tmp:
|
||||
root = Path(tmp) / 'runtime'
|
||||
for rel, path in paths:
|
||||
target = root / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(path.read_bytes())
|
||||
(root / 'manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + '\n', encoding='utf-8')
|
||||
with tarfile.open(archive, 'w:gz', format=tarfile.PAX_FORMAT) as tf:
|
||||
for path in sorted(root.rglob('*')):
|
||||
rel = path.relative_to(root.parent)
|
||||
info = tf.gettarinfo(str(path), arcname=str(rel).replace('\\', '/'))
|
||||
info.mtime = 0
|
||||
info.uid = 0
|
||||
info.gid = 0
|
||||
info.uname = 'root'
|
||||
info.gname = 'root'
|
||||
if path.is_file():
|
||||
with path.open('rb') as fh:
|
||||
tf.addfile(info, fh)
|
||||
else:
|
||||
tf.addfile(info)
|
||||
digest = sha256(archive)
|
||||
receipt = {
|
||||
'schema': 'aios.v2r2c1.model-api-staging-build-receipt.v1',
|
||||
'target_version': 'V2R2C1',
|
||||
'source_oid': oid,
|
||||
'repository_identity': 'aios/aios',
|
||||
'artifact_path': str(archive),
|
||||
'artifact_sha256': digest,
|
||||
'deployment_state': 'STAGING',
|
||||
'manifest_schema': SCHEMA,
|
||||
'included_paths': included,
|
||||
}
|
||||
receipt_path = output / f'aios-v2r2c1-model-api-{oid}.receipt.json'
|
||||
receipt_path.write_text(json.dumps(receipt, ensure_ascii=False, indent=2, sort_keys=True) + '\n', encoding='utf-8')
|
||||
print(json.dumps(receipt, ensure_ascii=False, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,543 @@
|
||||
"""Read-only Phase 2 runtime introspection with a bounded SSH transport.
|
||||
|
||||
The wrapper is deliberately sent as ``subprocess.run(input=payload)``. No
|
||||
JSON, ABI data, or base64 representation is placed in the Windows SSH argv;
|
||||
the default argv is the fixed, short command documented by
|
||||
``DEFAULT_REMOTE_COMMAND``.
|
||||
|
||||
The remote wrapper reads the current issuer unit's MainPID, WorkingDirectory,
|
||||
and PYTHONPATH, then resolves only the named importable modules. It does not
|
||||
walk release trees, ``/opt``, historical worktrees, SOUL, or MEMORY, and it
|
||||
does not call a database, signer, Bridge, Task API, or mutation endpoint.
|
||||
|
||||
This module is locally testable, but the module import and systemd/POSIX
|
||||
behaviour still require external Linux live validation. Phase 2 status is
|
||||
always explicit about all mutation classes being false.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping, Sequence
|
||||
|
||||
|
||||
DEFAULT_SSH = "ssh.exe"
|
||||
DEFAULT_HOST = "hw"
|
||||
DEFAULT_REMOTE_COMMAND = 'test "$(hostname)" = cn-network-mkt && sudo -n python3 -'
|
||||
DEFAULT_TIMEOUT_SECONDS = 60
|
||||
DEFAULT_ISSUER_UNIT = "aios-task-principal-issuer.service"
|
||||
DEFAULT_ADMISSION_MODULE = "aios_task_principal_admission"
|
||||
DEFAULT_MIGRATION_MODULE = "aios_task_principal_storage_migration"
|
||||
DEFAULT_REQUIRED_ADMISSION_FUNCTIONS = (
|
||||
"offline_migrate_task_principal_schema",
|
||||
"supersede_p0_scope",
|
||||
)
|
||||
DEFAULT_MIGRATION_VERIFIER = "SSHOwnerBootstrapVerifier"
|
||||
MAX_ARGV_BYTES = 512
|
||||
MAX_REQUEST_BYTES = 32 * 1024
|
||||
|
||||
MUTATION_FLAGS = (
|
||||
"task_db_mutation",
|
||||
"scope_mutation",
|
||||
"git_mutation",
|
||||
"service_mutation",
|
||||
)
|
||||
|
||||
|
||||
class RemoteTransportError(RuntimeError):
|
||||
"""A safe, non-secret description of an SSH transport failure."""
|
||||
|
||||
|
||||
class Phase2StatusError(RuntimeError):
|
||||
"""The remote result was not a valid fail-closed Phase2 status."""
|
||||
|
||||
|
||||
def build_ssh_argv(
|
||||
ssh: str = DEFAULT_SSH,
|
||||
host: str = DEFAULT_HOST,
|
||||
) -> list[str]:
|
||||
"""Return the only SSH argv shape permitted by the Phase2 transport."""
|
||||
|
||||
argv = [
|
||||
ssh,
|
||||
"-T",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"ConnectTimeout=12",
|
||||
host,
|
||||
DEFAULT_REMOTE_COMMAND,
|
||||
]
|
||||
argv_bytes = sum(len(part.encode("utf-8")) + 1 for part in argv)
|
||||
if argv_bytes > MAX_ARGV_BYTES:
|
||||
raise ValueError("SSH argv exceeds the Phase2 hard limit")
|
||||
return argv
|
||||
|
||||
|
||||
def run_remote_python(
|
||||
payload: bytes,
|
||||
*,
|
||||
ssh: str = DEFAULT_SSH,
|
||||
host: str = DEFAULT_HOST,
|
||||
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
runner: Callable[..., Any] = subprocess.run,
|
||||
) -> bytes:
|
||||
"""Send *payload* as stdin to the fixed remote Python command.
|
||||
|
||||
Error messages intentionally contain no stderr text. SSH stderr can
|
||||
include command output, environment values, or credentials emitted by a
|
||||
misconfigured remote service.
|
||||
"""
|
||||
|
||||
if not isinstance(payload, bytes):
|
||||
raise TypeError("payload must be bytes")
|
||||
if timeout <= 0:
|
||||
raise ValueError("timeout must be positive")
|
||||
|
||||
argv = build_ssh_argv(ssh=ssh, host=host)
|
||||
try:
|
||||
completed = runner(
|
||||
argv,
|
||||
input=payload,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise RemoteTransportError(
|
||||
f"remote SSH timed out after {timeout:g} seconds"
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
raise RemoteTransportError("remote SSH could not be started") from exc
|
||||
|
||||
return_code = getattr(completed, "returncode", None)
|
||||
if return_code != 0:
|
||||
raise RemoteTransportError(f"remote SSH exited with status {return_code}")
|
||||
output = getattr(completed, "stdout", b"")
|
||||
if isinstance(output, str):
|
||||
output = output.encode("utf-8")
|
||||
if not isinstance(output, bytes):
|
||||
raise RemoteTransportError("remote SSH returned a non-byte stdout value")
|
||||
return output
|
||||
|
||||
|
||||
def module_sha256(path: str | Path) -> str:
|
||||
"""Hash one resolved module file without searching neighbouring files."""
|
||||
|
||||
digest = hashlib.sha256()
|
||||
with Path(path).open("rb") as source:
|
||||
for block in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def validate_public_function_ast(
|
||||
source: str,
|
||||
required_functions: Sequence[str] = DEFAULT_REQUIRED_ADMISSION_FUNCTIONS,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate public symbols and the source-level export mechanism.
|
||||
|
||||
The live admission overlay intentionally re-exports the legacy public API
|
||||
through a small ``globals()[name] = value`` loop. Requiring every public
|
||||
function to be a direct ``def`` would therefore reject the exact deployed
|
||||
module even though the runtime symbol and signature are present. The AST
|
||||
check still proves that the module has either a direct/imported binding or
|
||||
an explicit dynamic re-export mechanism; the caller separately verifies
|
||||
the runtime callable and its signature.
|
||||
"""
|
||||
|
||||
try:
|
||||
tree = ast.parse(source)
|
||||
except SyntaxError as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"missing": list(required_functions),
|
||||
"error": f"AST_PARSE_ERROR:{exc.msg}",
|
||||
}
|
||||
|
||||
top_level = {
|
||||
node.name: node
|
||||
for node in tree.body
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
and not node.name.startswith("_")
|
||||
}
|
||||
imported = {
|
||||
alias.asname or alias.name.split(".", 1)[0]
|
||||
for node in tree.body
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom))
|
||||
for alias in node.names
|
||||
if alias.name != "*"
|
||||
}
|
||||
dynamic_reexport = any(
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "globals"
|
||||
for node in ast.walk(tree)
|
||||
)
|
||||
bound = set(top_level) | imported
|
||||
missing = [
|
||||
name
|
||||
for name in required_functions
|
||||
if name not in bound and not dynamic_reexport
|
||||
]
|
||||
return {
|
||||
"ok": not missing,
|
||||
"missing": missing,
|
||||
"functions": sorted(top_level),
|
||||
"imported": sorted(imported),
|
||||
"dynamic_reexport": dynamic_reexport,
|
||||
}
|
||||
|
||||
|
||||
def make_phase2_status(
|
||||
status: str,
|
||||
errors: Sequence[str] = (),
|
||||
**details: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a status object whose mutation flags are always false."""
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"kind": "phase2-status",
|
||||
"version": 1,
|
||||
"status": status,
|
||||
"read_only": True,
|
||||
"fail_closed": status != "PASS",
|
||||
}
|
||||
result.update({flag: False for flag in MUTATION_FLAGS})
|
||||
if errors:
|
||||
result["errors"] = list(errors)
|
||||
result.update(details)
|
||||
return result
|
||||
|
||||
|
||||
def validate_phase2_status(status: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Validate the result contract and reject any mutation claim."""
|
||||
|
||||
if status.get("kind") != "phase2-status":
|
||||
raise Phase2StatusError("remote result is not phase2-status")
|
||||
for flag in MUTATION_FLAGS:
|
||||
if status.get(flag) is not False:
|
||||
raise Phase2StatusError(f"remote result has invalid {flag}")
|
||||
if status.get("status") not in {"PASS", "FAIL"}:
|
||||
raise Phase2StatusError("remote result has invalid status")
|
||||
return dict(status)
|
||||
|
||||
|
||||
def _json_request(request: Mapping[str, Any]) -> str:
|
||||
"""Serialize only a small request into the stdin-delivered wrapper."""
|
||||
|
||||
encoded = json.dumps(dict(request), ensure_ascii=True, separators=(",", ":"))
|
||||
if len(encoded.encode("utf-8")) > MAX_REQUEST_BYTES:
|
||||
raise ValueError("Phase2 request is unexpectedly large")
|
||||
return encoded
|
||||
|
||||
|
||||
REMOTE_SCRIPT_PREFIX = r'''# Phase2 read-only runtime wrapper. Do not add mutations here.
|
||||
import ast
|
||||
import hashlib
|
||||
import importlib
|
||||
import importlib.util
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
REQUEST = json.loads('''
|
||||
REMOTE_SCRIPT_SUFFIX = r''')
|
||||
|
||||
MUTATION_FLAGS = ("task_db_mutation", "scope_mutation", "git_mutation", "service_mutation")
|
||||
|
||||
def status(status_name, errors=(), **details):
|
||||
result = {
|
||||
"kind": "phase2-status",
|
||||
"version": 1,
|
||||
"status": status_name,
|
||||
"read_only": True,
|
||||
"fail_closed": status_name != "PASS",
|
||||
}
|
||||
result.update({flag: False for flag in MUTATION_FLAGS})
|
||||
if errors:
|
||||
result["errors"] = list(errors)
|
||||
result.update(details)
|
||||
return result
|
||||
|
||||
def systemd_value(unit, property_name):
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["systemctl", "show", unit, "--property=" + property_name, "--value", "--no-pager"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
timeout=12,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
raise RuntimeError("SYSTEMD_QUERY_FAILED")
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError("SYSTEMD_QUERY_FAILED")
|
||||
return completed.stdout.decode("utf-8", "strict").strip()
|
||||
|
||||
def parse_pythonpath(environment_value):
|
||||
for token in shlex.split(environment_value):
|
||||
if token.startswith("PYTHONPATH="):
|
||||
return token[len("PYTHONPATH="):].split(os.pathsep)
|
||||
return []
|
||||
|
||||
def runtime_identity():
|
||||
unit = REQUEST.get("issuer_unit", "aios-task-principal-issuer.service")
|
||||
pid = REQUEST.get("issuer_pid")
|
||||
working_directory = REQUEST.get("working_directory")
|
||||
pythonpath = REQUEST.get("pythonpath")
|
||||
# Regression callers may pin one identity component while obtaining the
|
||||
# remaining components from the live unit. In particular, never replace
|
||||
# an explicitly supplied PID with a newly queried MainPID.
|
||||
if pid is None:
|
||||
pid = int(systemd_value(unit, "MainPID"))
|
||||
if working_directory is None:
|
||||
working_directory = systemd_value(unit, "WorkingDirectory")
|
||||
if pythonpath is None:
|
||||
pythonpath = parse_pythonpath(systemd_value(unit, "Environment"))
|
||||
if not pid or not working_directory or working_directory == "-":
|
||||
raise RuntimeError("ISSUER_RUNTIME_IDENTITY_INCOMPLETE")
|
||||
if isinstance(pythonpath, str):
|
||||
pythonpath = pythonpath.split(os.pathsep)
|
||||
return {
|
||||
"unit": unit,
|
||||
"main_pid": int(pid),
|
||||
"working_directory": working_directory,
|
||||
"pythonpath": [item for item in pythonpath if item],
|
||||
}
|
||||
|
||||
def setup_import_path(identity):
|
||||
path = [identity["working_directory"]]
|
||||
path.extend(identity["pythonpath"])
|
||||
path.extend(item for item in sys.path if item and item not in path)
|
||||
sys.path[:] = path
|
||||
|
||||
def importable_module(module_name):
|
||||
try:
|
||||
spec = importlib.util.find_spec(module_name)
|
||||
if spec is None or not spec.origin or spec.origin in {"built-in", "frozen"}:
|
||||
raise RuntimeError("MODULE_NOT_IMPORTABLE:" + module_name)
|
||||
module = importlib.import_module(module_name)
|
||||
except Exception as exc:
|
||||
if isinstance(exc, RuntimeError):
|
||||
raise
|
||||
raise RuntimeError("MODULE_IMPORT_FAILED:" + module_name) from exc
|
||||
module_path = getattr(module, "__file__", None)
|
||||
if not module_path:
|
||||
raise RuntimeError("MODULE_HAS_NO_FILE:" + module_name)
|
||||
return module, Path(module_path).resolve()
|
||||
|
||||
def hash_file(path):
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for block in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
def inspect_admission(module, path, required, expected_hash):
|
||||
source = path.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
nodes = {
|
||||
node.name: node for node in tree.body
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
and not node.name.startswith("_")
|
||||
}
|
||||
imported = {
|
||||
alias.asname or alias.name.split(".", 1)[0]
|
||||
for node in tree.body
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom))
|
||||
for alias in node.names
|
||||
if alias.name != "*"
|
||||
}
|
||||
dynamic_reexport = any(
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "globals"
|
||||
for node in ast.walk(tree)
|
||||
)
|
||||
bound = set(nodes) | imported
|
||||
missing = [
|
||||
name
|
||||
for name in required
|
||||
if name not in bound and not dynamic_reexport
|
||||
]
|
||||
if missing:
|
||||
raise RuntimeError("PUBLIC_SYMBOL_MISSING:" + ",".join(missing))
|
||||
signatures = {}
|
||||
for name in required:
|
||||
value = getattr(module, name, None)
|
||||
if value is None:
|
||||
raise RuntimeError("PUBLIC_SYMBOL_NOT_IMPORTABLE:" + name)
|
||||
try:
|
||||
signatures[name] = str(inspect.signature(value))
|
||||
except (TypeError, ValueError):
|
||||
raise RuntimeError("SIGNATURE_UNAVAILABLE:" + name)
|
||||
actual_hash = hash_file(path)
|
||||
if expected_hash and actual_hash != expected_hash:
|
||||
raise RuntimeError("MODULE_HASH_MISMATCH:admission")
|
||||
return {
|
||||
"name": module.__name__,
|
||||
"path": str(path),
|
||||
"sha256": actual_hash,
|
||||
"signatures": signatures,
|
||||
"ast_functions": sorted(nodes),
|
||||
"ast_imported": sorted(imported),
|
||||
"ast_dynamic_reexport": dynamic_reexport,
|
||||
}
|
||||
|
||||
def inspect_migration(module, path, verifier_name, expected_hash):
|
||||
verifier = getattr(module, verifier_name, None)
|
||||
if verifier is None:
|
||||
raise RuntimeError("MIGRATION_VERIFIER_MISSING:" + verifier_name)
|
||||
try:
|
||||
signature = str(inspect.signature(verifier))
|
||||
except (TypeError, ValueError):
|
||||
raise RuntimeError("MIGRATION_VERIFIER_SIGNATURE_UNAVAILABLE")
|
||||
actual_hash = hash_file(path)
|
||||
if expected_hash and actual_hash != expected_hash:
|
||||
raise RuntimeError("MODULE_HASH_MISMATCH:migration")
|
||||
return {
|
||||
"name": module.__name__,
|
||||
"path": str(path),
|
||||
"sha256": actual_hash,
|
||||
"verifier": verifier_name,
|
||||
"signature": signature,
|
||||
}
|
||||
|
||||
def main():
|
||||
try:
|
||||
identity = runtime_identity()
|
||||
setup_import_path(identity)
|
||||
admission_name = REQUEST.get("admission_module", "aios_task_principal_admission")
|
||||
migration_name = REQUEST.get("migration_module", "aios_task_principal_storage_migration")
|
||||
if migration_name == admission_name:
|
||||
raise RuntimeError("MIGRATION_MODULE_NOT_DISTINCT")
|
||||
required = tuple(REQUEST.get("required_admission_functions", (
|
||||
"offline_migrate_task_principal_schema", "supersede_p0_scope")))
|
||||
admission, admission_path = importable_module(admission_name)
|
||||
migration, migration_path = importable_module(migration_name)
|
||||
admission_info = inspect_admission(
|
||||
admission,
|
||||
admission_path,
|
||||
required,
|
||||
REQUEST.get("expected_admission_sha256"),
|
||||
)
|
||||
migration_info = inspect_migration(
|
||||
migration,
|
||||
migration_path,
|
||||
REQUEST.get("migration_verifier", "SSHOwnerBootstrapVerifier"),
|
||||
REQUEST.get("expected_migration_sha256"),
|
||||
)
|
||||
print(json.dumps(status(
|
||||
"PASS",
|
||||
issuer=identity,
|
||||
modules={"admission": admission_info, "migration": migration_info},
|
||||
ast_public_functions=list(required),
|
||||
), sort_keys=True, separators=(",", ":")))
|
||||
except Exception as exc:
|
||||
# Emit only stable error classes and never exception text from imports.
|
||||
error = str(exc).split(":", 1)[0] or "PHASE2_INTROSPECTION_FAILED"
|
||||
print(json.dumps(status("FAIL", errors=[error]), sort_keys=True, separators=(",", ":")))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
'''
|
||||
|
||||
|
||||
def build_remote_payload(request: Mapping[str, Any] | None = None) -> bytes:
|
||||
"""Build the stdin wrapper; request data never enters SSH argv."""
|
||||
|
||||
request = {} if request is None else request
|
||||
# repr() makes the embedded JSON a valid Python string for every request
|
||||
# value, including apostrophes and backslashes; it still remains stdin.
|
||||
request_literal = repr(_json_request(request))
|
||||
return (REMOTE_SCRIPT_PREFIX + request_literal + REMOTE_SCRIPT_SUFFIX).encode("utf-8")
|
||||
|
||||
|
||||
def _load_request(args: argparse.Namespace) -> dict[str, Any]:
|
||||
request: dict[str, Any] = {}
|
||||
if args.request_json:
|
||||
loaded = json.loads(args.request_json)
|
||||
if not isinstance(loaded, dict):
|
||||
raise ValueError("request JSON must be an object")
|
||||
request.update(loaded)
|
||||
if args.request_file:
|
||||
loaded = json.loads(Path(args.request_file).read_text(encoding="utf-8"))
|
||||
if not isinstance(loaded, dict):
|
||||
raise ValueError("request JSON must be an object")
|
||||
request.update(loaded)
|
||||
|
||||
overrides = {
|
||||
"issuer_unit": args.issuer_unit,
|
||||
"issuer_pid": args.issuer_pid,
|
||||
"working_directory": args.working_directory,
|
||||
"pythonpath": args.pythonpath,
|
||||
"admission_module": args.admission_module,
|
||||
"migration_module": args.migration_module,
|
||||
"expected_admission_sha256": args.expected_admission_sha256,
|
||||
"expected_migration_sha256": args.expected_migration_sha256,
|
||||
}
|
||||
request.update({key: value for key, value in overrides.items() if value is not None})
|
||||
return request
|
||||
|
||||
|
||||
def _write_status(path: str | Path, status: Mapping[str, Any]) -> None:
|
||||
destination = Path(path)
|
||||
destination.write_text(
|
||||
json.dumps(dict(status), ensure_ascii=True, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--request-json")
|
||||
parser.add_argument("--request-file")
|
||||
parser.add_argument("--output", default="phase2-status.json")
|
||||
parser.add_argument("--ssh", default=DEFAULT_SSH)
|
||||
parser.add_argument("--host", default=DEFAULT_HOST)
|
||||
parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT_SECONDS)
|
||||
parser.add_argument("--issuer-unit")
|
||||
parser.add_argument("--issuer-pid", type=int)
|
||||
parser.add_argument("--working-directory")
|
||||
parser.add_argument("--pythonpath", nargs="*")
|
||||
parser.add_argument("--admission-module")
|
||||
parser.add_argument("--migration-module")
|
||||
parser.add_argument("--expected-admission-sha256")
|
||||
parser.add_argument("--expected-migration-sha256")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
request = _load_request(args)
|
||||
payload = build_remote_payload(request)
|
||||
stdout = run_remote_python(
|
||||
payload,
|
||||
ssh=args.ssh,
|
||||
host=args.host,
|
||||
timeout=args.timeout,
|
||||
)
|
||||
remote_status = json.loads(stdout.decode("utf-8"))
|
||||
status = validate_phase2_status(remote_status)
|
||||
except (OSError, ValueError, json.JSONDecodeError, RemoteTransportError, Phase2StatusError) as exc:
|
||||
status = make_phase2_status("FAIL", errors=[str(exc).split(":", 1)[0]])
|
||||
_write_status(args.output, status)
|
||||
return 1
|
||||
|
||||
_write_status(args.output, status)
|
||||
return 0 if status["status"] == "PASS" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -9,10 +9,16 @@ WORKSPACE="${AIOS_GITOPS_WORKSPACE:-/data/aios-gitops/workspace}"
|
||||
TAG_REMOTE="${AIOS_GITOPS_GITEA_REMOTE:-gitea}"
|
||||
BRANCH_REMOTE="${AIOS_GITOPS_BRANCH_REMOTE:-gitea}"
|
||||
|
||||
case "$VERSION" in
|
||||
V[0-9]*R[0-9]*C[0-9]*) ;;
|
||||
*) echo "ERROR: version must match VxRxCx" >&2; exit 64 ;;
|
||||
esac
|
||||
SCRIPT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
if ! PYTHONPATH="$SCRIPT_ROOT:${PYTHONPATH:-}" python3 - "$VERSION" <<'PY'
|
||||
import sys
|
||||
from release_version import validate_release_version
|
||||
validate_release_version(sys.argv[1])
|
||||
PY
|
||||
then
|
||||
echo "ERROR: version must match VxRxCx" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
for value in "$TARGET_OID" "$EVIDENCE_CLOSURE_OID"; do
|
||||
[[ "$value" =~ ^[0-9a-f]{40}$ ]] || {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from __future__ import annotations
|
||||
import hashlib,json,re,tarfile
|
||||
from release_version import RELEASE_VERSION_RE
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
OID_RE=re.compile(r"^[0-9a-f]{40}$"); SHA_RE=re.compile(r"^[0-9a-f]{64}$"); VERSION_RE=re.compile(r"^V2R1C[0-9]+$")
|
||||
OID_RE=re.compile(r"^[0-9a-f]{40}$"); SHA_RE=re.compile(r"^[0-9a-f]{64}$"); VERSION_RE=RELEASE_VERSION_RE
|
||||
REQUIRED_SOURCE={"schema","version","repository","base_ref","base_oid","binding_mode","release_readiness","production_deployed","tag_created","baseline_changed","production_service","production_port","rollback_metadata","controller_schema","required_checks"}
|
||||
REQUEST_KEYS={"schema","version","repository","base_oid","target_oid","artifact_uri","artifact_sha256","artifact_size","evidence_closure_oid","approval_plan_id","execute","production_deployed","production_service","production_port","rollback_metadata","current_tag_oid","source_manifest_sha256"}
|
||||
class ManifestValidationError(ValueError):
|
||||
@@ -62,7 +63,7 @@ def validate_release_request(source_manifest:dict[str,Any],request_path:Path,exp
|
||||
if not isinstance(request["approval_plan_id"],str) or not request["approval_plan_id"].startswith("sha256:"): raise ManifestValidationError("APPROVAL_PLAN","invalid approval plan")
|
||||
if request["execute"] is not False or request["production_deployed"] is not False: raise ManifestValidationError("EXECUTION_NOT_ALLOWED","must be non-executing")
|
||||
if request["production_service"]!=source_manifest["production_service"] or request["production_port"]!=source_manifest["production_port"]: raise ManifestValidationError("SERVICE_IDENTITY","request service mismatch")
|
||||
if not isinstance(request["current_tag_oid"],str) or request["current_tag_oid"] not in {"V2R1C6","9e65bd59baea392294c0f253046d3ab116f63145"}: raise ManifestValidationError("PROTECTED_TAG","previous tag binding changed")
|
||||
if not isinstance(request["current_tag_oid"],str) or request["current_tag_oid"] not in {"V2R1C7","82792347937c0df59a8e02d6162775252bcad2e2"}: raise ManifestValidationError("PROTECTED_TAG","previous production tag binding changed")
|
||||
_sha(request["source_manifest_sha256"],"source_manifest_sha256"); return request
|
||||
def build_approval_request(*,source_manifest_path:Path,target_oid:str,artifact_uri:str,artifact_sha256:str,artifact_size:int,evidence_closure_oid:str,rollback_metadata:str,current_tag_oid:str)->dict[str,Any]:
|
||||
source=_json_object(source_manifest_path); _oid(target_oid,"target_oid"); _sha(artifact_sha256,"artifact_sha256"); _oid(evidence_closure_oid,"evidence_closure_oid"); request={"schema":"aios.release-request.v2","version":source["version"],"repository":source["repository"],"base_oid":source["base_oid"],"target_oid":target_oid,"artifact_uri":artifact_uri,"artifact_sha256":artifact_sha256,"artifact_size":artifact_size,"evidence_closure_oid":evidence_closure_oid,"execute":False,"production_deployed":False,"production_service":source["production_service"],"production_port":source["production_port"],"rollback_metadata":rollback_metadata,"current_tag_oid":current_tag_oid,"source_manifest_sha256":sha256_file(source_manifest_path)}; request["approval_plan_id"]="sha256:"+sha256_bytes(_canonical(request)); return request
|
||||
|
||||
@@ -3,13 +3,13 @@ from __future__ import annotations
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
def test_version_and_candidate_binding():
|
||||
assert (ROOT / "VERSION").read_text(encoding="utf-8").strip() == "V2R1C6"
|
||||
assert (ROOT / "VERSION").read_text(encoding="utf-8").strip() == "V2R1C7"
|
||||
metadata = json.loads((ROOT / "versions/V2R1C6.json").read_text(encoding="utf-8"))
|
||||
assert metadata["version"] == "V2R1C6"
|
||||
assert metadata["base_oid"] == "a33e2593967a180c89d44454350ccaeaf3f79b25"
|
||||
assert metadata["candidate_branch"] == "agent/v2r1c6-mainline-reconciliation-20260806"
|
||||
assert metadata["production_deployed"] is False
|
||||
|
||||
@@ -7,17 +7,19 @@ import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
from deployment_state import RuntimeDeploymentState
|
||||
from v2r1c6_privacy_tools import health_payload, serve # noqa: E402
|
||||
|
||||
|
||||
def test_health_is_engineering_only() -> None:
|
||||
result = health_payload()
|
||||
assert result == {
|
||||
"status": "ok",
|
||||
"version": "V2R1C6",
|
||||
"production_deployed": False,
|
||||
"gitops_writes_enabled": False,
|
||||
}
|
||||
result = health_payload(RuntimeDeploymentState("V2R1C7", "a" * 40, "b" * 64, "STAGED"))
|
||||
assert result["status"] == "ok"
|
||||
assert result["version"] == "V2R1C7"
|
||||
assert result["commit_oid"] == "a" * 40
|
||||
assert result["artifact_sha256"] == "b" * 64
|
||||
assert result["deployment_state"] == "STAGED"
|
||||
assert result["production_deployed"] is False
|
||||
assert result["health_timestamp"]
|
||||
|
||||
|
||||
def test_listener_rejects_non_loopback() -> None:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user