Compare commits
62
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
|
||||
@@ -94,6 +94,25 @@ class AuthorityGitExecutor:
|
||||
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")
|
||||
|
||||
@@ -24,7 +24,13 @@ from gitea_tag_publication import (
|
||||
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,
|
||||
@@ -64,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')))
|
||||
@@ -109,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',
|
||||
@@ -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)
|
||||
@@ -437,6 +525,8 @@ class H(BaseHTTPRequestHandler):
|
||||
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',
|
||||
@@ -452,12 +542,21 @@ class H(BaseHTTPRequestHandler):
|
||||
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(
|
||||
@@ -630,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()
|
||||
@@ -643,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})
|
||||
@@ -659,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:
|
||||
@@ -955,7 +1059,7 @@ 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', ''))
|
||||
|
||||
@@ -6,12 +6,19 @@ 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",
|
||||
@@ -26,16 +33,23 @@ BRIDGE_OPERATIONS = (
|
||||
"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"),
|
||||
"acquireWriterLease": ("schema", "task_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "lease_seconds"),
|
||||
"commitFileGoverned": ("task_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "path", "content_base64", "message"),
|
||||
"commitFilesGoverned": ("task_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "files", "message"),
|
||||
"getGitOpsJob": ("job_id",),
|
||||
"resumeGitOpsJob": ("job_id", "expected_revision"),
|
||||
"reconcileGitOpsJob": ("job_id", "expected_revision"),
|
||||
"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",),
|
||||
@@ -44,10 +58,35 @@ OPERATION_REQUIRED_FIELDS = {
|
||||
"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",
|
||||
@@ -57,19 +96,54 @@ _OPERATION_PATHS = {
|
||||
"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 operation_contracts() -> dict[str, dict[str, object]]:
|
||||
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."""
|
||||
return {
|
||||
contracts = {
|
||||
operation: {
|
||||
"method": "POST",
|
||||
"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:
|
||||
@@ -82,6 +156,8 @@ def render_openapi_contract_fragment() -> str:
|
||||
" 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"
|
||||
@@ -93,6 +169,8 @@ def render_openapi_contract_fragment() -> str:
|
||||
f"{required}\n"
|
||||
" properties:\n"
|
||||
" task_id: {type: string}\n"
|
||||
" job_id: {type: string, description: External durable Job identity for exact completed-replay reconciliation}\n"
|
||||
" source_lease_epoch: {type: integer, minimum: 1}\n"
|
||||
" repository: {type: string, example: aios/aios}\n"
|
||||
" expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}\n"
|
||||
" target_branch: {type: string, pattern: '^agent/[A-Za-z0-9._/-]+$'}\n"
|
||||
@@ -111,8 +189,12 @@ def render_openapi_contract_fragment() -> str:
|
||||
" 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"
|
||||
)
|
||||
|
||||
|
||||
@@ -178,20 +260,38 @@ class ControlPlaneReleaseManifest:
|
||||
class BridgeV2Adapter:
|
||||
"""Thin operation adapter; all mutating handlers are injected seams."""
|
||||
|
||||
def __init__(self, *, runtime_status: Mapping[str, Any], handlers: Mapping[str, Callable[[Mapping[str, Any]], Any]] | None = None) -> None:
|
||||
self.runtime_status = dict(runtime_status)
|
||||
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}
|
||||
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": True,
|
||||
"operations": list(BRIDGE_OPERATIONS),
|
||||
"operation_contracts": operation_contracts(),
|
||||
"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,
|
||||
@@ -208,18 +308,23 @@ class BridgeV2Adapter:
|
||||
|
||||
@staticmethod
|
||||
def legacy_write_disabled() -> dict[str, object]:
|
||||
return {"error": "LEGACY_WRITE_DISABLED", "replacement": "createGovernedTask", "governed_writes_available": True}
|
||||
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)
|
||||
@@ -233,5 +338,12 @@ class BridgeV2Adapter:
|
||||
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
@@ -56,7 +56,7 @@ class AuthorityPort(Protocol):
|
||||
|
||||
class RemotePort(Protocol):
|
||||
def repository_identity(self) -> str: ...
|
||||
def push_exact(self, branch: str, expected: str, new: str) -> None: ...
|
||||
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: ...
|
||||
@@ -258,10 +258,11 @@ class GiteaContinuationRemote:
|
||||
raise ContinuationExecutionError("GITEA_GIT_READBACK_INVALID")
|
||||
return oid
|
||||
|
||||
def push_exact(self, branch: str, expected: str, new: str) -> None:
|
||||
def push_exact(self, branch: str, expected: str, new: str, *, allow_absent: bool = False) -> None:
|
||||
self._require_projected()
|
||||
self.repository_identity()
|
||||
if self.read_git_ref(branch) != expected:
|
||||
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)
|
||||
|
||||
@@ -335,6 +336,10 @@ class ContinuationRuntimeBinding:
|
||||
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)
|
||||
@@ -450,6 +455,17 @@ class ContinuationExecutor:
|
||||
"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]:
|
||||
@@ -499,7 +515,7 @@ class ContinuationExecutor:
|
||||
)
|
||||
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)
|
||||
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)
|
||||
@@ -513,6 +529,7 @@ class ContinuationExecutor:
|
||||
def _remote_pending(self, job_id: str, authority_oid: str, new_oid: str) -> dict[str, object]:
|
||||
receipt = {
|
||||
"authority_oid": authority_oid,
|
||||
"commit_oid": new_oid,
|
||||
"desired_remote_oid": new_oid,
|
||||
"remote_verified": False,
|
||||
"force_push": False,
|
||||
@@ -535,6 +552,7 @@ class ContinuationExecutor:
|
||||
return self._remote_pending(job_id, authority_oid, new_oid)
|
||||
receipt = {
|
||||
"authority_oid": authority_oid,
|
||||
"commit_oid": new_oid,
|
||||
"git_remote_oid": git_oid,
|
||||
"api_branch_oid": api_oid,
|
||||
"commit_visible": True,
|
||||
@@ -583,11 +601,14 @@ class ContinuationExecutor:
|
||||
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)
|
||||
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)
|
||||
if authority_oid == new_oid and git_oid == api_oid == expected_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)
|
||||
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:
|
||||
|
||||
@@ -10,12 +10,16 @@ def _copy(value: Mapping[str, Any] | None, keys: tuple[str, ...]) -> dict[str, A
|
||||
|
||||
|
||||
def build_control_plane_snapshot(*, target_version: str, runtime: Mapping[str, Any] | None = None, repository: Mapping[str, Any] | None = None, production: Mapping[str, Any] | None = None, task: Mapping[str, Any] | None = None, capabilities: Mapping[str, Any] | None = None, credential_broker: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
||||
task_snapshot = _copy(task, ("task_id", "branch", "authority_branch_oid", "gitea_git_oid", "gitea_api_oid", "commit_visible", "generation", "reconciliation_state"))
|
||||
for key in ("external_job_id", "commit_oid", "current_head_oid", "completed_replay_reconciliation_id", "receipt_authority_oid"):
|
||||
if task is not None and key in task:
|
||||
task_snapshot[key] = task[key]
|
||||
return {
|
||||
"version": {"target_release": target_version},
|
||||
"runtime": _copy(runtime, ("bridge_schema_version", "control_plane_release_version", "runtime_release_commit", "runtime_path", "artifact_sha256", "drift_status")),
|
||||
"repository": _copy(repository, ("identity", "authority_git_dir", "authority_main_oid")),
|
||||
"production": _copy(production, ("baseline_version", "target_oid", "deployment_state")),
|
||||
"task": _copy(task, ("task_id", "branch", "authority_branch_oid", "gitea_git_oid", "gitea_api_oid", "commit_visible", "generation", "reconciliation_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,
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -178,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:
|
||||
@@ -216,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
|
||||
|
||||
@@ -251,6 +342,227 @@ 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:
|
||||
@@ -302,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)
|
||||
|
||||
@@ -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}"
|
||||
@@ -221,10 +221,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not a: return
|
||||
ver=data.get('version') or data.get('tag')
|
||||
try:
|
||||
validate_release_version(ver)
|
||||
except ValueError:
|
||||
self.send_json({'error':'version must match VxRxCx'},400)
|
||||
return
|
||||
validate_release_version(ver)
|
||||
except ValueError:
|
||||
self.send_json({'error':'version must match VxRxCx'},400)
|
||||
return
|
||||
msg=data.get('message') or f'release {ver}'; out=git_cmd(['tag','-a',ver,'-m',msg]);
|
||||
if data.get('push',False): git_cmd(['push','origin',ver])
|
||||
level='V' if re.match(r'^V[1-9]\d*R0C0', ver) else ('R' if re.match(r'^V\d+R[1-9]\d*C0', ver) else 'C'); exec_sql('INSERT OR REPLACE INTO gitops_versions(version,tag,level,summary,status,created_by,created_at) VALUES(?,?,?,?,?,?,?)',(ver,ver,level,msg,'ready',a['username'],now())); exec_sql('UPDATE gitops_baseline SET active_version=?,active_tag=?,updated_at=? WHERE id=?',(ver,ver,now(),'current')); audit(a['username'],'git_tag',ver,{'level':level}); self.send_json({'status':'tagged','version':ver,'git':out}); return
|
||||
|
||||
@@ -4,11 +4,21 @@ 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}$")
|
||||
@@ -18,6 +28,29 @@ 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
|
||||
@@ -36,49 +69,235 @@ class GovernedTaskBinding:
|
||||
return {"task_id": self.task_id, "repository": self.repository, "base_oid": self.base_oid, "target_branch": self.target_branch, "expected_branch_oid": self.expected_branch_oid, "generation": self.generation, "branch_mode": self.branch_mode, "create_target_branch": self.branch_mode == "CREATE_NEW", "worktree_mode": "detached_at_base_oid" if self.branch_mode == "CREATE_NEW" else "detached_at_expected_head", "cas_ref_update_required": True, "idempotent": idempotent, "state": "BOUND", "job_id": self.job_id}
|
||||
|
||||
|
||||
def _safe(value: str) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-")[:120] or "task"
|
||||
|
||||
# ===== 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.task_meta_root.mkdir(parents=True, exist_ok=True); self.job_root.mkdir(parents=True, exist_ok=True)
|
||||
self.authority = authority; self.root = Path(root); self.remote_name = remote_name; self.token_path = Path(token_path); self.ssh_key_path = Path(ssh_key_path); self.api_base = api_base; self.task_meta_root = self.root / "task-bindings"; self.job_root = self.root / "jobs"; self.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 / _safe(task_id); store = JobStore(root, payload_root=root / "payloads"); return store, GitOpsJobController(store)
|
||||
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 / (_safe(task_id) + ".json")
|
||||
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)
|
||||
if not path.is_file() or path.is_symlink(): return None
|
||||
return GovernedTaskBinding(**json.loads(path.read_text(encoding="utf-8")))
|
||||
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); temporary = path.with_suffix(".tmp"); temporary.write_text(json.dumps(asdict(binding), sort_keys=True), encoding="utf-8"); temporary.replace(path)
|
||||
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")
|
||||
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")): raise BootstrapError("TASK_BINDING_CONFLICT")
|
||||
return existing.response(idempotent=True)
|
||||
if not self.authority.commit_exists(values["base_oid"]): raise BootstrapError("BASE_COMMIT_NOT_FOUND")
|
||||
current = self.authority.read_ref("refs/heads/" + values["target_branch"])
|
||||
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"])
|
||||
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)
|
||||
return binding.response()
|
||||
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)
|
||||
|
||||
@@ -4,6 +4,7 @@ import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, urllib.
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from baseline_authority import BaselineAuthority, BaselineAuthorityError
|
||||
from silent_release_controller import ReleaseCandidate
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
@@ -23,17 +24,23 @@ from gitea_tag_publication import (
|
||||
TagPublisher,
|
||||
redact,
|
||||
validate_version,
|
||||
validate_gitea_api_url,
|
||||
)
|
||||
from orchestrator.action_monitor import ActionMonitor
|
||||
from silent_release_hardening import (
|
||||
ApprovalKeyRing,
|
||||
ApprovalRevocationStore,
|
||||
BuilderAttestation,
|
||||
BuilderAttestationKeyRing,
|
||||
BuilderAttestationVerifier,
|
||||
CapabilityAuthorizationError,
|
||||
ReleasePlan,
|
||||
SagaConflict,
|
||||
SignedApprovalVerifier,
|
||||
SignedReleasePlanApproval,
|
||||
SilentReleaseActionService,
|
||||
TrustedCandidateBuilderUnavailable,
|
||||
TrustedCandidateBundle,
|
||||
TrustedReleasePolicy,
|
||||
)
|
||||
try:
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobError, ServiceUnavailable, JobStore
|
||||
@@ -83,13 +90,34 @@ SILENT_RELEASE_STATE_ROOT = Path(os.getenv(
|
||||
'AIOS_SILENT_RELEASE_STATE_ROOT',
|
||||
'/var/lib/aios-gitops/silent-release',
|
||||
))
|
||||
SILENT_RELEASE_EXECUTION_ENABLED = str(os.getenv(
|
||||
'AIOS_SILENT_RELEASE_EXECUTION_ENABLED', 'false'
|
||||
)).lower() in {'1', 'true', 'yes', 'on'}
|
||||
# This immutable staging API can prepare only a shadow candidate. An
|
||||
# environment value must not turn its HTTP process into a release executor.
|
||||
SILENT_RELEASE_EXECUTION_ENABLED = False
|
||||
SILENT_RELEASE_PUBLIC_KEYS = Path(os.getenv(
|
||||
'AIOS_SILENT_RELEASE_PUBLIC_KEYS',
|
||||
'/etc/aios-gitops/silent-release-owner-public-keys.json',
|
||||
))
|
||||
TRUSTED_BUILDER_ENABLED = str(os.getenv(
|
||||
'AIOS_TRUSTED_CANDIDATE_BUILDER_ENABLED', 'false'
|
||||
)).lower() in {'1', 'true', 'yes', 'on'}
|
||||
TRUSTED_BUILDER_POLICY = Path(os.getenv(
|
||||
'AIOS_TRUSTED_CANDIDATE_BUILDER_POLICY', ''
|
||||
))
|
||||
TRUSTED_BUILDER_CREDENTIALS_DIRECTORY = Path(os.getenv(
|
||||
'AIOS_TRUSTED_CANDIDATE_BUILDER_CREDENTIALS_DIRECTORY',
|
||||
os.getenv('CREDENTIALS_DIRECTORY', ''),
|
||||
))
|
||||
TRUSTED_BUILDER_SOCKET = Path(os.getenv(
|
||||
'AIOS_TRUSTED_CANDIDATE_BUILDER_SOCKET',
|
||||
'/run/aios-trusted-candidate-builder.sock',
|
||||
))
|
||||
TRUSTED_BUILDER_PUBLIC_KEYRING = Path(os.getenv(
|
||||
'AIOS_TRUSTED_CANDIDATE_BUILDER_PUBLIC_KEYRING', ''
|
||||
))
|
||||
TRUSTED_RELEASE_POLICY = Path(os.getenv(
|
||||
'AIOS_SILENT_RELEASE_TRUSTED_POLICY',
|
||||
str(WS / 'config' / 'gitops' / 'silent-release-policy.json'),
|
||||
))
|
||||
monitor = ActionMonitor()
|
||||
|
||||
BASE_MODELS = [
|
||||
@@ -167,6 +195,86 @@ def _file_sha256(path: Path) -> str:
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _trusted_release_policy_from_file(path: Path) -> TrustedReleasePolicy:
|
||||
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||
required = {
|
||||
'services', 'release_roots', 'cutover_links', 'dropin_paths',
|
||||
'authoritative_git_dirs', 'repository_identities',
|
||||
}
|
||||
if not isinstance(payload, dict) or set(payload) != required:
|
||||
raise ValueError('trusted release policy schema is invalid')
|
||||
values = {}
|
||||
for name in required:
|
||||
raw = payload[name]
|
||||
if not isinstance(raw, list) or not raw or not all(
|
||||
isinstance(item, str) and item for item in raw
|
||||
):
|
||||
raise ValueError('trusted release policy values are invalid')
|
||||
values[name] = frozenset(raw)
|
||||
return TrustedReleasePolicy(**values)
|
||||
|
||||
|
||||
class _HostTrustedCandidateBuilderAdapter:
|
||||
"""Maps only a complete host-owned silent-release bundle response.
|
||||
|
||||
The API never manufactures candidate evidence or a builder signature. A
|
||||
host builder protocol that returns a different response is therefore
|
||||
unavailable, rather than silently downgraded to caller-owned evidence.
|
||||
"""
|
||||
|
||||
def __init__(self, client):
|
||||
self._client = client
|
||||
|
||||
def build(self, intent):
|
||||
import secrets
|
||||
from aios_trusted_candidate_builder import BuilderError
|
||||
|
||||
try:
|
||||
if not isinstance(intent, dict):
|
||||
raise TypeError('intent must be a mapping')
|
||||
version = str(intent.get('version') or 'V2R2C2')
|
||||
# A createReleaseIntent request is intentionally identity-light.
|
||||
# Candidate identity is resolved by the host builder policy, not by
|
||||
# the caller. Explicit release-worker requests may still carry it.
|
||||
candidate_id = str(intent.get('candidate_id') or '')
|
||||
source_commit = str(intent.get('source_commit') or '')
|
||||
if not version or not source_commit:
|
||||
raise KeyError('version/source_commit')
|
||||
except (KeyError, TypeError) as exc:
|
||||
raise CapabilityAuthorizationError(
|
||||
'trusted builder requires a release intent or worker request'
|
||||
) from exc
|
||||
try:
|
||||
response = self._client.build(
|
||||
version,
|
||||
candidate_id,
|
||||
source_commit,
|
||||
nonce=secrets.token_hex(32),
|
||||
)
|
||||
except BuilderError as exc:
|
||||
raise TrustedCandidateBuilderUnavailable(
|
||||
'trusted builder request failed'
|
||||
) from exc
|
||||
raw_bundle = response.get('silent_release_bundle')
|
||||
if not isinstance(raw_bundle, dict):
|
||||
raise CapabilityAuthorizationError(
|
||||
'trusted builder response is not a silent release bundle'
|
||||
)
|
||||
try:
|
||||
return TrustedCandidateBundle(
|
||||
candidate=ReleaseCandidate.from_dict(raw_bundle['candidate']),
|
||||
release_plan=ReleasePlan.from_dict(raw_bundle['release_plan']),
|
||||
checks=raw_bundle['checks'],
|
||||
attestation=BuilderAttestation.from_dict(
|
||||
raw_bundle['attestation'], require_fingerprint=True
|
||||
),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise CapabilityAuthorizationError(
|
||||
'trusted builder response is invalid'
|
||||
) from exc
|
||||
|
||||
def _build_silent_release_service() -> SilentReleaseActionService:
|
||||
verifier=None
|
||||
if SILENT_RELEASE_PUBLIC_KEYS.is_file():
|
||||
@@ -176,12 +284,88 @@ def _build_silent_release_service() -> SilentReleaseActionService:
|
||||
SILENT_RELEASE_STATE_ROOT / 'revocations'
|
||||
),
|
||||
)
|
||||
policy_path=WS / 'config' / 'gitops' / 'silent-release-policy.json'
|
||||
policy_path=TRUSTED_RELEASE_POLICY
|
||||
policy_sha=_file_sha256(policy_path) if policy_path.is_file() else ''
|
||||
candidate_builder = None
|
||||
builder_attestation_verifier = None
|
||||
trusted_policy = None
|
||||
builder_readiness_probe = None
|
||||
if TRUSTED_BUILDER_ENABLED:
|
||||
# Every path is opt-in. In particular, this API receives its own
|
||||
# client HMAC credential, never the builder's signing private key.
|
||||
if not (
|
||||
TRUSTED_BUILDER_POLICY.is_file()
|
||||
and TRUSTED_BUILDER_CREDENTIALS_DIRECTORY.is_dir()
|
||||
and TRUSTED_BUILDER_PUBLIC_KEYRING.is_file()
|
||||
and TRUSTED_RELEASE_POLICY.is_file()
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
'trusted builder configuration is incomplete'
|
||||
)
|
||||
try:
|
||||
from aios_trusted_candidate_builder import (
|
||||
BuilderKeyring,
|
||||
TrustedCandidateBuilderConfig,
|
||||
UnixTrustedCandidateBuilderClient,
|
||||
load_builder_policy,
|
||||
readiness_probe,
|
||||
)
|
||||
builder_policy = load_builder_policy(TRUSTED_BUILDER_POLICY)
|
||||
builder_config = TrustedCandidateBuilderConfig.from_policy(
|
||||
builder_policy
|
||||
)
|
||||
client_hmac = (
|
||||
TRUSTED_BUILDER_CREDENTIALS_DIRECTORY / 'builder_hmac'
|
||||
).read_bytes().strip()
|
||||
client_keyring = BuilderKeyring(builder_hmac=client_hmac)
|
||||
client = UnixTrustedCandidateBuilderClient(
|
||||
builder_config,
|
||||
client_keyring,
|
||||
socket_path=TRUSTED_BUILDER_SOCKET,
|
||||
)
|
||||
candidate_builder = _HostTrustedCandidateBuilderAdapter(client)
|
||||
builder_attestation_verifier = BuilderAttestationVerifier(
|
||||
BuilderAttestationKeyRing.from_file(
|
||||
TRUSTED_BUILDER_PUBLIC_KEYRING
|
||||
)
|
||||
)
|
||||
trusted_policy = _trusted_release_policy_from_file(
|
||||
TRUSTED_RELEASE_POLICY
|
||||
)
|
||||
|
||||
def builder_readiness_probe():
|
||||
if not TRUSTED_BUILDER_SOCKET.exists():
|
||||
return {'status': 'UNAVAILABLE'}
|
||||
remote = dict(readiness_probe(client))
|
||||
if remote.get('policy_sha256') != builder_config.policy_sha256:
|
||||
return {'status': 'UNAVAILABLE'}
|
||||
builder_attestation_verifier.keyring.require_active_identity(
|
||||
key_id=str(remote.get('builder_key_id') or ''),
|
||||
generation=remote.get('builder_key_generation'),
|
||||
algorithm=str(remote.get('signature_algorithm') or ''),
|
||||
purpose=str(remote.get('signature_purpose') or ''),
|
||||
)
|
||||
return {
|
||||
**remote,
|
||||
'hmac_credential_loaded': True,
|
||||
'peer_verification_configured': True,
|
||||
'public_key_trusted': True,
|
||||
'policy_loaded': True,
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
raise CapabilityAuthorizationError(
|
||||
'trusted builder configuration is invalid'
|
||||
) from exc
|
||||
return SilentReleaseActionService(
|
||||
SILENT_RELEASE_STATE_ROOT,
|
||||
execution_enabled=SILENT_RELEASE_EXECUTION_ENABLED,
|
||||
approval_verifier=verifier,
|
||||
candidate_builder=candidate_builder,
|
||||
builder_attestation_verifier=builder_attestation_verifier,
|
||||
trusted_policy=trusted_policy,
|
||||
builder_readiness_probe=builder_readiness_probe,
|
||||
allow_legacy_candidate_input=False,
|
||||
expected_repository=REPOSITORY_IDENTITY,
|
||||
policy_sha256=policy_sha,
|
||||
)
|
||||
@@ -320,7 +504,8 @@ 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:
|
||||
validate_version(tag)
|
||||
if not re.fullmatch(r'V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$', str(tag or '')):
|
||||
raise ValueError('invalid release version')
|
||||
try:
|
||||
result = run_authoritative_git(
|
||||
['rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'],
|
||||
@@ -343,6 +528,15 @@ def publication_request_from_query(q: dict[str, list[str]]) -> PublicationReques
|
||||
(q['approval_plan_id'])[0],
|
||||
)
|
||||
|
||||
def governed_release_query_payload(q: dict[str, list[str]]) -> dict[str, str]:
|
||||
request = publication_request_from_query(q)
|
||||
return {
|
||||
'version': request.version,
|
||||
'target_oid': request.target_oid,
|
||||
'evidence_closure_oid': request.evidence_closure_oid,
|
||||
'approval_plan_id': request.approval_plan_id,
|
||||
}
|
||||
|
||||
def required_authoritative_git_dir() -> Path:
|
||||
snapshot = repository_snapshot(
|
||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||
@@ -466,8 +660,11 @@ class H(BaseHTTPRequestHandler):
|
||||
q=urllib.parse.parse_qs(u.query)
|
||||
try:
|
||||
if path in ('/health','/api/health'):
|
||||
builder_readiness = silent_release_service().get_builder_readiness()
|
||||
return self.sendj({
|
||||
'status': 'ok',
|
||||
'process_liveness': 'PASS',
|
||||
'builder_readiness': builder_readiness,
|
||||
'service': os.getenv('AIOS_GITOPS_MODEL_API_SERVICE', 'aios-gitops-model-api-v2r2c1-staging.service'),
|
||||
'workspace': str(WS),
|
||||
'version': 'V2R2C1',
|
||||
@@ -553,15 +750,7 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj({'error':redact(exc)},413)
|
||||
except (AuthoritativeGitError, RuntimeError) as exc:
|
||||
return self.sendj({'error':redact(exc)},503)
|
||||
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)
|
||||
return self.sendj({'from':frm,'to':to,'diff':diff})
|
||||
if path in ('/api/patch','/api/patches','/api/branches','/patch'):
|
||||
snapshot = repository_snapshot(
|
||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||
@@ -714,8 +903,15 @@ class H(BaseHTTPRequestHandler):
|
||||
if path == '/createReleaseCandidate':
|
||||
a=self.need('release:candidate')
|
||||
if not a: return
|
||||
service = silent_release_service()
|
||||
readiness = service.get_builder_readiness()
|
||||
if readiness.get('status') != 'READY':
|
||||
return self.sendj({
|
||||
'error': 'TRUSTED_BUILDER_UNAVAILABLE',
|
||||
'builder_readiness': readiness,
|
||||
}, 503)
|
||||
return self.sendj(
|
||||
silent_release_service().create_release_candidate(data)
|
||||
service.create_release_candidate(data)
|
||||
)
|
||||
if path == '/createReleaseIntent':
|
||||
a=self.need('release:candidate')
|
||||
@@ -755,6 +951,13 @@ class H(BaseHTTPRequestHandler):
|
||||
error=redact(e)
|
||||
monitor.record_failure('do_POST', error, {'path':path})
|
||||
return self.sendj({'error':error},403)
|
||||
except TrustedCandidateBuilderUnavailable:
|
||||
monitor.record_failure(
|
||||
'do_POST', 'TRUSTED_BUILDER_UNAVAILABLE', {'path': path}
|
||||
)
|
||||
return self.sendj(
|
||||
{'error': 'TRUSTED_BUILDER_UNAVAILABLE'}, 503
|
||||
)
|
||||
except SagaConflict as e:
|
||||
error=redact(e)
|
||||
monitor.record_failure('do_POST', error, {'path':path})
|
||||
@@ -830,10 +1033,7 @@ class H(BaseHTTPRequestHandler):
|
||||
a=self.need('tag:create');
|
||||
if not a: return
|
||||
ver=data.get('version') or data.get('tag')
|
||||
try:
|
||||
validate_version(ver)
|
||||
except ValueError:
|
||||
return self.sendj({'error':'version must match VxRxCx'},400)
|
||||
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)
|
||||
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',
|
||||
@@ -1027,8 +1227,6 @@ def api_spec():
|
||||
|
||||
|
||||
def main():
|
||||
validate_gitea_api_url(os.getenv('AIOS_GITOPS_GITEA_API_URL', ''))
|
||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
||||
if __name__ == '__main__': main()
|
||||
|
||||
|
||||
@@ -0,0 +1,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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"""Governed V2R2C1 production release orchestration.
|
||||
"""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
|
||||
@@ -20,6 +23,8 @@ 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,
|
||||
@@ -406,6 +411,14 @@ class GovernedProductionReleaseExecutor:
|
||||
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
|
||||
@@ -546,7 +559,139 @@ def _health(url: str, binding: ReleaseBinding, artifact: Mapping[str, Any]) -> d
|
||||
return {"status": "ACTIVE_VERIFIED" if exact else "CONFLICT", "http_status": status, **body}
|
||||
|
||||
|
||||
def build_executor_config(config: Any) -> GovernedProductionReleaseExecutor:
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -15,6 +15,7 @@ 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@
|
||||
@@ -22,12 +23,13 @@ Environment=PYTHONPATH=@RUNTIME_ROOT@:@RUNTIME_ROOT@/app:@RUNTIME_ROOT@/runtime-
|
||||
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
|
||||
ReadWritePaths=/var/lib/aios-gitops/bridge-v2-staging /data/aios-gitops/repo.git /data/aios-gitops/repo.git/worktrees
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -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
|
||||
@@ -7,9 +7,6 @@ Wants=network-online.target
|
||||
Type=simple
|
||||
User=aiman
|
||||
Group=gitops
|
||||
PermissionsStartOnly=true
|
||||
ExecStartPre=/usr/bin/install -d -o aiman -g gitops -m 0750 /var/lib/aios-gitops/model-api-staging
|
||||
ExecStartPre=/usr/bin/install -d -o aiman -g gitops -m 0750 /var/lib/aios-gitops/model-api-staging/silent-release
|
||||
WorkingDirectory=@RUNTIME_ROOT@
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
@@ -30,16 +27,10 @@ Environment=AIOS_GITOPS_DEPLOYMENT_STATE=STAGING
|
||||
Environment=AIOS_GITOPS_MODEL_API_SERVICE=aios-gitops-model-api-v2r2c1-staging.service
|
||||
Environment=AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
||||
Environment=AIOS_SILENT_RELEASE_STATE_ROOT=/var/lib/aios-gitops/model-api-staging/silent-release
|
||||
Environment=AIOS_GITOPS_GITEA_API_URL=https://g.cnnetmkt.top/git/api/v1
|
||||
Environment=AIOS_GITOPS_GITEA_REPOSITORY=aios/aios
|
||||
Environment=AIOS_GITOPS_GITEA_REMOTE=gitea-http
|
||||
Environment=AIOS_GITOPS_GITEA_TOKEN_FILE=/run/credentials/aios-gitops-model-api-v2r2c1-staging.service/gitea_token
|
||||
Environment=AIOS_GITOPS_GITEA_TOKEN_LINE=1
|
||||
LoadCredential=gitea_token:/run/credentials/aios-governance-executor.service/gitea-governance-executor-token
|
||||
Environment=AIOS_BRIDGE_V2_URL=http://127.0.0.1:18892
|
||||
Environment=AIOS_GITOPS_HOST=127.0.0.1
|
||||
Environment=AIOS_GITOPS_PORT=18893
|
||||
Environment=PYTHONPATH=@RUNTIME_ROOT@:@RUNTIME_ROOT@/app:@RUNTIME_ROOT@/runtime-agent-os
|
||||
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
|
||||
|
||||
@@ -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、
|
||||
@@ -0,0 +1,102 @@
|
||||
# AIOS 本地开发工作流约定(CN)
|
||||
|
||||
> 适用范围:具备本地工作区能力的执行环境(人 / Agent)。
|
||||
> 定位:本地高效开发层(clone / fetch / 分支 / 修改 / 验证 / commit / push 到非 main 分支)。
|
||||
> **不绕过**远端现有治理:branch protection、CAS、generation、release approval 等保持不变。
|
||||
> 本文档为流程约定,不是治理授权;不开放任何 GitOps 写权限。
|
||||
|
||||
---
|
||||
|
||||
## 1. 分层原则
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ 开发层(本地 git worktree) │
|
||||
│ clone/reuse → 拉最新基线 → agent/* 分支 → 修改 → 本地验证 │
|
||||
│ → commit → push 远端 非 main 分支 │
|
||||
├───────────────────────────────────────────────────────────────┤
|
||||
│ 治理控制面(Gitea + gitops 治理) │
|
||||
│ protected main(禁 direct push / force / tag / baseline) │
|
||||
│ PR + Owner approval → CAS/generation 校验 → release 流程 │
|
||||
│ scoped writer 授权 / Admission / GitOps gate │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
本地 Git 是高效开发层,不应绕过治理控制面。仅当本地环境不可用、需要读取生产运行事实、或必须通过 AIOS 治理 API 执行的动作,才直接走远端控制面。
|
||||
|
||||
---
|
||||
|
||||
## 2. 远端与开发基线
|
||||
|
||||
- 远端:`https://g.cnnetmkt.top/git/aios/aios.git`(Gitea,仅 HTTPS)
|
||||
- 默认分支:`main`(protected)
|
||||
- 开发分支前缀:`agent/*`(开发)、`acceptance/*`(验收)、`patch/*`(平台修复)
|
||||
- 本地 git 身份:由 operator 配置(本仓库内配置,不写全局,避免污染其他仓库)
|
||||
|
||||
---
|
||||
|
||||
## 3. 每次开发的固定动作
|
||||
|
||||
### 3.1 拉取最新基线
|
||||
|
||||
```bash
|
||||
git status --short # 必须确认无未提交修改,再动基线
|
||||
git fetch origin
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
```
|
||||
|
||||
> **禁止 `git reset --hard`。**
|
||||
> `reset --hard` 存在误覆盖 Owner 工作与 Agent 中间产物的风险;AIOS 保护 Owner 工作与 Agent 产物。
|
||||
> 基线漂移(本地与 `origin/main` 分叉、或授权 base OID 漂移)通过**检查 + 重新绑定**解决,而非覆盖丢弃。
|
||||
> 涉及授权基准漂移(AUTHORIZED_BASE_OID_DRIFT)时必须由 Owner 重新授权,不得静默 rebase。
|
||||
|
||||
### 3.2 创建开发分支
|
||||
|
||||
```bash
|
||||
git checkout -b agent/<描述> origin/main
|
||||
# 示例:agent/fix-auth-timeout-20260908、agent/repository-discovery-p0-20260902
|
||||
```
|
||||
|
||||
### 3.3 修改 + 成组提交
|
||||
|
||||
- **成组**:一组逻辑完整的内容 → 一个 commit。不要"每改一个文件就 commit/push"。
|
||||
- 提交信息遵循仓库风格:`feat(...)` / `fix(...)` / `chore(...)`(参考 `git log`)。
|
||||
- **禁止**提交:密钥、token、签名材料、明文凭据(仓库配有 `.gitleaks.toml`)。
|
||||
|
||||
### 3.4 本地验证
|
||||
|
||||
- 按 `AGENT_DEPLOYMENT_REQUIREMENTS.md` 及各 scope 测试要求在本地运行验证
|
||||
(`python3 -m unittest`、smoke、`py_compile` 等),通过后再 commit + push。
|
||||
|
||||
### 3.5 同步远端
|
||||
|
||||
```bash
|
||||
git push -u origin agent/<分支>
|
||||
```
|
||||
|
||||
推送非 main 分支后,交由远端治理决定是否合入 main。
|
||||
|
||||
---
|
||||
|
||||
## 4. 治理边界(严格遵守)
|
||||
|
||||
以下操作必须留在远端治理控制面,本地 git 一律不做:
|
||||
|
||||
- `main` 直接 push / merge —— 走 protected PR + Owner approval
|
||||
- `force push` / 历史重写 —— 全程禁止
|
||||
- tag 创建 / 更新 / 删除 —— 属 release approval,Owner 控制
|
||||
- baseline(生产基线)写入 —— Owner 控制
|
||||
- 分支删除 —— Owner 控制
|
||||
- 仓库管理 / 管理员操作 —— Owner 控制
|
||||
- 生产 / 发布部署标记 —— 走验收 + release approval
|
||||
|
||||
---
|
||||
|
||||
## 5. 状态登记与授权
|
||||
|
||||
- 当前 `AIOS_GITOPS_WRITES_ENABLED=false` 为**预期状态**:Agent 不可直接写 GitOps 状态,符合
|
||||
"禁止绕过 Admission / GitOps gate" 的治理设计。
|
||||
- 本文档建议通过普通代码变更(agent 分支 + PR)提交收录,**不要求、也不应打开 GitOps 写权限**。
|
||||
- 待 `AIOS_GITOPS_WRITES_ENABLED` 或正式 scoped writer 授权开启后,如需将该约定登记到 AIOS
|
||||
控制面,再由 Owner / 治理 API 执行。
|
||||
@@ -240,11 +240,177 @@ 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:
|
||||
@@ -254,6 +420,7 @@ paths:
|
||||
additionalProperties: false
|
||||
required:
|
||||
- task_id
|
||||
- job_id
|
||||
- repository
|
||||
- expected_branch_oid
|
||||
- target_branch
|
||||
@@ -263,6 +430,8 @@ paths:
|
||||
- message
|
||||
properties:
|
||||
task_id: {type: string}
|
||||
job_id: {type: string, description: External durable Job identity for exact completed-replay reconciliation}
|
||||
source_lease_epoch: {type: integer, minimum: 1}
|
||||
repository: {type: string, example: aios/aios}
|
||||
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9._/-]+$'}
|
||||
@@ -281,8 +450,12 @@ paths:
|
||||
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,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,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,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.
|
||||
@@ -205,3 +205,13 @@ Automatically roll back the staging deployment if any of the following occur:
|
||||
- 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,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())
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -8,6 +9,7 @@ import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "runtime/agent-os"))
|
||||
|
||||
from aios_chatgpt_bridge_v2 import ( # noqa: E402
|
||||
OPERATION_REQUIRED_FIELDS,
|
||||
@@ -17,7 +19,9 @@ from aios_chatgpt_bridge_v2 import ( # noqa: E402
|
||||
from aios_chatgpt_bridge_v2_server import GovernedRuntime # noqa: E402
|
||||
from aios_gitops_job_controller import JobState # noqa: E402
|
||||
from gitea_tag_publication import validate_gitea_api_url # noqa: E402
|
||||
from silent_release_hardening import ReleaseSagaStore, SilentReleaseActionService # noqa: E402
|
||||
from release_version import validate_release_version # noqa: E402
|
||||
import aios_model_api_staging as model_api # noqa: E402
|
||||
|
||||
|
||||
def test_canonical_release_version_is_shared_and_accepts_v2r2c1():
|
||||
@@ -35,6 +39,41 @@ def test_gitea_url_validation_is_startup_safe():
|
||||
validate_gitea_api_url("g.cnnetmkt.top/git/api/v1")
|
||||
|
||||
|
||||
def test_public_release_reads_use_the_bridge_canonical_evaluator():
|
||||
query = {
|
||||
"version": ["V2R2C1"],
|
||||
"target_oid": ["998d30304787e31e5ae607c0bd86b5edb2b0e5ad"],
|
||||
"evidence_closure_oid": ["a8b906d1bc24088c19647e98de6a679268f6d0a2"],
|
||||
"approval_plan_id": ["sha256:plan"],
|
||||
}
|
||||
assert model_api.governed_release_query_payload(query) == {
|
||||
"version": "V2R2C1",
|
||||
"target_oid": "998d30304787e31e5ae607c0bd86b5edb2b0e5ad",
|
||||
"evidence_closure_oid": "a8b906d1bc24088c19647e98de6a679268f6d0a2",
|
||||
"approval_plan_id": "sha256:plan",
|
||||
}
|
||||
source = inspect.getsource(model_api.H.do_GET)
|
||||
assert "publication_publisher()" in source
|
||||
assert "get_release_consistency" in source
|
||||
assert "run_release_acceptance" in source
|
||||
|
||||
|
||||
def test_governed_read_routes_are_bound_to_authenticated_facade():
|
||||
route_script = (
|
||||
ROOT / "ops/gitops-migration/bind_external_action_surface_final_staging.sh"
|
||||
).read_text()
|
||||
for path in (
|
||||
"/api/governed-write-capabilities",
|
||||
"/api/control-plane-snapshot",
|
||||
"/api/online-agent-task",
|
||||
"/api/online-agent-task-events",
|
||||
):
|
||||
assert f"location = {path}" in route_script
|
||||
assert f"proxy_pass http://127.0.0.1:18890{path};" in route_script
|
||||
assert "location /api/ {" in route_script
|
||||
assert "Authorization $http_authorization" in route_script
|
||||
|
||||
|
||||
def test_commit_files_runtime_and_published_schema_have_same_required_fields():
|
||||
runtime = operation_contracts()["commitFilesGoverned"]["required_fields"]
|
||||
assert runtime == list(OPERATION_REQUIRED_FIELDS["commitFilesGoverned"])
|
||||
@@ -114,3 +153,13 @@ def test_active_reconcile_still_uses_executor():
|
||||
"job_id": "job-active",
|
||||
"state": "COMPLETED",
|
||||
}
|
||||
|
||||
|
||||
def test_missing_release_saga_is_a_not_found_empty_state(tmp_path):
|
||||
service = object.__new__(SilentReleaseActionService)
|
||||
service.sagas = ReleaseSagaStore(tmp_path)
|
||||
service.execution_enabled = False
|
||||
result = service.get_release_saga("never-created-action-contract")
|
||||
assert result["status"] == "NOT_FOUND"
|
||||
assert result["state"] == "NOT_FOUND"
|
||||
assert not list(tmp_path.rglob("*"))
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
|
||||
import aios_agent_task_api_lineage_supersession as api
|
||||
|
||||
|
||||
class FakeController:
|
||||
def __init__(self, db_path, verifier, **kwargs):
|
||||
self.db_path = db_path
|
||||
|
||||
def supersede(self, body):
|
||||
if body.get("fail"):
|
||||
raise api.TaskLineageSupersessionError("SOURCE_TASK_EXACT_CAS_MISMATCH", 409)
|
||||
return {"status": "SUPERSEDED", "idempotent": bool(body.get("replay"))}
|
||||
|
||||
|
||||
def test_endpoint_authenticates_before_controller(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(api, "TaskLineageSupersessionController", FakeController)
|
||||
endpoint = api.LineageSupersessionEndpoint(
|
||||
"unused.sqlite3", object(), lambda headers: calls.append(dict(headers))
|
||||
)
|
||||
status, result = endpoint.post({"Authorization": "Bearer control"}, {})
|
||||
assert status == 201
|
||||
assert result["status"] == "SUPERSEDED"
|
||||
assert calls == [{"Authorization": "Bearer control"}]
|
||||
assert endpoint.post({}, {"replay": True})[0] == 200
|
||||
assert endpoint.post({}, {"fail": True}) == (
|
||||
409, {"error": "SOURCE_TASK_EXACT_CAS_MISMATCH"}
|
||||
)
|
||||
|
||||
|
||||
def test_authentication_failure_prevents_controller(monkeypatch):
|
||||
called = False
|
||||
|
||||
class MustNotRun(FakeController):
|
||||
def supersede(self, body):
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
monkeypatch.setattr(api, "TaskLineageSupersessionController", MustNotRun)
|
||||
|
||||
def deny(_headers):
|
||||
raise PermissionError("denied")
|
||||
|
||||
endpoint = api.LineageSupersessionEndpoint("unused", object(), deny)
|
||||
with pytest.raises(PermissionError, match="denied"):
|
||||
endpoint.post({}, {})
|
||||
assert called is False
|
||||
|
||||
|
||||
def test_runtime_binding_is_lazy_and_fails_closed_when_overlay_missing(monkeypatch):
|
||||
for name in (
|
||||
"aios_agent_task_api", "aios_agent_task_api_p0_expired_recovery",
|
||||
"aios_agent_task_protocol", "aios_owner_approval_verifier",
|
||||
):
|
||||
monkeypatch.delitem(sys.modules, name, raising=False)
|
||||
original = __import__
|
||||
|
||||
def blocked(name, *args, **kwargs):
|
||||
if name == "aios_agent_task_api_p0_expired_recovery":
|
||||
raise ImportError(name)
|
||||
return original(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("builtins.__import__", blocked)
|
||||
with pytest.raises(RuntimeError, match="RUNTIME_OVERLAY_UNAVAILABLE"):
|
||||
api.build_runtime()
|
||||
|
||||
|
||||
def test_error_status_is_not_inferred_from_untrusted_text():
|
||||
assert api._status(api.TaskLineageSupersessionError("anything", 403)) == 403
|
||||
@@ -0,0 +1,452 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0,str(ROOT/"active-runtime/app"))
|
||||
|
||||
from aios_agent_task_initial_execution_projection import (
|
||||
InitialExecutionProjectionError,
|
||||
InitialExecutionProjectionRequest,
|
||||
InitialExecutionProjectionStore,
|
||||
SourceCaptureProjectionRecoveryRequest,
|
||||
)
|
||||
from aios_online_agent_execution import (
|
||||
GovernedExecutionBinding,
|
||||
project_execution_envelope,
|
||||
)
|
||||
|
||||
AUTHORIZATION="owner-initial-execution-projection-recovery-20260820"
|
||||
TASK_ID="task-initial-projection-a"
|
||||
JOB_ID="job-initial-projection-a"
|
||||
BASE="a"*40
|
||||
BRANCH="agent/task-initial-projection-a"
|
||||
IDEMPOTENCY="task-initial-projection-binding-v1"
|
||||
CAPABILITY="agent.runtime.openclaw"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _auth(monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"AIOS_AGENT_TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZATION_ID",
|
||||
AUTHORIZATION,
|
||||
)
|
||||
|
||||
|
||||
def canon(value):
|
||||
return hashlib.sha256(
|
||||
json.dumps(value,sort_keys=True,separators=(",",":")).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def binding():
|
||||
return GovernedExecutionBinding.from_mapping({
|
||||
"task_id":TASK_ID,
|
||||
"job_id":JOB_ID,
|
||||
"repository":"aios/aios",
|
||||
"base_oid":BASE,
|
||||
"expected_branch_oid":BASE,
|
||||
"target_branch":BRANCH,
|
||||
"branch_mode":"CREATE_NEW",
|
||||
"generation":1,
|
||||
"idempotency_key":IDEMPOTENCY,
|
||||
})
|
||||
|
||||
|
||||
def runtime_input():
|
||||
return {
|
||||
"goal":"Supervise release preparation",
|
||||
"instructions":"Observe and resume only the same lineage.",
|
||||
"success_criteria":[{"id":"heartbeat","description":"Heartbeat observed"}],
|
||||
"constraints":{"production_write":False},
|
||||
"scope":{"scope_type":"legacy","tenant_id":None,"project_id":None},
|
||||
"trace_id":TASK_ID,
|
||||
}
|
||||
|
||||
|
||||
def envelope(*, job_id=JOB_ID, action="agent_runtime", capability=CAPABILITY):
|
||||
bound=binding()
|
||||
if job_id != JOB_ID:
|
||||
bound=GovernedExecutionBinding.from_mapping({**bound.public(),"job_id":job_id})
|
||||
return project_execution_envelope(
|
||||
bound,
|
||||
{
|
||||
"action":action,
|
||||
"adapter":"openclaw" if action=="agent_runtime" else "source_capture",
|
||||
"capability_scope":[capability],
|
||||
"runtime_input":runtime_input() if action=="agent_runtime" else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def make_db(tmp_path, *, claimed_by=None, lease=False, metadata=None, caps=None, event="TASK_CREATED"):
|
||||
path=tmp_path/"state.sqlite3"
|
||||
con=sqlite3.connect(path)
|
||||
con.executescript("""
|
||||
CREATE TABLE tasks(
|
||||
task_id TEXT PRIMARY KEY,
|
||||
goal TEXT NOT NULL,
|
||||
instructions TEXT NOT NULL,
|
||||
success_criteria_json TEXT NOT NULL,
|
||||
required_capabilities_json TEXT NOT NULL,
|
||||
constraints_json TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL,
|
||||
deadline TEXT,
|
||||
priority INTEGER NOT NULL,
|
||||
max_attempts INTEGER NOT NULL,
|
||||
attempt INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL,
|
||||
claimed_by TEXT,
|
||||
lease_token_hash TEXT,
|
||||
lease_expires_at TEXT,
|
||||
result_json TEXT,
|
||||
result_digest TEXT,
|
||||
result_idempotency_key TEXT,
|
||||
verification_json TEXT,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE events(
|
||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT,
|
||||
agent_id TEXT,
|
||||
event_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
""")
|
||||
metadata={} if metadata is None else metadata
|
||||
caps=[] if caps is None else caps
|
||||
con.execute(
|
||||
"INSERT INTO tasks VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
TASK_ID,"Supervise release preparation",
|
||||
"Observe and resume only the same lineage.",
|
||||
json.dumps([{"id":"heartbeat","description":"Heartbeat observed"}]),
|
||||
json.dumps(caps),json.dumps({"production_write":False}),json.dumps(metadata),
|
||||
None,100,20,0,"QUEUED",claimed_by,
|
||||
"lease-hash" if lease else None,
|
||||
"2099-01-01T00:00:00Z" if lease else None,
|
||||
None,None,None,None,1,"2026-08-20T00:00:00Z","2026-08-20T00:00:00Z",
|
||||
),
|
||||
)
|
||||
con.execute(
|
||||
"INSERT INTO events(task_id,agent_id,event_type,payload_json,created_at) VALUES (?,?,?,?,?)",
|
||||
(TASK_ID,None,event,"{}","2026-08-20T00:00:00Z"),
|
||||
)
|
||||
con.commit();con.close()
|
||||
return path
|
||||
|
||||
|
||||
def request(*, digest=None, key="initial-projection-a-v1"):
|
||||
return InitialExecutionProjectionRequest.from_mapping({
|
||||
"expected_status":"QUEUED",
|
||||
"expected_revision":1,
|
||||
"expected_attempt":0,
|
||||
"expected_metadata_sha256":canon({}),
|
||||
"expected_required_capabilities_sha256":canon([]),
|
||||
"expected_job_id":JOB_ID,
|
||||
"expected_execution_digest":digest or envelope()["execution_digest"],
|
||||
"required_capability":CAPABILITY,
|
||||
"adapter":"openclaw",
|
||||
"authorization_id":AUTHORIZATION,
|
||||
"idempotency_key":key,
|
||||
"reason":"initial governed supervisor runtime projection",
|
||||
})
|
||||
|
||||
|
||||
def resolver(_task_id,_job_id,_capability,_adapter,received_runtime_input):
|
||||
assert received_runtime_input==runtime_input()
|
||||
return envelope()
|
||||
|
||||
|
||||
def source_capture_result():
|
||||
old=envelope(action="source_capture",capability="git.read")
|
||||
return {
|
||||
"task_id":TASK_ID,
|
||||
"job_id":JOB_ID,
|
||||
"repository":"aios/aios",
|
||||
"base_oid":BASE,
|
||||
"expected_branch_oid":BASE,
|
||||
"target_branch":BRANCH,
|
||||
"generation":1,
|
||||
"status":"SUCCEEDED",
|
||||
"classification":"NEEDS_REWORK_SOURCE_CAPTURE",
|
||||
"execution_mode":"source_capture",
|
||||
"execution_digest":old["execution_digest"],
|
||||
"changed_files":[],
|
||||
"writer_lease":"NONE",
|
||||
"verification_state":"NEEDS_REWORK",
|
||||
"direct_main_write":False,
|
||||
"production_write":False,
|
||||
"force_push":False,
|
||||
"protected_ref_bypass":False,
|
||||
"secret_material_exposed":False,
|
||||
"source_snapshot":{"schema":"test.snapshot.v1","payload_sha256":"d"*64},
|
||||
}
|
||||
|
||||
|
||||
def make_source_capture_rework_db(tmp_path, *, mutate=None, events=None):
|
||||
path=make_db(tmp_path)
|
||||
result=source_capture_result()
|
||||
verification={
|
||||
"decision":"NEEDS_REWORK",
|
||||
"reason":"SOURCE_CAPTURE_COMPLETE_RUNTIME_PROJECTION_REQUIRED",
|
||||
}
|
||||
if mutate:
|
||||
mutate(result,verification)
|
||||
event_types=events or [
|
||||
"TASK_CLAIMED","TASK_HEARTBEAT","RESULT_SUBMITTED","TASK_VERIFIED"
|
||||
]
|
||||
con=sqlite3.connect(path)
|
||||
con.execute(
|
||||
"UPDATE tasks SET attempt=1,status='QUEUED',claimed_by=NULL,revision=5,"
|
||||
"result_json=?,result_digest=?,result_idempotency_key=?,verification_json=?",
|
||||
(
|
||||
json.dumps(result,sort_keys=True,separators=(",",":")),
|
||||
canon(result),"source-capture-result-v1",
|
||||
json.dumps(verification,sort_keys=True,separators=(",",":")),
|
||||
),
|
||||
)
|
||||
for event_type in event_types:
|
||||
payload={}
|
||||
if event_type=="TASK_VERIFIED":
|
||||
payload={"decision":"NEEDS_REWORK","next_state":"QUEUED"}
|
||||
con.execute(
|
||||
"INSERT INTO events(task_id,agent_id,event_type,payload_json,created_at) "
|
||||
"VALUES (?,?,?,?,?)",
|
||||
(TASK_ID,"generic-worker",event_type,json.dumps(payload),"2026-08-20T00:01:00Z"),
|
||||
)
|
||||
con.commit();con.close()
|
||||
return path,result,verification
|
||||
|
||||
|
||||
def recovery_request(result,verification,*,digest=None,key="source-capture-recovery-a-v1"):
|
||||
return SourceCaptureProjectionRecoveryRequest.from_mapping({
|
||||
"expected_status":"QUEUED",
|
||||
"expected_revision":5,
|
||||
"expected_attempt":1,
|
||||
"expected_metadata_sha256":canon({}),
|
||||
"expected_required_capabilities_sha256":canon([]),
|
||||
"previous_verification_sha256":canon(verification),
|
||||
"expected_source_capture_result_sha256":canon(result),
|
||||
"expected_source_capture_execution_digest":result["execution_digest"],
|
||||
"expected_job_id":JOB_ID,
|
||||
"expected_execution_digest":digest or envelope()["execution_digest"],
|
||||
"required_capability":CAPABILITY,
|
||||
"adapter":"openclaw",
|
||||
"authorization_id":AUTHORIZATION,
|
||||
"idempotency_key":key,
|
||||
"reason":"first durable runtime projection after governed source capture",
|
||||
})
|
||||
|
||||
|
||||
def test_initial_projection_cas_persists_envelope_capability_and_event(tmp_path):
|
||||
path=make_db(tmp_path)
|
||||
result=InitialExecutionProjectionStore(path,resolver).project(TASK_ID,request())
|
||||
assert result["revision"]==2
|
||||
assert result["attempt"]==0
|
||||
assert result["idempotent"] is False
|
||||
con=sqlite3.connect(path)
|
||||
row=con.execute(
|
||||
"SELECT status,attempt,revision,claimed_by,metadata_json,required_capabilities_json FROM tasks"
|
||||
).fetchone()
|
||||
assert row[:4]==("QUEUED",0,2,None)
|
||||
metadata=json.loads(row[4])
|
||||
assert metadata["execution"]["action"]=="agent_runtime"
|
||||
assert metadata["execution"]["adapter"]=="openclaw"
|
||||
assert metadata["execution"]["governance"]["writer_lease"] is False
|
||||
assert json.loads(row[5])==[CAPABILITY]
|
||||
events=con.execute("SELECT event_type,payload_json FROM events ORDER BY event_id").fetchall()
|
||||
assert [event[0] for event in events]==[
|
||||
"TASK_CREATED","TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZED"
|
||||
]
|
||||
payload=json.loads(events[-1][1])
|
||||
assert payload["job_id"]==JOB_ID
|
||||
assert payload["main_write"] is False
|
||||
assert payload["production_write"] is False
|
||||
|
||||
|
||||
def test_initial_projection_idempotent_exact_replay(tmp_path):
|
||||
path=make_db(tmp_path)
|
||||
store=InitialExecutionProjectionStore(path,resolver)
|
||||
first=store.project(TASK_ID,request())
|
||||
second=store.project(TASK_ID,request())
|
||||
assert first["idempotent"] is False
|
||||
assert second["idempotent"] is True
|
||||
assert second["execution_digest"]==first["execution_digest"]
|
||||
con=sqlite3.connect(path)
|
||||
assert con.execute("SELECT COUNT(*) FROM task_initial_execution_projections").fetchone()[0]==1
|
||||
assert con.execute("SELECT COUNT(*) FROM events").fetchone()[0]==2
|
||||
|
||||
|
||||
def test_source_capture_recovery_projects_first_runtime_envelope(tmp_path):
|
||||
path,result,verification=make_source_capture_rework_db(tmp_path)
|
||||
response=InitialExecutionProjectionStore(path,resolver).project_from_source_capture(
|
||||
TASK_ID,recovery_request(result,verification)
|
||||
)
|
||||
assert response["revision"]==6
|
||||
assert response["attempt"]==1
|
||||
assert response["recovery_from_source_capture"] is True
|
||||
assert response["idempotent"] is False
|
||||
con=sqlite3.connect(path)
|
||||
row=con.execute(
|
||||
"SELECT status,attempt,revision,claimed_by,metadata_json,"
|
||||
"required_capabilities_json,result_json,verification_json FROM tasks"
|
||||
).fetchone()
|
||||
assert row[:4]==("QUEUED",1,6,None)
|
||||
metadata=json.loads(row[4])
|
||||
assert metadata["execution"]["action"]=="agent_runtime"
|
||||
assert metadata["execution"]["adapter"]=="openclaw"
|
||||
assert metadata["execution"]["governance"]["writer_lease"] is False
|
||||
assert json.loads(row[5])==[CAPABILITY]
|
||||
assert json.loads(row[6])==result
|
||||
assert json.loads(row[7])==verification
|
||||
events=con.execute("SELECT event_type,payload_json FROM events ORDER BY event_id").fetchall()
|
||||
assert [event[0] for event in events]==[
|
||||
"TASK_CREATED","TASK_CLAIMED","TASK_HEARTBEAT","RESULT_SUBMITTED",
|
||||
"TASK_VERIFIED","TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZED",
|
||||
]
|
||||
payload=json.loads(events[-1][1])
|
||||
assert payload["recovery_from_source_capture"] is True
|
||||
assert payload["source_capture_execution_digest"]==result["execution_digest"]
|
||||
assert payload["main_write"] is False
|
||||
assert payload["production_write"] is False
|
||||
|
||||
|
||||
def test_source_capture_recovery_idempotent_exact_replay(tmp_path):
|
||||
path,result,verification=make_source_capture_rework_db(tmp_path)
|
||||
store=InitialExecutionProjectionStore(path,resolver)
|
||||
first=store.project_from_source_capture(TASK_ID,recovery_request(result,verification))
|
||||
second=store.project_from_source_capture(TASK_ID,recovery_request(result,verification))
|
||||
assert first["idempotent"] is False
|
||||
assert second["idempotent"] is True
|
||||
assert second["execution_digest"]==first["execution_digest"]
|
||||
con=sqlite3.connect(path)
|
||||
assert con.execute(
|
||||
"SELECT COUNT(*) FROM task_source_capture_projection_recoveries"
|
||||
).fetchone()[0]==1
|
||||
assert con.execute("SELECT COUNT(*) FROM events").fetchone()[0]==6
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutate,error",
|
||||
[
|
||||
(lambda result,verification: result.__setitem__("changed_files",["x"]),
|
||||
"SOURCE_CAPTURE_RESULT_CONTRACT_INVALID"),
|
||||
(lambda result,verification: result.__setitem__("job_id","job-other"),
|
||||
"SOURCE_CAPTURE_RESULT_CONTRACT_INVALID"),
|
||||
(lambda result,verification: verification.__setitem__("decision","SUCCEEDED"),
|
||||
"SOURCE_CAPTURE_NEEDS_REWORK_VERIFICATION_REQUIRED"),
|
||||
],
|
||||
)
|
||||
def test_source_capture_recovery_rejects_unsafe_result_or_verification(
|
||||
tmp_path,mutate,error
|
||||
):
|
||||
path,result,verification=make_source_capture_rework_db(tmp_path,mutate=mutate)
|
||||
with pytest.raises(InitialExecutionProjectionError,match=error):
|
||||
InitialExecutionProjectionStore(path,resolver).project_from_source_capture(
|
||||
TASK_ID,recovery_request(result,verification)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"events",
|
||||
[
|
||||
["TASK_CLAIMED","RESULT_SUBMITTED","TASK_VERIFIED"],
|
||||
["TASK_CLAIMED","TASK_HEARTBEAT","RESULT_SUBMITTED","TASK_VERIFIED","TASK_CLAIMED"],
|
||||
["TASK_CLAIMED","TASK_HEARTBEAT","TASK_VERIFIED","RESULT_SUBMITTED"],
|
||||
],
|
||||
)
|
||||
def test_source_capture_recovery_rejects_invalid_event_chain(tmp_path,events):
|
||||
path,result,verification=make_source_capture_rework_db(tmp_path,events=events)
|
||||
with pytest.raises(
|
||||
InitialExecutionProjectionError,match="SOURCE_CAPTURE_RECOVERY_EVENT_CHAIN_INVALID"
|
||||
):
|
||||
InitialExecutionProjectionStore(path,resolver).project_from_source_capture(
|
||||
TASK_ID,recovery_request(result,verification)
|
||||
)
|
||||
|
||||
|
||||
def test_source_capture_recovery_rejects_execution_digest_drift(tmp_path):
|
||||
path,result,verification=make_source_capture_rework_db(tmp_path)
|
||||
with pytest.raises(InitialExecutionProjectionError,match="EXECUTION_DIGEST_DRIFT"):
|
||||
InitialExecutionProjectionStore(path,resolver).project_from_source_capture(
|
||||
TASK_ID,recovery_request(result,verification,digest="f"*64)
|
||||
)
|
||||
|
||||
|
||||
def test_source_capture_recovery_request_is_strict(tmp_path):
|
||||
_path,result,verification=make_source_capture_rework_db(tmp_path)
|
||||
data=recovery_request(result,verification).public()
|
||||
data["metadata_patch"]={"execution":"forbidden"}
|
||||
with pytest.raises(InitialExecutionProjectionError,match="FIELDS_INVALID"):
|
||||
SourceCaptureProjectionRecoveryRequest.from_mapping(data)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs,error",
|
||||
[
|
||||
({"claimed_by":"worker-a"},"TASK_ALREADY_CLAIMED"),
|
||||
({"lease":True},"ACTIVE_LEASE_DENY"),
|
||||
({"metadata":{"execution":{}}},"TASK_EXECUTION_ALREADY_PROJECTED"),
|
||||
({"caps":["git.read"]},"TASK_INITIAL_CAPABILITIES_MUST_BE_EMPTY"),
|
||||
({"event":"TASK_CLAIMED"},"TASK_INITIAL_EVENT_CHAIN_REQUIRED"),
|
||||
],
|
||||
)
|
||||
def test_initial_projection_rejects_noninitial_state(tmp_path,kwargs,error):
|
||||
path=make_db(tmp_path,**kwargs)
|
||||
with pytest.raises(InitialExecutionProjectionError,match=error):
|
||||
InitialExecutionProjectionStore(path,resolver).project(TASK_ID,request())
|
||||
|
||||
|
||||
def test_initial_projection_rejects_binding_drift(tmp_path):
|
||||
path=make_db(tmp_path)
|
||||
def wrong(*_args):
|
||||
return envelope(job_id="job-other")
|
||||
with pytest.raises(InitialExecutionProjectionError,match="GOVERNED_BINDING_CONFLICT"):
|
||||
InitialExecutionProjectionStore(path,wrong).project(TASK_ID,request())
|
||||
|
||||
|
||||
def test_initial_projection_rejects_nonruntime_envelope(tmp_path):
|
||||
path=make_db(tmp_path)
|
||||
def wrong(*_args):
|
||||
return envelope(action="source_capture",capability="git.read")
|
||||
with pytest.raises(InitialExecutionProjectionError):
|
||||
InitialExecutionProjectionStore(path,wrong).project(TASK_ID,request())
|
||||
|
||||
|
||||
def test_initial_projection_rejects_execution_digest_drift(tmp_path):
|
||||
path=make_db(tmp_path)
|
||||
with pytest.raises(InitialExecutionProjectionError,match="EXECUTION_DIGEST_DRIFT"):
|
||||
InitialExecutionProjectionStore(path,resolver).project(
|
||||
TASK_ID,request(digest="f"*64)
|
||||
)
|
||||
|
||||
|
||||
def test_request_is_narrow_and_initial_state_bound():
|
||||
data=request().public()
|
||||
data["arbitrary_metadata"]={"x":1}
|
||||
with pytest.raises(InitialExecutionProjectionError,match="FIELDS_INVALID"):
|
||||
InitialExecutionProjectionRequest.from_mapping(data)
|
||||
data=request().public();data["expected_attempt"]=1
|
||||
with pytest.raises(InitialExecutionProjectionError,match="INITIAL_STATE_REQUIRED"):
|
||||
InitialExecutionProjectionRequest.from_mapping(data)
|
||||
|
||||
|
||||
def test_initial_projection_unit_is_unprivileged_and_owner_credential_bound():
|
||||
unit=(ROOT/"deploy/systemd/aios-agent-task-initial-execution-projection.service").read_text()
|
||||
assert "User=aios-agent-task" in unit
|
||||
assert "Group=aios-agent-task" in unit
|
||||
assert "LoadCredential=agent_task_control_token:" in unit
|
||||
assert "Environment=AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892" in unit
|
||||
assert "RestrictAddressFamilies=AF_UNIX AF_INET" in unit
|
||||
assert "ReadWritePaths=/var/lib/aios-agent-task" in unit
|
||||
assert "User=root" not in unit
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
APP = Path(__file__).resolve().parents[1] / "active-runtime" / "app"
|
||||
if str(APP) not in sys.path:
|
||||
sys.path.insert(0, str(APP))
|
||||
|
||||
from aios_gitops_job_identity import ( # noqa: E402
|
||||
JobIdentityError,
|
||||
legacy_task_storage_key,
|
||||
task_storage_key,
|
||||
)
|
||||
|
||||
|
||||
def test_collision_resistant_key_separates_historical_lossy_collision() -> None:
|
||||
first = "TASK/A"
|
||||
second = "TASK:A"
|
||||
|
||||
assert legacy_task_storage_key(first) == legacy_task_storage_key(second) == "TASK-A"
|
||||
assert task_storage_key(first).startswith("TASK-A-")
|
||||
assert task_storage_key(second).startswith("TASK-A-")
|
||||
assert task_storage_key(first) != task_storage_key(second)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["", " task", "task ", "x" * 513, None])
|
||||
def test_task_storage_key_rejects_noncanonical_task_identity(value: object) -> None:
|
||||
with pytest.raises(JobIdentityError, match="TASK_ID_INVALID"):
|
||||
task_storage_key(value) # type: ignore[arg-type]
|
||||
@@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from aios_authority_git_executor import AuthorityGitExecutor
|
||||
@@ -24,6 +25,22 @@ class Probe:
|
||||
return True
|
||||
|
||||
|
||||
def test_authority_create_task_branch_is_agent_scoped_and_idempotent(tmp_path):
|
||||
bare = tmp_path / "repo.git"
|
||||
subprocess.run(["git", "init", "--bare", str(bare)], check=True, capture_output=True)
|
||||
source = tmp_path / "source"
|
||||
subprocess.run(["git", "init", str(source)], check=True, capture_output=True)
|
||||
(source / "README.md").write_text("fixture\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(source), "add", "README.md"], check=True)
|
||||
subprocess.run(["git", "-C", str(source), "-c", "user.name=test", "-c", "user.email=test@example.invalid", "commit", "-m", "fixture"], check=True, capture_output=True)
|
||||
base = subprocess.check_output(["git", "-C", str(source), "rev-parse", "HEAD"], text=True).strip()
|
||||
executor = AuthorityGitExecutor(bare, tmp_path / "worktrees")
|
||||
subprocess.run(["git", "-C", str(source), "push", str(bare), "HEAD:refs/heads/main"], check=True, capture_output=True)
|
||||
assert executor.create_task_branch("refs/heads/agent/contract-test", base) == base
|
||||
assert executor.create_task_branch("refs/heads/agent/contract-test", base) == base
|
||||
assert executor.read_ref("refs/heads/agent/contract-test") == base
|
||||
|
||||
|
||||
def test_create_new_lease_is_exact_base_and_absent():
|
||||
request = WriterLeaseRequestV2(
|
||||
task_id="TASK-P0-BRIDGE-V21-DURABLE-RECONCILIATION-20260809",
|
||||
@@ -75,6 +92,7 @@ def test_slash_branch_uses_paginated_collection():
|
||||
def test_http_route_contract_is_complete():
|
||||
expected = {
|
||||
"/v2/runtime", "/v2/capabilities", "/v2/governed-task",
|
||||
"/v2/execution-envelope",
|
||||
"/v2/writer-lease", "/v2/commit-file", "/v2/commit-files",
|
||||
"/v2/job", "/v2/job/resume", "/v2/job/reconcile",
|
||||
"/v2/tag/approval-request",
|
||||
@@ -84,6 +102,13 @@ def test_http_route_contract_is_complete():
|
||||
"/v2/release/execute",
|
||||
"/v2/release/consistency",
|
||||
"/v2/release/acceptance",
|
||||
"/v2/repository/tree",
|
||||
"/v2/repository/search",
|
||||
"/v2/repository/diff-index",
|
||||
"/v2/repository/file",
|
||||
"/v2/task-admission/envelope",
|
||||
"/v2/task-admission/authorize",
|
||||
"/v2/task-admission/status",
|
||||
}
|
||||
from aios_chatgpt_bridge_v2_server import ROUTES
|
||||
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
|
||||
from aios_agent_task_projection_recovery import (
|
||||
CanonicalTaskProjectionIntent,
|
||||
CanonicalTaskProjectionStore,
|
||||
ProjectionRecoveryError,
|
||||
ProjectionState,
|
||||
sha256_json,
|
||||
)
|
||||
from aios_agent_task_projection_recovery_http import recover_from_json
|
||||
|
||||
|
||||
TASK_ID = "TASK-CONV-V2R2C2-RECONCILE-SNAPSHOT-20260826"
|
||||
JOB_ID = "job-9eee1142f2fc8bb7b571d065c01f40e0"
|
||||
OID = "2afc2cf41fc9090cf01e3882d1b8db8d54cf1844"
|
||||
RUNBOOK_SHA = "a500504a76fb99b26541eaf4edd842a942992ae170fc0f3838530189ca672320"
|
||||
|
||||
|
||||
def make_db(tmp_path: Path) -> Path:
|
||||
path = tmp_path / "state.sqlite3"
|
||||
con = sqlite3.connect(path)
|
||||
con.executescript(
|
||||
"""
|
||||
CREATE TABLE tasks(
|
||||
task_id TEXT PRIMARY KEY,
|
||||
goal TEXT NOT NULL,
|
||||
instructions TEXT NOT NULL,
|
||||
success_criteria_json TEXT NOT NULL,
|
||||
required_capabilities_json TEXT NOT NULL,
|
||||
constraints_json TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL,
|
||||
deadline TEXT,
|
||||
priority INTEGER NOT NULL,
|
||||
max_attempts INTEGER NOT NULL,
|
||||
attempt INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL,
|
||||
claimed_by TEXT,
|
||||
lease_token_hash TEXT,
|
||||
lease_expires_at TEXT,
|
||||
result_json TEXT,
|
||||
result_digest TEXT,
|
||||
result_idempotency_key TEXT,
|
||||
verification_json TEXT,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE events(
|
||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT,
|
||||
agent_id TEXT,
|
||||
event_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
return path
|
||||
|
||||
|
||||
def manifest() -> dict:
|
||||
task = {
|
||||
"task_id": TASK_ID,
|
||||
"goal": "Complete V2R2C2 staging convergence",
|
||||
"instructions": "Resume the immutable runbook and close the parent goal.",
|
||||
"success_criteria": [{"id": "closure", "required": True, "expected": "PASS"}],
|
||||
"constraints": {
|
||||
"staging_only": True,
|
||||
"production_write": False,
|
||||
"main_direct_write": False,
|
||||
},
|
||||
"required_capabilities": [],
|
||||
"metadata": {
|
||||
"governed_task_id": TASK_ID,
|
||||
"bridge_job_id": JOB_ID,
|
||||
"runbook_sha256": RUNBOOK_SHA,
|
||||
},
|
||||
"priority": 100,
|
||||
"max_attempts": 20,
|
||||
"deadline": None,
|
||||
}
|
||||
return {
|
||||
"schema": "aios.canonical-task-projection-intent.v1",
|
||||
"projection_id": "projection-v2r2c2-parent-recovery-20260826",
|
||||
"projection_generation": 1,
|
||||
"idempotency_key": "projection-v2r2c2-parent-recovery-20260826-v1",
|
||||
"task": task,
|
||||
"governed_binding": {
|
||||
"governed_task_id": TASK_ID,
|
||||
"bridge_job_id": JOB_ID,
|
||||
"repository": "aios/aios",
|
||||
"branch": "agent/v2r2c2-convergence-integration-20260825",
|
||||
"generation": 2,
|
||||
"base_oid": OID,
|
||||
"expected_branch_oid": OID,
|
||||
},
|
||||
"source_artifact": {
|
||||
"ref": f"git:{OID}:runbooks/agent-v2r2c2-control-plane-convergence-20260825.md",
|
||||
"sha256": RUNBOOK_SHA,
|
||||
},
|
||||
"task_payload_sha256": sha256_json(task),
|
||||
}
|
||||
|
||||
|
||||
def test_missing_task_is_recovered_with_same_identity(tmp_path):
|
||||
db = make_db(tmp_path)
|
||||
intent = CanonicalTaskProjectionIntent.from_mapping(manifest())
|
||||
result = CanonicalTaskProjectionStore(db).reconcile(intent)
|
||||
|
||||
assert result["state"] == ProjectionState.CONVERGED.value
|
||||
assert result["canonical_task_id"] == TASK_ID
|
||||
assert result["bridge_job_id"] == JOB_ID
|
||||
assert result["created_new_row"] is True
|
||||
|
||||
con = sqlite3.connect(db)
|
||||
row = con.execute(
|
||||
"SELECT task_id,status,attempt,revision,claimed_by,"
|
||||
"required_capabilities_json,metadata_json FROM tasks WHERE task_id=?",
|
||||
(TASK_ID,),
|
||||
).fetchone()
|
||||
assert row[:5] == (TASK_ID, "QUEUED", 0, 1, None)
|
||||
assert json.loads(row[5]) == []
|
||||
metadata = json.loads(row[6])
|
||||
assert "execution" not in metadata
|
||||
assert "execution_projection" not in metadata
|
||||
events = con.execute(
|
||||
"SELECT event_type FROM events WHERE task_id=? ORDER BY event_id",
|
||||
(TASK_ID,),
|
||||
).fetchall()
|
||||
assert events == [("TASK_CREATED",)]
|
||||
|
||||
|
||||
def test_replay_is_idempotent_and_creates_no_duplicate_event(tmp_path):
|
||||
db = make_db(tmp_path)
|
||||
intent = CanonicalTaskProjectionIntent.from_mapping(manifest())
|
||||
store = CanonicalTaskProjectionStore(db)
|
||||
first = store.reconcile(intent)
|
||||
second = store.reconcile(intent)
|
||||
|
||||
assert first["idempotent"] is False
|
||||
assert second["idempotent"] is True
|
||||
con = sqlite3.connect(db)
|
||||
assert con.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] == 1
|
||||
assert con.execute("SELECT COUNT(*) FROM events").fetchone()[0] == 1
|
||||
assert con.execute(
|
||||
"SELECT COUNT(*) FROM task_projection_receipts"
|
||||
).fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_existing_matching_row_converges_without_second_task_created_event(tmp_path):
|
||||
db = make_db(tmp_path)
|
||||
intent = CanonicalTaskProjectionIntent.from_mapping(manifest())
|
||||
store = CanonicalTaskProjectionStore(db)
|
||||
store.reconcile(intent)
|
||||
|
||||
con = sqlite3.connect(db)
|
||||
con.execute("DELETE FROM task_projection_receipts")
|
||||
con.execute("DELETE FROM task_projection_intents")
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
result = store.reconcile(intent)
|
||||
assert result["created_new_row"] is False
|
||||
|
||||
con = sqlite3.connect(db)
|
||||
assert con.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] == 1
|
||||
assert con.execute("SELECT COUNT(*) FROM events").fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_existing_different_task_payload_fails_closed(tmp_path):
|
||||
db = make_db(tmp_path)
|
||||
intent = CanonicalTaskProjectionIntent.from_mapping(manifest())
|
||||
store = CanonicalTaskProjectionStore(db)
|
||||
store.reconcile(intent)
|
||||
|
||||
con = sqlite3.connect(db)
|
||||
con.execute("DELETE FROM task_projection_receipts")
|
||||
con.execute("DELETE FROM task_projection_intents")
|
||||
con.execute("UPDATE tasks SET goal='drifted'")
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
with pytest.raises(
|
||||
ProjectionRecoveryError,
|
||||
match="CANONICAL_TASK_PROJECTION_IDENTITY_CONFLICT",
|
||||
):
|
||||
store.reconcile(intent)
|
||||
|
||||
|
||||
def test_intent_replay_with_same_generation_but_different_request_conflicts(tmp_path):
|
||||
db = make_db(tmp_path)
|
||||
first = manifest()
|
||||
intent = CanonicalTaskProjectionIntent.from_mapping(first)
|
||||
store = CanonicalTaskProjectionStore(db)
|
||||
store.persist_intent(intent)
|
||||
|
||||
other = manifest()
|
||||
other["task"]["instructions"] = "different"
|
||||
other["task_payload_sha256"] = sha256_json(other["task"])
|
||||
other_intent = CanonicalTaskProjectionIntent.from_mapping(other)
|
||||
with pytest.raises(
|
||||
ProjectionRecoveryError,
|
||||
match="PROJECTION_INTENT_IDEMPOTENCY_CONFLICT",
|
||||
):
|
||||
store.persist_intent(other_intent)
|
||||
|
||||
|
||||
def test_recovery_refuses_non_pristine_execution_metadata(tmp_path):
|
||||
value = manifest()
|
||||
value["task"]["metadata"]["execution"] = {"action": "agent_runtime"}
|
||||
value["task_payload_sha256"] = sha256_json(value["task"])
|
||||
with pytest.raises(
|
||||
ProjectionRecoveryError,
|
||||
match="TASK_RECOVERY_REQUIRES_PRISTINE_METADATA",
|
||||
):
|
||||
CanonicalTaskProjectionIntent.from_mapping(value)
|
||||
|
||||
|
||||
def test_recovery_refuses_runtime_capability_in_pristine_row(tmp_path):
|
||||
value = manifest()
|
||||
value["task"]["required_capabilities"] = ["agent.runtime.openclaw"]
|
||||
value["task_payload_sha256"] = sha256_json(value["task"])
|
||||
with pytest.raises(
|
||||
ProjectionRecoveryError,
|
||||
match="TASK_RECOVERY_REQUIRES_PRISTINE_CAPABILITIES",
|
||||
):
|
||||
CanonicalTaskProjectionIntent.from_mapping(value)
|
||||
|
||||
|
||||
def test_http_adapter_rejects_path_body_task_mismatch(tmp_path):
|
||||
db = make_db(tmp_path)
|
||||
status, body = recover_from_json(db, "TASK-other-123", manifest())
|
||||
assert status == 409
|
||||
assert body["error"] == "PROJECTION_TASK_IDENTITY_MISMATCH"
|
||||
|
||||
|
||||
def test_projection_readback_contains_durable_mapping_and_receipt(tmp_path):
|
||||
db = make_db(tmp_path)
|
||||
intent = CanonicalTaskProjectionIntent.from_mapping(manifest())
|
||||
store = CanonicalTaskProjectionStore(db)
|
||||
store.reconcile(intent)
|
||||
|
||||
projection = store.get_projection(TASK_ID)
|
||||
assert projection is not None
|
||||
assert projection["task_id"] == TASK_ID
|
||||
assert projection["bridge_job_id"] == JOB_ID
|
||||
assert projection["state"] == "CONVERGED"
|
||||
assert projection["receipt"]["canonical_task_id"] == TASK_ID
|
||||
@@ -0,0 +1,333 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_gitops_job_controller import BindingConflict, GitOpsJobController, JobError, JobState, JobStore, sha256_json
|
||||
|
||||
|
||||
TASK = "TASK-CONV-V2R2C2-RECONCILE-SNAPSHOT-20260826"
|
||||
EXTERNAL = "job-external-v7"
|
||||
BRANCH = "agent/v2r2c2-convergence-integration-20260825"
|
||||
BASE = "e" * 40
|
||||
COMMIT = "1" * 40
|
||||
|
||||
|
||||
def make_store(tmp_path: Path) -> tuple[GitOpsJobController, str, dict]:
|
||||
store = JobStore(tmp_path / "job-root")
|
||||
controller = GitOpsJobController(store)
|
||||
request = {
|
||||
"idempotency_key": "v7-replay",
|
||||
"campaign_id": TASK,
|
||||
"task_id": TASK,
|
||||
"external_job_id": EXTERNAL,
|
||||
"repository": "aios/aios",
|
||||
"operation": "CONTINUATION_COMMIT_FILES",
|
||||
"branch": BRANCH,
|
||||
"target_branch": BRANCH,
|
||||
"branch_mode": "CONTINUE_EXISTING",
|
||||
"base_oid": BASE,
|
||||
"lineage_anchor_oid": BASE,
|
||||
"expected_branch_oid": BASE,
|
||||
"generation": 2,
|
||||
"payload_digest": "a" * 64,
|
||||
"files_digest": "b" * 64,
|
||||
"message_digest": "c" * 64,
|
||||
"worker_principal": "aios-autonomy-agent",
|
||||
"worker_lineage": "governed-continuation",
|
||||
}
|
||||
receipt = controller.submit(request)
|
||||
controller.advance(receipt.job_id, JobState.AUTHORIZED, side_effect_state="AUTHORIZED")
|
||||
row = store.read(receipt.job_id)
|
||||
controller.lease_job(
|
||||
receipt.job_id,
|
||||
lease_id="lease-v7-epoch-3",
|
||||
lineage_anchor_oid=BASE,
|
||||
current_head_oid=BASE,
|
||||
generation=2,
|
||||
lease_expires_at="2099-01-01T00:00:00Z",
|
||||
expected_revision=row["revision"],
|
||||
)
|
||||
with store._connect() as connection:
|
||||
connection.execute("ALTER TABLE branch_leases ADD COLUMN epoch INTEGER")
|
||||
connection.execute("UPDATE branch_leases SET epoch=3, state='USED' WHERE job_id=?", (receipt.job_id,))
|
||||
connection.execute("CREATE TABLE branch_lease_events (lease_id TEXT NOT NULL, state TEXT NOT NULL, current_head_oid TEXT NOT NULL, generation INTEGER NOT NULL)")
|
||||
connection.execute("INSERT INTO branch_lease_events VALUES (?, 'USED', ?, 2)", ("lease-v7-epoch-3", BASE))
|
||||
controller.advance(receipt.job_id, JobState.WORKTREE_READY, side_effect_state="WORKTREE_READY")
|
||||
controller.advance(receipt.job_id, JobState.EXECUTING, side_effect_state="IN_PROGRESS")
|
||||
completed = {
|
||||
"commit_oid": COMMIT,
|
||||
"authority_oid": COMMIT,
|
||||
"remote_verified": True,
|
||||
"worker_principal": request["worker_principal"],
|
||||
"worker_lineage": request["worker_lineage"],
|
||||
}
|
||||
controller.advance(receipt.job_id, JobState.REMOTE_VERIFIED, side_effect_state="REMOTE_VERIFIED", receipt=completed)
|
||||
final = controller.advance(receipt.job_id, JobState.COMPLETED, side_effect_state="REMOTE_VERIFIED", receipt=completed)
|
||||
row = store.read(receipt.job_id)
|
||||
envelope = {
|
||||
"task_id": TASK,
|
||||
"external_job_id": EXTERNAL,
|
||||
"internal_job_id": receipt.job_id,
|
||||
"repository": "aios/aios",
|
||||
"target_branch": BRANCH,
|
||||
"generation": 2,
|
||||
"branch_mode": "CONTINUE_EXISTING",
|
||||
"payload_digest": request["payload_digest"],
|
||||
"files_digest": request["files_digest"],
|
||||
"message_digest": request["message_digest"],
|
||||
"commit_oid": COMMIT,
|
||||
"authority_head_oid": COMMIT,
|
||||
"worker_principal": request["worker_principal"],
|
||||
"worker_lineage": request["worker_lineage"],
|
||||
"source_lease_epoch": 3,
|
||||
"historical_lease_head_oid": BASE,
|
||||
"durable_revision": row["revision"],
|
||||
"completion_identity": sha256_json({"job_id": receipt.job_id, "revision": row["revision"], "receipt": completed}),
|
||||
"reason": "COMPLETED_REPLAY_LEASE_EVIDENCE_RECONCILIATION",
|
||||
"provenance": {"source": "v7-test", "authority_readback": True},
|
||||
}
|
||||
assert final.state is JobState.COMPLETED
|
||||
return controller, receipt.job_id, envelope
|
||||
|
||||
|
||||
def test_completed_replay_is_append_only_and_exactly_idempotent(tmp_path: Path):
|
||||
controller, job_id, envelope = make_store(tmp_path)
|
||||
with controller.store._connect() as connection:
|
||||
before = dict(connection.execute("SELECT * FROM branch_leases WHERE job_id=?", (job_id,)).fetchone())
|
||||
before_events = [dict(row) for row in connection.execute("SELECT * FROM branch_lease_events WHERE lease_id=?", ("lease-v7-epoch-3",)).fetchall()]
|
||||
old_receipt = connection.execute("SELECT receipt_json FROM jobs WHERE job_id=?", (job_id,)).fetchone()[0]
|
||||
first = controller.store.append_completed_replay_reconciliation(job_id, envelope, authority_head_oid=COMMIT)
|
||||
second = controller.store.append_completed_replay_reconciliation(job_id, envelope, authority_head_oid=COMMIT)
|
||||
restarted = GitOpsJobController(JobStore(controller.store.root))
|
||||
third = restarted.store.append_completed_replay_reconciliation(job_id, envelope, authority_head_oid=COMMIT)
|
||||
assert first["reconciliation_id"] == second["reconciliation_id"] == third["reconciliation_id"]
|
||||
assert first["current_head_oid"] == COMMIT
|
||||
with controller.store._connect() as connection:
|
||||
after = dict(connection.execute("SELECT * FROM branch_leases WHERE job_id=?", (job_id,)).fetchone())
|
||||
after_events = [dict(row) for row in connection.execute("SELECT * FROM branch_lease_events WHERE lease_id=?", ("lease-v7-epoch-3",)).fetchall()]
|
||||
new_receipt = connection.execute("SELECT receipt_json FROM jobs WHERE job_id=?", (job_id,)).fetchone()[0]
|
||||
count = connection.execute("SELECT COUNT(*) FROM completed_replay_reconciliations WHERE job_id=?", (job_id,)).fetchone()[0]
|
||||
assert before == after
|
||||
assert before_events == after_events
|
||||
assert old_receipt == new_receipt
|
||||
assert count == 1
|
||||
with pytest.raises(sqlite3.IntegrityError, match="COMPLETED_REPLAY_APPEND_ONLY"):
|
||||
with controller.store._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE completed_replay_reconciliations SET reason='tampered' WHERE job_id=?",
|
||||
(job_id,),
|
||||
)
|
||||
with pytest.raises(sqlite3.IntegrityError, match="COMPLETED_REPLAY_APPEND_ONLY"):
|
||||
with controller.store._connect() as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM completed_replay_reconciliations WHERE job_id=?",
|
||||
(job_id,),
|
||||
)
|
||||
|
||||
|
||||
def test_completed_replay_authority_conflict_fails_closed(tmp_path: Path):
|
||||
controller, job_id, envelope = make_store(tmp_path)
|
||||
with pytest.raises(BindingConflict, match="AUTHORITY_CONFLICT"):
|
||||
controller.store.append_completed_replay_reconciliation(job_id, envelope, authority_head_oid="2" * 40)
|
||||
|
||||
|
||||
def test_completed_replay_binding_conflict_fails_closed(tmp_path: Path):
|
||||
controller, job_id, envelope = make_store(tmp_path)
|
||||
conflicting = {**envelope, "files_digest": "d" * 64}
|
||||
with pytest.raises(BindingConflict, match="BINDING_CONFLICT"):
|
||||
controller.store.append_completed_replay_reconciliation(job_id, conflicting, authority_head_oid=COMMIT)
|
||||
|
||||
|
||||
def test_identical_reconciliation_converges_under_contention(tmp_path: Path):
|
||||
controller, job_id, envelope = make_store(tmp_path)
|
||||
|
||||
def run(_: int) -> str:
|
||||
return controller.store.append_completed_replay_reconciliation(
|
||||
job_id, envelope, authority_head_oid=COMMIT
|
||||
)["reconciliation_id"]
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
values = list(pool.map(run, range(16)))
|
||||
assert set(values) == {values[0]}
|
||||
assert controller.store.completed_replay_evidence(job_id)["commit_oid"] == COMMIT
|
||||
|
||||
|
||||
|
||||
def test_public_job_projection_exposes_external_reconciled_head(tmp_path: Path):
|
||||
from aios_chatgpt_bridge_v2_server import GovernedRuntime
|
||||
|
||||
controller, job_id, envelope = make_store(tmp_path)
|
||||
reconciliation = controller.store.append_completed_replay_reconciliation(
|
||||
job_id, envelope, authority_head_oid=COMMIT
|
||||
)
|
||||
|
||||
class Authority:
|
||||
def read_ref(self, ref: str):
|
||||
assert ref == "refs/heads/" + BRANCH
|
||||
return COMMIT
|
||||
|
||||
runtime = object.__new__(GovernedRuntime)
|
||||
runtime.authority = Authority()
|
||||
runtime._controller_for_job = lambda _job_id: controller
|
||||
result = GovernedRuntime.job(runtime, {"job_id": job_id}, "get")
|
||||
assert result["job_id"] == EXTERNAL
|
||||
assert result["current_head_oid"] == COMMIT
|
||||
assert result["completed_replay_reconciliation_id"] == reconciliation["reconciliation_id"]
|
||||
assert result["receipt"]["commit_oid"] == result["receipt"]["authority_oid"] == COMMIT
|
||||
|
||||
|
||||
def test_writer_lease_admits_exact_completed_replay_without_new_lease(tmp_path: Path):
|
||||
from aios_chatgpt_bridge_v2_server import GovernedRuntime
|
||||
|
||||
controller, job_id, envelope = make_store(tmp_path)
|
||||
controller.store.append_completed_replay_reconciliation(
|
||||
job_id, envelope, authority_head_oid=COMMIT
|
||||
)
|
||||
|
||||
class Authority:
|
||||
def read_ref(self, ref: str):
|
||||
assert ref == "refs/heads/" + BRANCH
|
||||
return COMMIT
|
||||
|
||||
runtime = object.__new__(GovernedRuntime)
|
||||
runtime.config = SimpleNamespace(state_root=tmp_path)
|
||||
runtime.controllers = {"job": controller}
|
||||
runtime.authority = Authority()
|
||||
body = {
|
||||
"task_id": TASK,
|
||||
"job_id": EXTERNAL,
|
||||
"repository": "aios/aios",
|
||||
"branch_mode": "CONTINUE_EXISTING",
|
||||
"base_oid": BASE,
|
||||
"expected_branch_oid": BASE,
|
||||
"target_branch": BRANCH,
|
||||
"generation": 2,
|
||||
"lease_seconds": 900,
|
||||
}
|
||||
result = GovernedRuntime.acquire_lease(runtime, body)
|
||||
assert result["completed_replay"] is True
|
||||
assert result["lease_created"] is False
|
||||
assert result["expected_branch_oid"] == BASE
|
||||
assert result["current_head_oid"] == COMMIT
|
||||
assert result["completed_replay_reconciliation_id"].startswith("reconcile-")
|
||||
with controller.store._connect() as connection:
|
||||
assert connection.execute(
|
||||
"SELECT COUNT(*) FROM branch_leases WHERE job_id=?", (job_id,)
|
||||
).fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_conflicting_reconciliation_has_no_ambiguous_winner(tmp_path: Path):
|
||||
controller, job_id, envelope = make_store(tmp_path)
|
||||
conflicting = {**envelope, "reason": "CONFLICTING_PROVENANCE"}
|
||||
values = [envelope, conflicting] * 8
|
||||
|
||||
def run(candidate: dict) -> str:
|
||||
try:
|
||||
result = controller.store.append_completed_replay_reconciliation(
|
||||
job_id, candidate, authority_head_oid=COMMIT
|
||||
)
|
||||
return "ok:" + result["identity_digest"]
|
||||
except BindingConflict:
|
||||
return "conflict"
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
outcomes = list(pool.map(run, values))
|
||||
winners = {item for item in outcomes if item.startswith("ok:")}
|
||||
assert len(winners) == 1
|
||||
assert "conflict" in outcomes
|
||||
with controller.store._connect() as connection:
|
||||
assert connection.execute(
|
||||
"SELECT COUNT(*) FROM completed_replay_reconciliations WHERE job_id=?", (job_id,)
|
||||
).fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_reconciliation_rejects_head_alias_before_append(tmp_path: Path):
|
||||
controller, job_id, envelope = make_store(tmp_path)
|
||||
conflicting = {**envelope, "historical_lease_head_oid": COMMIT}
|
||||
with pytest.raises(BindingConflict, match="HISTORICAL_LEASE_CONFLICT"):
|
||||
controller.store.append_completed_replay_reconciliation(
|
||||
job_id, conflicting, authority_head_oid=COMMIT
|
||||
)
|
||||
with controller.store._connect() as connection:
|
||||
assert connection.execute(
|
||||
"SELECT COUNT(*) FROM completed_replay_reconciliations WHERE job_id=?",
|
||||
(job_id,),
|
||||
).fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_http_commit_allowlist_carries_replay_identity(tmp_path: Path):
|
||||
from aios_chatgpt_bridge_v2_server import BridgeHandler
|
||||
|
||||
handler = object.__new__(BridgeHandler)
|
||||
body = {
|
||||
"task_id": TASK,
|
||||
"job_id": EXTERNAL,
|
||||
"source_lease_epoch": 3,
|
||||
"repository": "aios/aios",
|
||||
"expected_branch_oid": BASE,
|
||||
"target_branch": BRANCH,
|
||||
"generation": 2,
|
||||
"idempotency_key": "v7-replay",
|
||||
"files": [{"path": "x.txt", "content_base64": "eA=="}],
|
||||
"message": "replay",
|
||||
}
|
||||
handler._allowed("commitFilesGoverned", body)
|
||||
|
||||
|
||||
def test_cold_append_requires_existing_database(tmp_path: Path):
|
||||
missing = tmp_path / "missing-job-root"
|
||||
with pytest.raises(JobError, match="JOB_ROOT_NOT_FOUND"):
|
||||
JobStore.for_existing_append(missing)
|
||||
assert not missing.exists()
|
||||
|
||||
|
||||
def test_real_http_post_carries_external_replay_identity(tmp_path: Path):
|
||||
from http.client import HTTPConnection
|
||||
from http.server import HTTPServer
|
||||
from threading import Thread
|
||||
from aios_chatgpt_bridge_v2_server import BridgeHandler
|
||||
|
||||
observed = {}
|
||||
class Adapter:
|
||||
def commitFilesGoverned(self, body):
|
||||
observed.update(body)
|
||||
return {"job_id": body["job_id"], "current_head_oid": COMMIT}
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), BridgeHandler)
|
||||
BridgeHandler.adapter = Adapter()
|
||||
thread = Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
body = {
|
||||
"task_id": TASK,
|
||||
"job_id": EXTERNAL,
|
||||
"source_lease_epoch": 3,
|
||||
"repository": "aios/aios",
|
||||
"expected_branch_oid": BASE,
|
||||
"target_branch": BRANCH,
|
||||
"generation": 2,
|
||||
"idempotency_key": "v7-replay",
|
||||
"files": [{"path": "x.txt", "content_base64": "eA=="}],
|
||||
"message": "replay",
|
||||
}
|
||||
try:
|
||||
connection = HTTPConnection("127.0.0.1", server.server_port, timeout=5)
|
||||
connection.request("POST", "/v2/commit-files", body=json.dumps(body), headers={"Content-Type": "application/json"})
|
||||
response = connection.getresponse()
|
||||
payload = json.loads(response.read().decode())
|
||||
connection.close()
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
BridgeHandler.adapter = None
|
||||
assert response.status == 200
|
||||
assert observed["job_id"] == EXTERNAL
|
||||
assert observed["source_lease_epoch"] == 3
|
||||
assert payload["job_id"] == EXTERNAL
|
||||
assert payload["current_head_oid"] == COMMIT
|
||||
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
|
||||
from aios_convergence_contracts import (
|
||||
AcceptanceCriterion,
|
||||
AcceptanceDecision,
|
||||
AcceptanceInvariant,
|
||||
AcceptanceVector,
|
||||
ContinuationManifest,
|
||||
ContinuationStopReason,
|
||||
ConvergenceContractError,
|
||||
DesiredState,
|
||||
EvidenceState,
|
||||
ObservedHealth,
|
||||
ObservedState,
|
||||
ReconciliationState,
|
||||
StateOwnership,
|
||||
evaluate_acceptance,
|
||||
ownership_index,
|
||||
plan_reconciliation,
|
||||
validate_state_ownership,
|
||||
)
|
||||
|
||||
|
||||
def test_acceptance_never_promotes_required_unknown():
|
||||
vector = AcceptanceVector(
|
||||
subject_type="goal",
|
||||
subject_id="goal-1",
|
||||
subject_revision=1,
|
||||
criteria=(
|
||||
AcceptanceCriterion(
|
||||
criterion_id="runtime",
|
||||
required=True,
|
||||
state=EvidenceState.UNKNOWN,
|
||||
verifier_id="runtime-proof",
|
||||
),
|
||||
),
|
||||
)
|
||||
result = evaluate_acceptance(vector)
|
||||
assert result.decision == AcceptanceDecision.UNKNOWN
|
||||
assert result.reasons == ("criterion_unknown:runtime",)
|
||||
|
||||
|
||||
def test_acceptance_rejects_failed_invariant():
|
||||
vector = AcceptanceVector(
|
||||
subject_type="release",
|
||||
subject_id="V2R2C2",
|
||||
subject_revision=1,
|
||||
criteria=(
|
||||
AcceptanceCriterion("tests", True, EvidenceState.PASS, "pytest"),
|
||||
),
|
||||
invariants=(
|
||||
AcceptanceInvariant("protected-ref", EvidenceState.FAIL),
|
||||
),
|
||||
)
|
||||
assert evaluate_acceptance(vector).decision == AcceptanceDecision.REJECT
|
||||
|
||||
|
||||
def test_acceptance_blocks_on_unresolved_blocker():
|
||||
vector = AcceptanceVector(
|
||||
subject_type="goal",
|
||||
subject_id="goal-1",
|
||||
subject_revision=1,
|
||||
criteria=(AcceptanceCriterion("code", True, EvidenceState.PASS, "ci"),),
|
||||
unresolved_blockers=("blocker-1",),
|
||||
)
|
||||
assert evaluate_acceptance(vector).decision == AcceptanceDecision.BLOCKED
|
||||
|
||||
|
||||
def test_state_ownership_is_unique_and_final_writer_is_enforced():
|
||||
custom = (
|
||||
StateOwnership(
|
||||
object_type="goal",
|
||||
authoritative_service="goal-kernel",
|
||||
authoritative_store="goal-store",
|
||||
final_writer="goal-kernel",
|
||||
),
|
||||
)
|
||||
validate_state_ownership("goal", "goal-kernel", custom, final_state=True)
|
||||
with pytest.raises(ConvergenceContractError, match="STATE_OWNERSHIP_VIOLATION"):
|
||||
validate_state_ownership("goal", "worker", custom, final_state=True)
|
||||
with pytest.raises(ConvergenceContractError, match="duplicate_state_owner"):
|
||||
ownership_index(custom + custom)
|
||||
|
||||
|
||||
def test_missing_or_unknown_observation_never_converges():
|
||||
desired = DesiredState("contract:gpt", "external_projection", 2, {"hash": "abc"})
|
||||
missing = plan_reconciliation(desired, None)
|
||||
assert missing.state == ReconciliationState.OBSERVING
|
||||
assert missing.observation_required is True
|
||||
|
||||
unknown = ObservedState(
|
||||
resource_id="contract:gpt",
|
||||
observed_generation=None,
|
||||
observed_spec={},
|
||||
health=ObservedHealth.UNKNOWN,
|
||||
source_identity="gpt-action",
|
||||
observed_at="2026-08-25T00:00:00Z",
|
||||
)
|
||||
result = plan_reconciliation(desired, unknown)
|
||||
assert result.state == ReconciliationState.OBSERVING
|
||||
|
||||
|
||||
def test_equal_healthy_observation_converges_and_key_is_stable():
|
||||
desired = DesiredState("worker:a", "worker_binding", 3, {"build": "x"})
|
||||
observed = ObservedState(
|
||||
resource_id="worker:a",
|
||||
observed_generation=3,
|
||||
observed_spec={"build": "x"},
|
||||
health=ObservedHealth.HEALTHY,
|
||||
source_identity="worker-registry",
|
||||
observed_at="2026-08-25T00:00:00Z",
|
||||
)
|
||||
one = plan_reconciliation(desired, observed)
|
||||
two = plan_reconciliation(desired, observed)
|
||||
assert one.state == ReconciliationState.CONVERGED
|
||||
assert one.idempotency_key == two.idempotency_key
|
||||
|
||||
|
||||
def test_drift_is_diff_found_not_auto_applied():
|
||||
desired = DesiredState("projection:openapi", "external_projection", 4, {"hash": "new"})
|
||||
observed = ObservedState(
|
||||
resource_id="projection:openapi",
|
||||
observed_generation=3,
|
||||
observed_spec={"hash": "old"},
|
||||
health=ObservedHealth.HEALTHY,
|
||||
source_identity="published-openapi",
|
||||
observed_at="2026-08-25T00:00:00Z",
|
||||
)
|
||||
result = plan_reconciliation(desired, observed)
|
||||
assert result.state == ReconciliationState.DIFF_FOUND
|
||||
assert result.observation_required is False
|
||||
|
||||
|
||||
def test_continuation_manifest_is_durable_and_hashable():
|
||||
manifest = ContinuationManifest(
|
||||
trace_id="trace-1",
|
||||
goal_id="goal-1",
|
||||
goal_revision=1,
|
||||
task_id="task-1",
|
||||
run_id="run-1",
|
||||
current_node_id="node-4",
|
||||
acceptance_vector_ref="acceptance:goal-1:1",
|
||||
next_action="resume node-4",
|
||||
stop_reason=ContinuationStopReason.CONTEXT_BOUNDARY,
|
||||
checkpoint_ref="checkpoint-9",
|
||||
unresolved_blocker_refs=("blocker-a",),
|
||||
)
|
||||
assert manifest.as_dict()["stop_reason"] == "context_boundary"
|
||||
assert manifest.content_hash.startswith("sha256:")
|
||||
@@ -0,0 +1,506 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import importlib
|
||||
import importlib.util
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
|
||||
APP = Path(__file__).resolve().parents[1] / "active-runtime" / "app"
|
||||
AGENT_OS = Path(__file__).resolve().parents[1] / "runtime" / "agent-os"
|
||||
for import_root in (APP, AGENT_OS):
|
||||
if str(import_root) not in sys.path:
|
||||
sys.path.insert(0, str(import_root))
|
||||
|
||||
from silent_release_hardening import ( # noqa: E402
|
||||
BuilderAttestation,
|
||||
BuilderAttestationKeyRing,
|
||||
BuilderAttestationVerifier,
|
||||
CapabilityAuthorizationError,
|
||||
SilentReleaseActionService,
|
||||
TrustedCandidateBuilderUnavailable,
|
||||
)
|
||||
import silent_release_hardening as hardening # noqa: E402
|
||||
|
||||
|
||||
COMMIT = "a" * 40
|
||||
TREE = "b" * 40
|
||||
SHA = "c" * 64
|
||||
GITEA = {"consistent": True}
|
||||
|
||||
|
||||
def _af_inet_available() -> bool:
|
||||
try:
|
||||
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
except OSError:
|
||||
return False
|
||||
probe.close()
|
||||
return True
|
||||
|
||||
|
||||
def _canonical_digest(value: dict) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _instant(delta: timedelta = timedelta()) -> str:
|
||||
return (datetime.now(timezone.utc) + delta).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
|
||||
|
||||
def _keyring_payload(private: Ed25519PrivateKey, *, purpose: str) -> dict:
|
||||
public = private.public_key().public_bytes(
|
||||
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
||||
)
|
||||
record = {
|
||||
"algorithm": "Ed25519",
|
||||
"purpose": purpose,
|
||||
"key_id": "builder-key-001",
|
||||
"generation": 1,
|
||||
"not_before": _instant(timedelta(minutes=-1)),
|
||||
"expires_at": _instant(timedelta(days=1)),
|
||||
"status": "ACTIVE",
|
||||
"public_key": base64.b64encode(public).decode("ascii"),
|
||||
"public_key_sha256": hashlib.sha256(public).hexdigest(),
|
||||
}
|
||||
payload = {
|
||||
"algorithm": "Ed25519",
|
||||
"purpose": "AIOS_TRUSTED_RELEASE_BUILDER",
|
||||
"keys": [record],
|
||||
}
|
||||
payload["keyring_sha256"] = hashlib.sha256(
|
||||
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
).hexdigest()
|
||||
return payload
|
||||
|
||||
|
||||
def _refresh_keyring_digest(payload: dict) -> dict:
|
||||
canonical = {
|
||||
"algorithm": payload["algorithm"],
|
||||
"purpose": payload["purpose"],
|
||||
"keys": payload["keys"],
|
||||
}
|
||||
payload["keyring_sha256"] = hashlib.sha256(
|
||||
json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
).hexdigest()
|
||||
return payload
|
||||
|
||||
|
||||
def _attestation(private: Ed25519PrivateKey) -> BuilderAttestation:
|
||||
unsigned = BuilderAttestation(
|
||||
builder_attestation_id="builder-attestation-001",
|
||||
builder_key_id="builder-key-001",
|
||||
source_commit=COMMIT,
|
||||
tree_oid=TREE,
|
||||
artifact_sha256=SHA,
|
||||
manifest_sha256=SHA,
|
||||
package_closure_sha256=SHA,
|
||||
test_evidence_sha256=SHA,
|
||||
canary_evidence_sha256=SHA,
|
||||
issued_at=_instant(),
|
||||
signature="",
|
||||
builder_key_generation=1,
|
||||
signature_algorithm="Ed25519",
|
||||
signature_purpose="AIOS_TRUSTED_RELEASE_BUILDER",
|
||||
gitea_evidence_sha256=_canonical_digest(GITEA),
|
||||
rollback_evidence_sha256=_canonical_digest({
|
||||
"ready": True,
|
||||
"rollback_sha256": SHA,
|
||||
"source_commit": COMMIT,
|
||||
}),
|
||||
policy_evidence_sha256=SHA,
|
||||
secret_scan_evidence_sha256=SHA,
|
||||
)
|
||||
return BuilderAttestation(
|
||||
**{
|
||||
**unsigned.canonical_payload,
|
||||
"signature": base64.b64encode(
|
||||
private.sign(unsigned.canonical_bytes())
|
||||
).decode("ascii"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_builder_verifier_rejects_owner_key_record_cross_purpose(tmp_path):
|
||||
private = Ed25519PrivateKey.generate()
|
||||
payload = _keyring_payload(private, purpose="owner_approval")
|
||||
path = tmp_path / "builder-keyring.json"
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="purpose"):
|
||||
BuilderAttestationKeyRing.from_file(path)
|
||||
|
||||
|
||||
def test_builder_verifier_checks_domain_key_window_digest_and_signature(tmp_path):
|
||||
private = Ed25519PrivateKey.generate()
|
||||
path = tmp_path / "builder-keyring.json"
|
||||
path.write_text(
|
||||
json.dumps(_keyring_payload(private, purpose="AIOS_TRUSTED_RELEASE_BUILDER")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
verifier = BuilderAttestationVerifier(
|
||||
BuilderAttestationKeyRing.from_file(path)
|
||||
)
|
||||
candidate = SimpleNamespace(
|
||||
source_commit=COMMIT,
|
||||
tree_oid=TREE,
|
||||
artifact_sha256=SHA,
|
||||
manifest_sha256=SHA,
|
||||
gitea_consistency=GITEA,
|
||||
rollback_ready=True,
|
||||
)
|
||||
plan = SimpleNamespace(
|
||||
deployment=SimpleNamespace(
|
||||
package_closure_sha256=SHA,
|
||||
rollback_sha256=SHA,
|
||||
),
|
||||
policy_sha256=SHA,
|
||||
)
|
||||
assert verifier.verify(_attestation(private), candidate, plan).builder_key_id == "builder-key-001"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", ["REVOKED", "EXPIRED", "FUTURE"])
|
||||
def test_builder_verifier_rejects_unavailable_key_lifecycle_states(tmp_path, state):
|
||||
private = Ed25519PrivateKey.generate()
|
||||
payload = _keyring_payload(private, purpose="AIOS_TRUSTED_RELEASE_BUILDER")
|
||||
record = payload["keys"][0]
|
||||
if state == "REVOKED":
|
||||
record["status"] = "REVOKED"
|
||||
elif state == "EXPIRED":
|
||||
record["not_before"] = _instant(timedelta(days=-2))
|
||||
record["expires_at"] = _instant(timedelta(days=-1))
|
||||
else:
|
||||
record["not_before"] = _instant(timedelta(days=1))
|
||||
record["expires_at"] = _instant(timedelta(days=2))
|
||||
path = tmp_path / "builder-keyring.json"
|
||||
path.write_text(json.dumps(_refresh_keyring_digest(payload)), encoding="utf-8")
|
||||
verifier = BuilderAttestationVerifier(BuilderAttestationKeyRing.from_file(path))
|
||||
candidate = SimpleNamespace(
|
||||
source_commit=COMMIT, tree_oid=TREE,
|
||||
artifact_sha256=SHA, manifest_sha256=SHA,
|
||||
gitea_consistency=GITEA, rollback_ready=True,
|
||||
)
|
||||
plan = SimpleNamespace(deployment=SimpleNamespace(package_closure_sha256=SHA, rollback_sha256=SHA), policy_sha256=SHA)
|
||||
with pytest.raises(CapabilityAuthorizationError, match="validity|revoked|unavailable"):
|
||||
verifier.verify(_attestation(private), candidate, plan)
|
||||
|
||||
|
||||
def test_builder_verifier_rejects_unknown_key_invalid_signature_and_keyring_digest(tmp_path):
|
||||
private = Ed25519PrivateKey.generate()
|
||||
payload = _keyring_payload(private, purpose="AIOS_TRUSTED_RELEASE_BUILDER")
|
||||
path = tmp_path / "builder-keyring.json"
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
verifier = BuilderAttestationVerifier(BuilderAttestationKeyRing.from_file(path))
|
||||
candidate = SimpleNamespace(
|
||||
source_commit=COMMIT, tree_oid=TREE,
|
||||
artifact_sha256=SHA, manifest_sha256=SHA,
|
||||
gitea_consistency=GITEA, rollback_ready=True,
|
||||
)
|
||||
plan = SimpleNamespace(deployment=SimpleNamespace(package_closure_sha256=SHA, rollback_sha256=SHA), policy_sha256=SHA)
|
||||
with pytest.raises(CapabilityAuthorizationError, match="not trusted"):
|
||||
verifier.verify(
|
||||
dataclasses.replace(_attestation(private), builder_key_id="unknown-builder-key"),
|
||||
candidate,
|
||||
plan,
|
||||
)
|
||||
with pytest.raises(CapabilityAuthorizationError, match="signature"):
|
||||
verifier.verify(
|
||||
dataclasses.replace(_attestation(private), signature=base64.b64encode(b"x" * 64).decode()),
|
||||
candidate,
|
||||
plan,
|
||||
)
|
||||
|
||||
payload["keyring_sha256"] = "0" * 64
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="keyring digest"):
|
||||
BuilderAttestationKeyRing.from_file(path)
|
||||
|
||||
|
||||
def test_builder_attestation_rejects_tampered_digest_and_signing_metadata():
|
||||
private = Ed25519PrivateKey.generate()
|
||||
raw = _attestation(private).to_dict()
|
||||
raw["fingerprint"] = "0" * 64
|
||||
with pytest.raises(CapabilityAuthorizationError, match="digest is invalid"):
|
||||
BuilderAttestation.from_dict(raw, require_fingerprint=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("builder_key_generation", 2),
|
||||
("signature_algorithm", "RSA"),
|
||||
("signature_purpose", "owner_approval"),
|
||||
],
|
||||
)
|
||||
def test_builder_keyring_rejects_attestation_metadata_mismatch(tmp_path, field, value):
|
||||
private = Ed25519PrivateKey.generate()
|
||||
path = tmp_path / "builder-keyring.json"
|
||||
path.write_text(
|
||||
json.dumps(_keyring_payload(private, purpose="AIOS_TRUSTED_RELEASE_BUILDER")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
verifier = BuilderAttestationVerifier(
|
||||
BuilderAttestationKeyRing.from_file(path)
|
||||
)
|
||||
candidate = SimpleNamespace(
|
||||
source_commit=COMMIT, tree_oid=TREE,
|
||||
artifact_sha256=SHA, manifest_sha256=SHA,
|
||||
gitea_consistency=GITEA, rollback_ready=True,
|
||||
)
|
||||
plan = SimpleNamespace(deployment=SimpleNamespace(package_closure_sha256=SHA, rollback_sha256=SHA), policy_sha256=SHA)
|
||||
with pytest.raises(CapabilityAuthorizationError, match="metadata"):
|
||||
verifier.verify(
|
||||
dataclasses.replace(_attestation(private), **{field: value}),
|
||||
candidate,
|
||||
plan,
|
||||
)
|
||||
raw = _attestation(private).to_dict()
|
||||
raw["builder_key_generation"] = 2
|
||||
with pytest.raises(CapabilityAuthorizationError, match="digest is invalid"):
|
||||
BuilderAttestation.from_dict(raw, require_fingerprint=True)
|
||||
|
||||
|
||||
def test_builder_unavailable_intent_is_bounded_retryable_and_recovers_without_secret(tmp_path, monkeypatch):
|
||||
class UnavailableBuilder:
|
||||
def build(self, intent):
|
||||
raise TrustedCandidateBuilderUnavailable("socket token=should-not-leak")
|
||||
|
||||
monkeypatch.setattr(hardening, "_fsync_directory", lambda _path: None)
|
||||
service = SilentReleaseActionService(
|
||||
tmp_path,
|
||||
candidate_builder=UnavailableBuilder(),
|
||||
max_builder_attempts=2,
|
||||
)
|
||||
payload = {"event_id": "event-builder-001", "source_commit": COMMIT}
|
||||
|
||||
first = service.create_release_intent(payload)
|
||||
second = service.create_release_intent(payload)
|
||||
|
||||
assert first["status"] == "BUILDER_RETRYABLE_FAILURE"
|
||||
assert first["retryable"] is True
|
||||
assert first["attempt_count"] == 1
|
||||
assert "should-not-leak" not in json.dumps(first)
|
||||
assert second["status"] == "BUILDER_TERMINAL_FAILURE"
|
||||
assert second["retryable"] is False
|
||||
assert second["attempt_count"] == 2
|
||||
|
||||
|
||||
def test_stale_building_intent_recovers_after_restart_without_double_running_active_attempt(tmp_path, monkeypatch):
|
||||
class CountingUnavailableBuilder:
|
||||
calls = 0
|
||||
|
||||
def build(self, intent):
|
||||
self.calls += 1
|
||||
raise TrustedCandidateBuilderUnavailable("nonsecret")
|
||||
|
||||
monkeypatch.setattr(hardening, "_fsync_directory", lambda _path: None)
|
||||
builder = CountingUnavailableBuilder()
|
||||
service = SilentReleaseActionService(
|
||||
tmp_path,
|
||||
candidate_builder=builder,
|
||||
max_builder_attempts=3,
|
||||
builder_attempt_timeout_seconds=1,
|
||||
)
|
||||
payload = {"event_id": "event-builder-crash-001", "source_commit": COMMIT}
|
||||
intent_id = "intent-" + hashlib.sha256(
|
||||
f"{payload['event_id']}\0{COMMIT}".encode()
|
||||
).hexdigest()[:24]
|
||||
intent_path = tmp_path / "intents" / f"{intent_id}.json"
|
||||
intent_path.parent.mkdir(parents=True)
|
||||
active_record = {
|
||||
"attempt_count": 1,
|
||||
"attempt_started_at": _instant(),
|
||||
"created_at": _instant(timedelta(minutes=-1)),
|
||||
"event_id": payload["event_id"],
|
||||
"intent_id": intent_id,
|
||||
"source_commit": COMMIT,
|
||||
"status": "BUILDING",
|
||||
}
|
||||
intent_path.write_text(json.dumps(active_record), encoding="utf-8")
|
||||
|
||||
active = service.create_release_intent(payload)
|
||||
assert active["status"] == "BUILDING"
|
||||
assert active["idempotent"] is True
|
||||
assert builder.calls == 0
|
||||
|
||||
active_record["attempt_started_at"] = _instant(timedelta(minutes=-5))
|
||||
intent_path.write_text(json.dumps(active_record), encoding="utf-8")
|
||||
recovered = service.create_release_intent(payload)
|
||||
assert recovered["status"] == "BUILDER_RETRYABLE_FAILURE"
|
||||
assert recovered["attempt_count"] == 2
|
||||
assert recovered["failure_code"] == "TRUSTED_BUILDER_UNAVAILABLE"
|
||||
assert recovered["last_recovery_code"] == "TRUSTED_BUILDER_CRASH_RECOVERED"
|
||||
assert recovered["recovery_count"] == 1
|
||||
assert builder.calls == 1
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="model API runtime requires POSIX fcntl")
|
||||
@pytest.mark.skipif(
|
||||
not _af_inet_available(),
|
||||
reason="isolated executor permits AF_UNIX only; live HTTP probe runs at activation",
|
||||
)
|
||||
def test_model_api_keeps_process_liveness_separate_from_builder_readiness(monkeypatch):
|
||||
api = importlib.import_module("aios_model_api_staging")
|
||||
|
||||
class FakeService:
|
||||
ready = False
|
||||
|
||||
def get_builder_readiness(self):
|
||||
return {"status": "READY" if self.ready else "UNAVAILABLE"}
|
||||
|
||||
def create_release_candidate(self, payload):
|
||||
if not payload:
|
||||
raise ValueError("event_id is required")
|
||||
return {"status": "READY_FOR_APPROVAL"}
|
||||
|
||||
service = FakeService()
|
||||
monkeypatch.setattr(api, "silent_release_service", lambda: service)
|
||||
monkeypatch.setattr(
|
||||
api,
|
||||
"authenticate",
|
||||
lambda token: (
|
||||
{"account": "test", "role": "owner", "scopes": ["release:candidate"]}
|
||||
if token == "test-token"
|
||||
else None
|
||||
),
|
||||
)
|
||||
server = api.ThreadingHTTPServer(("127.0.0.1", 0), api.H)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
base = f"http://127.0.0.1:{server.server_port}"
|
||||
|
||||
def request(path, body=None):
|
||||
data = None if body is None else json.dumps(body).encode("utf-8")
|
||||
value = urllib.request.Request(
|
||||
base + path,
|
||||
data=data,
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
method="POST" if body is not None else "GET",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(value, timeout=5) as response:
|
||||
return response.status, json.load(response)
|
||||
except urllib.error.HTTPError as error:
|
||||
return error.code, json.load(error)
|
||||
|
||||
try:
|
||||
health_code, health = request("/health")
|
||||
unavailable_code, unavailable = request("/createReleaseCandidate", {})
|
||||
service.ready = True
|
||||
invalid_code, invalid = request("/createReleaseCandidate", {})
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
assert health_code == 200
|
||||
assert health["process_liveness"] == "PASS"
|
||||
assert health["builder_readiness"]["status"] == "UNAVAILABLE"
|
||||
assert unavailable_code == 503
|
||||
assert unavailable["error"] == "TRUSTED_BUILDER_UNAVAILABLE"
|
||||
assert invalid_code == 400
|
||||
assert invalid["error"] == "event_id is required"
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="model API runtime requires POSIX fcntl")
|
||||
def test_host_adapter_accepts_a_real_builder_bundle_response():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"trusted_builder_public_seam",
|
||||
APP.parents[1] / "tests" / "test_trusted_candidate_builder.py",
|
||||
)
|
||||
assert spec and spec.loader
|
||||
backend_seam = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(backend_seam)
|
||||
server, client, _ = backend_seam.make_seam()
|
||||
candidate_id = "rc-v2r2c2-d2b5a8339a34"
|
||||
source_commit = "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6"
|
||||
response = server.handle_request(
|
||||
client.create_request(
|
||||
"V2R2C2", candidate_id, source_commit, nonce="z" * 32
|
||||
),
|
||||
connection=object(),
|
||||
)
|
||||
|
||||
class ResponseClient:
|
||||
def build(self, version, supplied_candidate_id, supplied_source, *, nonce):
|
||||
assert version == "V2R2C2"
|
||||
assert supplied_candidate_id == candidate_id
|
||||
assert supplied_source == source_commit
|
||||
assert len(nonce) >= 32
|
||||
return response
|
||||
|
||||
api = importlib.import_module("aios_model_api_staging")
|
||||
bundle = api._HostTrustedCandidateBuilderAdapter(ResponseClient()).build(
|
||||
{
|
||||
"candidate_id": candidate_id,
|
||||
"source_commit": source_commit,
|
||||
"version": "V2R2C2",
|
||||
}
|
||||
)
|
||||
|
||||
assert bundle.validate().candidate.source_commit == response["base_oid"]
|
||||
assert bundle.attestation.builder_key_id == response["attestation"]["builder_key_id"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="model API runtime requires POSIX fcntl")
|
||||
def test_host_adapter_forwards_v2r2c3_to_the_policy_and_fails_closed_when_unavailable():
|
||||
from aios_trusted_candidate_builder import BuilderError
|
||||
api = importlib.import_module("aios_model_api_staging")
|
||||
calls = []
|
||||
|
||||
class DerivedOnlyClient:
|
||||
def build(self, version, candidate_id, source_commit, *, nonce):
|
||||
calls.append((version, candidate_id, source_commit, nonce))
|
||||
raise BuilderError("GOVERNED_V2R2C3_DERIVATION_REQUIRED")
|
||||
|
||||
with pytest.raises(TrustedCandidateBuilderUnavailable):
|
||||
api._HostTrustedCandidateBuilderAdapter(DerivedOnlyClient()).build(
|
||||
{
|
||||
"candidate_id": "r42-release-candidate-v2r2c3-20260820",
|
||||
"source_commit": COMMIT,
|
||||
"version": "V2R2C3",
|
||||
}
|
||||
)
|
||||
assert calls[0][:3] == (
|
||||
"V2R2C3",
|
||||
"r42-release-candidate-v2r2c3-20260820",
|
||||
COMMIT,
|
||||
)
|
||||
|
||||
|
||||
def test_host_adapter_accepts_intent_only_without_caller_candidate_id():
|
||||
api = importlib.import_module('aios_model_api_staging')
|
||||
calls = []
|
||||
|
||||
class IntentClient:
|
||||
def build(self, version, candidate_id, source_commit, *, nonce):
|
||||
calls.append((version, candidate_id, source_commit, nonce))
|
||||
raise RuntimeError('intent-only probe')
|
||||
|
||||
with pytest.raises(RuntimeError, match='intent-only probe'):
|
||||
api._HostTrustedCandidateBuilderAdapter(IntentClient()).build({
|
||||
'event_id': 'event-intent-only',
|
||||
'intent_id': 'intent-intent-only',
|
||||
'source_commit': COMMIT,
|
||||
'version': 'V2R2C2',
|
||||
})
|
||||
assert calls[0][0] == 'V2R2C2'
|
||||
assert calls[0][1] == ''
|
||||
assert calls[0][2] == COMMIT
|
||||
assert len(calls[0][3]) >= 32
|
||||
@@ -0,0 +1,176 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from aios_owner_merge_authorization import (
|
||||
OwnerMergeApprovalError,
|
||||
OwnerMergeApprovalPolicy,
|
||||
OwnerMergeApprovalVerifier,
|
||||
)
|
||||
from aios_owner_merge_authorization_server import OwnerMergeApprovalService
|
||||
from aios_single_host_authority import (
|
||||
RegistryOwnerApprovalVerifier,
|
||||
ssh_fingerprint,
|
||||
ssh_public_line,
|
||||
)
|
||||
|
||||
|
||||
NOW = 2_000_000_000
|
||||
|
||||
|
||||
def _write_key(path: Path, key: Ed25519PrivateKey) -> None:
|
||||
path.write_bytes(
|
||||
key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service_env(tmp_path: Path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
owner_path = tmp_path / "owner.key"
|
||||
_write_key(owner_path, owner_key)
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": "aios.single-host-signer-registry.v1",
|
||||
"authority_generation": 1,
|
||||
"signers": [
|
||||
{
|
||||
"signer_id": "local-owner-authority",
|
||||
"role": "OWNER_AUTHORITY",
|
||||
"provider": "local_unix_service",
|
||||
"fingerprint": ssh_fingerprint(owner_key.public_key()),
|
||||
"public_key": ssh_public_line(owner_key.public_key(), "owner"),
|
||||
"socket": "/run/aios/owner-authority.sock",
|
||||
"secret_ref": "aios-secret://owner",
|
||||
"status": "ACTIVE",
|
||||
"generation": 1,
|
||||
"parent_signer_id": "",
|
||||
"parent_fingerprint": "",
|
||||
}
|
||||
],
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
os.chmod(registry_path, 0o644)
|
||||
policy = OwnerMergeApprovalPolicy(
|
||||
repository="aios/aios",
|
||||
pr_number=75,
|
||||
pr_head_oid="a" * 40,
|
||||
expected_main_oid="b" * 40,
|
||||
operation="merge.pull-request",
|
||||
required_check_contract_digest="c" * 64,
|
||||
required_checks=("contract", "security"),
|
||||
owner_identity="owner:aios-single-host",
|
||||
audit_lineage_digest="d" * 64,
|
||||
maximum_ttl_seconds=300,
|
||||
)
|
||||
policy_path = tmp_path / "policy.json"
|
||||
policy_path.write_text(json.dumps(policy.as_dict(), sort_keys=True) + "\n")
|
||||
os.chmod(policy_path, 0o600)
|
||||
return {
|
||||
"policy": policy,
|
||||
"policy_path": policy_path,
|
||||
"registry_path": registry_path,
|
||||
"owner_path": owner_path,
|
||||
"ledger_path": tmp_path / "issuance.sqlite3",
|
||||
}
|
||||
|
||||
|
||||
def _service(env):
|
||||
return OwnerMergeApprovalService(
|
||||
policy_path=env["policy_path"],
|
||||
registry_path=env["registry_path"],
|
||||
owner_key_path=env["owner_path"],
|
||||
issuance_ledger_path=env["ledger_path"],
|
||||
allowed_uids=(1000,),
|
||||
require_root_owner=False,
|
||||
clock=lambda: NOW,
|
||||
token_urlsafe=lambda _size: "server-generated-nonce",
|
||||
)
|
||||
|
||||
|
||||
def test_service_issues_exact_owner_approval_and_exact_replay_is_audit_idempotent(service_env):
|
||||
service = _service(service_env)
|
||||
request = {"operation": "issue_owner_merge_approval", "payload": {}}
|
||||
|
||||
first = service.handle(request, peer_uid=1000)
|
||||
second = _service(service_env).handle(request, peer_uid=1000)
|
||||
|
||||
assert first["status"] == "PASS"
|
||||
assert first["idempotent"] is False
|
||||
assert second["idempotent"] is True
|
||||
assert second["artifact"] == first["artifact"]
|
||||
verifier = OwnerMergeApprovalVerifier(
|
||||
primary=RegistryOwnerApprovalVerifier(
|
||||
service_env["registry_path"], require_root_owner=False
|
||||
),
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
payload, proof_path = verifier.verify(first["artifact"], service_env["policy"])
|
||||
assert proof_path == "primary"
|
||||
assert payload["repository"] == "aios/aios"
|
||||
assert payload["pr_number"] == 75
|
||||
|
||||
|
||||
def test_expired_approval_is_retained_for_audit_but_reissued_with_a_new_nonce(service_env):
|
||||
clock = [NOW]
|
||||
token_values = iter(("approval-one", "nonce-one", "approval-two", "nonce-two"))
|
||||
service = OwnerMergeApprovalService(
|
||||
policy_path=service_env["policy_path"],
|
||||
registry_path=service_env["registry_path"],
|
||||
owner_key_path=service_env["owner_path"],
|
||||
issuance_ledger_path=service_env["ledger_path"],
|
||||
allowed_uids=(1000,),
|
||||
require_root_owner=False,
|
||||
clock=lambda: clock[0],
|
||||
token_urlsafe=lambda _size: next(token_values),
|
||||
)
|
||||
request = {"operation": "issue_owner_merge_approval", "payload": {}}
|
||||
|
||||
first = service.handle(request, peer_uid=1000)
|
||||
clock[0] += service_env["policy"].maximum_ttl_seconds
|
||||
second = service.handle(request, peer_uid=1000)
|
||||
|
||||
assert first["idempotent"] is False
|
||||
assert second["idempotent"] is False
|
||||
assert second["artifact"]["payload"]["nonce"] != first["artifact"]["payload"]["nonce"]
|
||||
assert second["artifact"]["payload"]["issued_at"] == clock[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_request",
|
||||
[
|
||||
{"operation": "issue_owner_merge_approval", "payload": {"nonce": "caller-nonce"}},
|
||||
{"operation": "issue_owner_merge_approval", "payload": {"pr_head_oid": "f" * 40}},
|
||||
{"operation": "sign", "payload": {}},
|
||||
{"operation": "sign_bytes", "payload": {}},
|
||||
],
|
||||
)
|
||||
def test_service_rejects_caller_selected_scope_and_generic_signing(service_env, raw_request):
|
||||
service = _service(service_env)
|
||||
|
||||
with pytest.raises(OwnerMergeApprovalError, match="OWNER_MERGE_ISSUER_"):
|
||||
service.handle(raw_request, peer_uid=1000)
|
||||
|
||||
|
||||
def test_service_rejects_untrusted_peer_before_issuing_anything(service_env):
|
||||
with pytest.raises(OwnerMergeApprovalError, match="OWNER_MERGE_ISSUER_PEER_UID_FORBIDDEN"):
|
||||
_service(service_env).handle(
|
||||
{"operation": "issue_owner_merge_approval", "payload": {}},
|
||||
peer_uid=2000,
|
||||
)
|
||||
@@ -0,0 +1,327 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from aios_owner_merge_authorization import (
|
||||
GovernedOwnerMergeAdmission,
|
||||
OwnerMergeApprovalAuthority,
|
||||
OwnerMergeApprovalError,
|
||||
OwnerMergeApprovalPolicy,
|
||||
OwnerMergeApprovalVerifier,
|
||||
OwnerMergeLiveState,
|
||||
OwnerMergeOidReadback,
|
||||
SQLiteOwnerMergeNonceLedger,
|
||||
)
|
||||
from aios_single_host_authority import (
|
||||
RegistryOwnerApprovalVerifier,
|
||||
ssh_fingerprint,
|
||||
ssh_public_line,
|
||||
)
|
||||
|
||||
|
||||
NOW = 2_000_000_000
|
||||
REPOSITORY = "aios/aios"
|
||||
PR_NUMBER = 75
|
||||
HEAD = "a" * 40
|
||||
BASE = "b" * 40
|
||||
MERGED = "c" * 40
|
||||
CHECKS = ("contract", "security", "unit")
|
||||
CHECK_CONTRACT = "d" * 64
|
||||
LINEAGE = "e" * 64
|
||||
|
||||
|
||||
def _write_key(path: Path, key: Ed25519PrivateKey) -> None:
|
||||
path.write_bytes(
|
||||
key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
class RecordingExecutor:
|
||||
def __init__(self, result: str = MERGED) -> None:
|
||||
self.result = result
|
||||
self.calls: list[dict[str, object]] = []
|
||||
|
||||
def execute(self, approval: dict[str, object]) -> str:
|
||||
self.calls.append(dict(approval))
|
||||
return self.result
|
||||
|
||||
|
||||
class RecordingReconciler:
|
||||
def __init__(self, oid: str = MERGED) -> None:
|
||||
self.oid = oid
|
||||
self.calls: list[str] = []
|
||||
|
||||
def readback(self, merge_oid: str) -> OwnerMergeOidReadback:
|
||||
self.calls.append(merge_oid)
|
||||
return OwnerMergeOidReadback(
|
||||
authority_main_oid=self.oid,
|
||||
gitea_main_oid=self.oid,
|
||||
api_main_oid=self.oid,
|
||||
)
|
||||
|
||||
|
||||
class UnavailableProofVerifier:
|
||||
def verify(self, **_kwargs: object) -> bool:
|
||||
raise RuntimeError("default signer/verifier unavailable")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def approval_env(tmp_path: Path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
owner_path = tmp_path / "owner.key"
|
||||
_write_key(owner_path, owner_key)
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": "aios.single-host-signer-registry.v1",
|
||||
"authority_generation": 1,
|
||||
"signers": [
|
||||
{
|
||||
"signer_id": "local-owner-authority",
|
||||
"role": "OWNER_AUTHORITY",
|
||||
"provider": "local_unix_service",
|
||||
"fingerprint": ssh_fingerprint(owner_key.public_key()),
|
||||
"public_key": ssh_public_line(owner_key.public_key(), "owner"),
|
||||
"socket": "/run/aios/owner-authority.sock",
|
||||
"secret_ref": "aios-secret://owner",
|
||||
"status": "ACTIVE",
|
||||
"generation": 1,
|
||||
"parent_signer_id": "",
|
||||
"parent_fingerprint": "",
|
||||
}
|
||||
],
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
os.chmod(registry_path, 0o644)
|
||||
policy = OwnerMergeApprovalPolicy(
|
||||
repository=REPOSITORY,
|
||||
pr_number=PR_NUMBER,
|
||||
pr_head_oid=HEAD,
|
||||
expected_main_oid=BASE,
|
||||
operation="merge.pull-request",
|
||||
required_check_contract_digest=CHECK_CONTRACT,
|
||||
required_checks=CHECKS,
|
||||
owner_identity="owner:aios-single-host",
|
||||
audit_lineage_digest=LINEAGE,
|
||||
maximum_ttl_seconds=300,
|
||||
)
|
||||
authority = OwnerMergeApprovalAuthority(
|
||||
owner_path,
|
||||
policy,
|
||||
require_root_owner=False,
|
||||
clock=lambda: NOW,
|
||||
token_urlsafe=lambda _size: "fixed-nonce-material",
|
||||
)
|
||||
artifact = authority.issue()
|
||||
registry_verifier = RegistryOwnerApprovalVerifier(
|
||||
registry_path,
|
||||
require_root_owner=False,
|
||||
)
|
||||
return {
|
||||
"policy": policy,
|
||||
"artifact": artifact,
|
||||
"registry_verifier": registry_verifier,
|
||||
"registry_path": registry_path,
|
||||
"owner_path": owner_path,
|
||||
"nonce_path": tmp_path / "nonces.sqlite3",
|
||||
}
|
||||
|
||||
|
||||
def _live_state(**updates: object) -> OwnerMergeLiveState:
|
||||
raw = {
|
||||
"repository": REPOSITORY,
|
||||
"pr_number": PR_NUMBER,
|
||||
"pr_head_oid": HEAD,
|
||||
"main_oid": BASE,
|
||||
"required_check_contract_digest": CHECK_CONTRACT,
|
||||
"check_statuses": {name: True for name in CHECKS},
|
||||
}
|
||||
raw.update(updates)
|
||||
return OwnerMergeLiveState(**raw)
|
||||
|
||||
|
||||
def _admission(env, *, live: OwnerMergeLiveState | None = None, primary=None, recovery=None, reconciler=None):
|
||||
verifier = OwnerMergeApprovalVerifier(
|
||||
primary=primary or env["registry_verifier"],
|
||||
recovery=recovery,
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
executor = RecordingExecutor()
|
||||
reconciler = reconciler or RecordingReconciler()
|
||||
admission = GovernedOwnerMergeAdmission(
|
||||
policy=env["policy"],
|
||||
verifier=verifier,
|
||||
live_state_reader=lambda: live or _live_state(),
|
||||
executor=executor,
|
||||
reconciler=reconciler,
|
||||
nonce_ledger=SQLiteOwnerMergeNonceLedger(env["nonce_path"]),
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
return admission, executor, reconciler
|
||||
|
||||
|
||||
def test_valid_owner_approval_with_exact_live_binding_enters_governed_merge(approval_env):
|
||||
admission, executor, reconciler = _admission(approval_env)
|
||||
|
||||
receipt = admission.admit(approval_env["artifact"])
|
||||
|
||||
assert receipt.merge_oid == MERGED
|
||||
assert receipt.owner_identity == "owner:aios-single-host"
|
||||
assert len(executor.calls) == 1
|
||||
assert reconciler.calls == [MERGED]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("live", "code"),
|
||||
[
|
||||
(_live_state(pr_head_oid="f" * 40), "OWNER_MERGE_HEAD_DRIFT"),
|
||||
(_live_state(main_oid="f" * 40), "OWNER_MERGE_BASE_DRIFT"),
|
||||
],
|
||||
)
|
||||
def test_head_or_main_drift_invalidates_owner_approval_before_execution(approval_env, live, code):
|
||||
admission, executor, _reconciler = _admission(approval_env, live=live)
|
||||
|
||||
with pytest.raises(OwnerMergeApprovalError, match=code):
|
||||
admission.admit(approval_env["artifact"])
|
||||
|
||||
assert executor.calls == []
|
||||
|
||||
|
||||
def test_expired_owner_approval_is_rejected_before_execution(approval_env):
|
||||
verifier = OwnerMergeApprovalVerifier(
|
||||
primary=approval_env["registry_verifier"],
|
||||
clock=lambda: NOW + 301,
|
||||
)
|
||||
executor = RecordingExecutor()
|
||||
admission = GovernedOwnerMergeAdmission(
|
||||
policy=approval_env["policy"],
|
||||
verifier=verifier,
|
||||
live_state_reader=_live_state,
|
||||
executor=executor,
|
||||
reconciler=RecordingReconciler(),
|
||||
nonce_ledger=SQLiteOwnerMergeNonceLedger(approval_env["nonce_path"]),
|
||||
clock=lambda: NOW + 301,
|
||||
)
|
||||
|
||||
with pytest.raises(OwnerMergeApprovalError, match="OWNER_MERGE_APPROVAL_EXPIRED"):
|
||||
admission.admit(approval_env["artifact"])
|
||||
|
||||
assert executor.calls == []
|
||||
|
||||
|
||||
def test_changed_policy_scope_requires_a_formal_owner_rebind(approval_env):
|
||||
replacement = replace(approval_env["policy"], expected_main_oid="f" * 40)
|
||||
verifier = OwnerMergeApprovalVerifier(
|
||||
primary=approval_env["registry_verifier"],
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
|
||||
with pytest.raises(OwnerMergeApprovalError, match="OWNER_MERGE_APPROVAL_BASE_BINDING_INVALID"):
|
||||
verifier.verify(approval_env["artifact"], replacement)
|
||||
|
||||
|
||||
def test_nonce_replay_cannot_create_a_second_merge_side_effect(approval_env):
|
||||
admission, executor, _reconciler = _admission(approval_env)
|
||||
admission.admit(approval_env["artifact"])
|
||||
|
||||
with pytest.raises(OwnerMergeApprovalError, match="OWNER_MERGE_NONCE_REPLAY"):
|
||||
admission.admit(approval_env["artifact"])
|
||||
|
||||
assert len(executor.calls) == 1
|
||||
|
||||
|
||||
def test_non_owner_proof_is_rejected_even_when_payload_claims_owner_identity(approval_env):
|
||||
attacker_path = approval_env["owner_path"].parent / "attacker.key"
|
||||
_write_key(attacker_path, Ed25519PrivateKey.generate())
|
||||
attacker = OwnerMergeApprovalAuthority(
|
||||
attacker_path,
|
||||
approval_env["policy"],
|
||||
require_root_owner=False,
|
||||
clock=lambda: NOW,
|
||||
token_urlsafe=lambda _size: "attacker-nonce-material",
|
||||
)
|
||||
admission, executor, _reconciler = _admission(approval_env)
|
||||
|
||||
with pytest.raises(OwnerMergeApprovalError, match="OWNER_MERGE_APPROVAL_SIGNATURE_INVALID"):
|
||||
admission.admit(attacker.issue())
|
||||
|
||||
assert executor.calls == []
|
||||
|
||||
|
||||
def test_recovery_verifier_can_prove_existing_owner_approval_when_primary_is_unavailable(approval_env):
|
||||
recovery = approval_env["registry_verifier"]
|
||||
admission, executor, _reconciler = _admission(
|
||||
approval_env,
|
||||
primary=UnavailableProofVerifier(),
|
||||
recovery=recovery,
|
||||
)
|
||||
|
||||
receipt = admission.admit(approval_env["artifact"])
|
||||
|
||||
assert receipt.proof_path == "recovery"
|
||||
assert len(executor.calls) == 1
|
||||
|
||||
|
||||
def test_missing_trusted_owner_proof_fails_closed(approval_env):
|
||||
admission, executor, _reconciler = _admission(approval_env)
|
||||
|
||||
with pytest.raises(OwnerMergeApprovalError, match="OWNER_MERGE_APPROVAL_ARTIFACT_INVALID"):
|
||||
admission.admit({})
|
||||
|
||||
assert executor.calls == []
|
||||
|
||||
|
||||
def test_owner_approval_never_converts_failed_technical_checks_to_pass(approval_env):
|
||||
statuses = {name: True for name in CHECKS}
|
||||
statuses["security"] = False
|
||||
admission, executor, _reconciler = _admission(
|
||||
approval_env,
|
||||
live=_live_state(check_statuses=statuses),
|
||||
)
|
||||
|
||||
with pytest.raises(OwnerMergeApprovalError, match="OWNER_MERGE_REQUIRED_CHECKS_FAILED"):
|
||||
admission.admit(approval_env["artifact"])
|
||||
|
||||
assert executor.calls == []
|
||||
|
||||
|
||||
def test_post_merge_oid_disagreement_is_not_reported_as_success_and_is_not_retried(approval_env):
|
||||
admission, executor, _reconciler = _admission(
|
||||
approval_env,
|
||||
reconciler=RecordingReconciler(oid="f" * 40),
|
||||
)
|
||||
|
||||
with pytest.raises(OwnerMergeApprovalError, match="OWNER_MERGE_OID_RECONCILIATION_REQUIRED"):
|
||||
admission.admit(approval_env["artifact"])
|
||||
with pytest.raises(OwnerMergeApprovalError, match="OWNER_MERGE_NONCE_REPLAY"):
|
||||
admission.admit(approval_env["artifact"])
|
||||
|
||||
assert len(executor.calls) == 1
|
||||
|
||||
|
||||
def test_malformed_executor_result_is_unknown_and_cannot_be_retried(approval_env):
|
||||
admission, executor, _reconciler = _admission(approval_env)
|
||||
executor.result = "not-an-oid"
|
||||
|
||||
with pytest.raises(OwnerMergeApprovalError, match="OWNER_MERGE_EXECUTOR_RESULT_INVALID"):
|
||||
admission.admit(approval_env["artifact"])
|
||||
with pytest.raises(OwnerMergeApprovalError, match="OWNER_MERGE_NONCE_REPLAY"):
|
||||
admission.admit(approval_env["artifact"])
|
||||
|
||||
assert len(executor.calls) == 1
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Local, non-SSH tests for the bounded Phase2 transport and status contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).parents[1] / "scripts" / "phase2_introspect_and_freeze.py"
|
||||
SPEC = importlib.util.spec_from_file_location("phase2_introspect_and_freeze", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
phase2 = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(phase2)
|
||||
|
||||
|
||||
def test_transport_sends_large_payload_without_argv_growth():
|
||||
payload = b"x" * (256 * 1024)
|
||||
observed = {}
|
||||
|
||||
def runner(argv, **kwargs):
|
||||
observed["argv"] = argv
|
||||
observed["kwargs"] = kwargs
|
||||
return SimpleNamespace(returncode=0, stdout=b'{"ok":true}')
|
||||
|
||||
assert phase2.run_remote_python(payload, runner=runner) == b'{"ok":true}'
|
||||
assert observed["kwargs"]["input"] == payload
|
||||
argv = observed["argv"]
|
||||
assert argv == [
|
||||
"ssh.exe",
|
||||
"-T",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"ConnectTimeout=12",
|
||||
"hw",
|
||||
'test "$(hostname)" = cn-network-mkt && sudo -n python3 -',
|
||||
]
|
||||
assert sum(len(part.encode("utf-8")) + 1 for part in argv) <= 512
|
||||
assert all(payload not in part.encode("utf-8") for part in argv)
|
||||
assert all("base64" not in part.lower() for part in argv)
|
||||
|
||||
|
||||
def test_transport_reports_timeout_without_stderr():
|
||||
def runner(argv, **kwargs):
|
||||
raise subprocess.TimeoutExpired(argv, kwargs["timeout"], stderr=b"secret=do-not-leak")
|
||||
|
||||
with pytest.raises(phase2.RemoteTransportError, match="timed out") as error:
|
||||
phase2.run_remote_python(b"payload", runner=runner, timeout=3)
|
||||
assert "secret" not in str(error.value)
|
||||
|
||||
|
||||
def test_transport_reports_nonzero_without_stderr():
|
||||
def runner(argv, **kwargs):
|
||||
return SimpleNamespace(returncode=23, stdout=b"", stderr=b"token=do-not-leak")
|
||||
|
||||
with pytest.raises(phase2.RemoteTransportError, match="status 23") as error:
|
||||
phase2.run_remote_python(b"payload", runner=runner)
|
||||
assert "token" not in str(error.value)
|
||||
|
||||
|
||||
def test_status_flags_are_explicit_and_missing_public_symbol_fails_closed():
|
||||
valid = phase2.validate_public_function_ast(
|
||||
"def offline_migrate_task_principal_schema():\n pass\n"
|
||||
"def supersede_p0_scope():\n pass\n"
|
||||
)
|
||||
assert valid["ok"] is True
|
||||
|
||||
missing = phase2.validate_public_function_ast(
|
||||
"def offline_migrate_task_principal_schema():\n pass\n"
|
||||
)
|
||||
assert missing["ok"] is False
|
||||
assert missing["missing"] == ["supersede_p0_scope"]
|
||||
|
||||
status = phase2.make_phase2_status(
|
||||
"FAIL", errors=["PUBLIC_SYMBOL_MISSING:supersede_p0_scope"]
|
||||
)
|
||||
assert status["status"] == "FAIL"
|
||||
assert status["fail_closed"] is True
|
||||
assert all(status[flag] is False for flag in phase2.MUTATION_FLAGS)
|
||||
|
||||
|
||||
def test_dynamic_reexport_is_accepted_only_with_explicit_globals_export_mechanism():
|
||||
source = (
|
||||
"for _name, _value in _legacy.__dict__.items():\n"
|
||||
" globals()[_name] = _value\n"
|
||||
)
|
||||
result = phase2.validate_public_function_ast(source)
|
||||
assert result["ok"] is True
|
||||
assert result["dynamic_reexport"] is True
|
||||
|
||||
no_export = "x = 1\n"
|
||||
result = phase2.validate_public_function_ast(no_export)
|
||||
assert result["ok"] is False
|
||||
assert result["missing"] == list(phase2.DEFAULT_REQUIRED_ADMISSION_FUNCTIONS)
|
||||
|
||||
|
||||
def test_status_validator_rejects_mutation_claims():
|
||||
status = phase2.make_phase2_status("PASS")
|
||||
assert phase2.validate_phase2_status(status)["read_only"] is True
|
||||
status["git_mutation"] = True
|
||||
with pytest.raises(phase2.Phase2StatusError, match="git_mutation"):
|
||||
phase2.validate_phase2_status(status)
|
||||
|
||||
|
||||
def test_request_payload_contains_request_but_argv_does_not():
|
||||
payload = phase2.build_remote_payload(
|
||||
{
|
||||
"issuer_pid": 123,
|
||||
"expected_admission_sha256": "a" * 64,
|
||||
}
|
||||
)
|
||||
assert b'"issuer_pid":123' in payload
|
||||
assert b'"expected_admission_sha256":"' in payload
|
||||
argv = phase2.build_ssh_argv()
|
||||
assert all("123" not in part and "aaaa" not in part for part in argv)
|
||||
|
||||
|
||||
def test_live_defaults_use_current_issuer_and_storage_migration_module():
|
||||
assert phase2.DEFAULT_ISSUER_UNIT == "aios-task-principal-issuer.service"
|
||||
assert phase2.DEFAULT_MIGRATION_MODULE == "aios_task_principal_storage_migration"
|
||||
payload = phase2.build_remote_payload()
|
||||
assert b"aios-task-principal-issuer.service" in payload
|
||||
assert b"aios_task_principal_storage_migration" in payload
|
||||
stale_issuer = "aios-task-principal-" + "admission.service"
|
||||
stale_migration = "aios_task_principal_" + "migration"
|
||||
assert stale_issuer.encode() not in payload
|
||||
assert stale_migration.encode() not in payload
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
FIXTURE_SOURCE = '"""x"""\nimport re\nfrom aios_online_agent_execution import (\n GovernedExecutionBinding,\n validate_execution_envelope,\n)\n\nclass InitialExecutionProjectionHandler:\n store = None\n def _authorized(self):\n return True\n def _json(self, value, status):\n pass\n def do_GET(self):\n if self.path == "/health":\n self._json(\n {"status": "ok", "service": "aios-agent-task-initial-execution-projection"},\n 200,\n )\n else:\n self._json({"error": "NOT_FOUND"}, 404)\n\n def do_POST(self):\n initial_match = re.fullmatch(\n r"/v1/tasks/([^/]+)/initial-execution-projection", self.path\n )\n recovery_match = re.fullmatch(\n r"/v1/tasks/([^/]+)/source-capture-projection-recovery", self.path\n )\n if initial_match is None and recovery_match is None:\n self._json({"error": "NOT_FOUND"}, 404)\n return\n if not self._authorized():\n self._json({"error": "UNAUTHORIZED"}, 401)\n return\n try:\n value = {}\n if self.store is None:\n raise RuntimeError("RUNTIME_UNAVAILABLE")\n if initial_match is not None:\n request = InitialExecutionProjectionRequest.from_mapping(value)\n result = self.store.project(initial_match.group(1), request)\n else:\n recovery_request = SourceCaptureProjectionRecoveryRequest.from_mapping(value)\n result = self.store.project_from_source_capture(\n recovery_match.group(1), recovery_request\n )\n self._json(result, 200 if result["idempotent"] else 201)\n except Exception:\n pass\n'
|
||||
|
||||
|
||||
def test_patcher_is_strict_and_idempotent(tmp_path):
|
||||
source = tmp_path / "aios_agent_task_initial_execution_projection.py"
|
||||
source.write_text(FIXTURE_SOURCE, encoding="utf-8")
|
||||
patcher = Path(__file__).resolve().parents[1] / "tools" / "integrate_projection_recovery.py"
|
||||
|
||||
first = subprocess.run(
|
||||
[sys.executable, str(patcher), str(source)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert first.returncode == 0, first.stderr
|
||||
assert first.stdout.strip() == "CHANGED"
|
||||
text = source.read_text()
|
||||
assert "/canonical-projection-recovery" in text
|
||||
assert "/canonical-projection" in text
|
||||
assert "recover_canonical_projection" in text
|
||||
|
||||
second = subprocess.run(
|
||||
[sys.executable, str(patcher), str(source)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert second.returncode == 0
|
||||
assert second.stdout.strip() == "ALREADY_APPLIED"
|
||||
|
||||
|
||||
def test_patcher_fails_closed_on_unexpected_source(tmp_path):
|
||||
source = tmp_path / "wrong.py"
|
||||
source.write_text("print('different')\n")
|
||||
patcher = Path(__file__).resolve().parents[1] / "tools" / "integrate_projection_recovery.py"
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(patcher), str(source)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 2
|
||||
assert "ANCHOR_COUNT" in result.stderr
|
||||
@@ -0,0 +1,32 @@
|
||||
from pathlib import Path
|
||||
import ast
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_r7_amendment_module_is_valid_and_fail_closed():
|
||||
text = (
|
||||
ROOT
|
||||
/ "active-runtime/app/aios_task_deterministic_execution_amendment.py"
|
||||
).read_text()
|
||||
ast.parse(text)
|
||||
assert "validate_execution_envelope" in text
|
||||
assert "SIGNED_EXECUTION_AMENDMENT_REQUIRED" in text
|
||||
assert "BEFORE UPDATE OF status ON tasks" in text
|
||||
assert "APPLY_STATES" in text
|
||||
assert '"FAILED"' in text and '"QUEUED"' in text
|
||||
assert "AMENDMENT_EXECUTION_OPERATIONS_REQUIRED" in text
|
||||
assert "AMENDMENT_PROTECTED_WRITE_FORBIDDEN" in text
|
||||
|
||||
|
||||
def test_bridge_unit_keeps_strict_sandbox_and_required_projection():
|
||||
text = (
|
||||
ROOT
|
||||
/ "deploy/systemd/aios-chatgpt-bridge-v2-staging.service"
|
||||
).read_text()
|
||||
assert "ProtectSystem=strict" in text
|
||||
assert "ProtectHome=true" in text
|
||||
assert (
|
||||
"LoadCredential=gitea_token:"
|
||||
) in text
|
||||
assert "/data/aios-gitops/repo.git/worktrees" in text
|
||||
@@ -0,0 +1,514 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
|
||||
from aios_repository_discovery import ( # noqa: E402
|
||||
RepositoryDiscovery,
|
||||
RepositoryDiscoveryError,
|
||||
RepositoryDiscoveryNotFound,
|
||||
get_diff_index,
|
||||
list_repository_tree,
|
||||
read_repository_file,
|
||||
search_repository_source,
|
||||
)
|
||||
|
||||
|
||||
REPOSITORY = "aios/aios"
|
||||
SECRET = b"repository-discovery-test-secret"
|
||||
|
||||
|
||||
def git(*args: str, cwd: Path | None = None) -> str:
|
||||
return subprocess.run(
|
||||
["git", *( ["-C", str(cwd)] if cwd is not None else []), *args],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def repository(tmp_path: Path):
|
||||
bare = tmp_path / "authoritative.git"
|
||||
seed = tmp_path / "seed"
|
||||
git("init", "--bare", str(bare))
|
||||
git("init", "-b", "main", str(seed))
|
||||
git("config", "user.name", "Discovery Test", cwd=seed)
|
||||
git("config", "user.email", "discovery@example.invalid", cwd=seed)
|
||||
(seed / "README.md").write_text("first immutable snapshot\n", encoding="utf-8")
|
||||
(seed / "00-large.txt").write_text("x" * 512, encoding="utf-8")
|
||||
(seed / "mystery" / "jobstore").mkdir(parents=True)
|
||||
(seed / "mystery" / "jobstore" / "recovery.py").write_text(
|
||||
"def locate():\n return 'JobStore.for_existing_readonly recovery marker'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
for index in range(3):
|
||||
(seed / "search" / f"match-{index}.py").parent.mkdir(exist_ok=True)
|
||||
(seed / "search" / f"match-{index}.py").write_text(
|
||||
f"P0_SEARCH_MARKER line {index}\n", encoding="utf-8"
|
||||
)
|
||||
(seed / "binary.bin").write_bytes(b"binary-marker\x00not-source\n")
|
||||
(seed / "nested" / "a" / "b").mkdir(parents=True)
|
||||
(seed / "nested" / "a" / "b" / "value.txt").write_text("deep value\n", encoding="utf-8")
|
||||
git("add", ".", cwd=seed)
|
||||
git("commit", "-m", "initial", cwd=seed)
|
||||
main_oid = git("rev-parse", "HEAD", cwd=seed)
|
||||
git("tag", "v1.0.0", cwd=seed)
|
||||
git("remote", "add", "origin", str(bare), cwd=seed)
|
||||
git("push", "origin", "main", "--tags", cwd=seed)
|
||||
git("--git-dir", str(bare), "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
|
||||
git("checkout", "-b", "feature/discovery", cwd=seed)
|
||||
(seed / "docs").mkdir()
|
||||
git("mv", "README.md", "docs/README.md", cwd=seed)
|
||||
for index in range(18):
|
||||
changed = seed / "zz-diff-index" / f"changed-{index:02}.txt"
|
||||
changed.parent.mkdir(exist_ok=True)
|
||||
changed.write_text(f"change {index}\n", encoding="utf-8")
|
||||
git("add", ".", cwd=seed)
|
||||
git("commit", "-m", "large indexed diff", cwd=seed)
|
||||
feature_oid = git("rev-parse", "HEAD", cwd=seed)
|
||||
git("push", "origin", "feature/discovery", cwd=seed)
|
||||
git("--git-dir", str(bare), "config", "remote.gitea.url", "https://g.example.invalid/git/aios/aios.git")
|
||||
git("branch", "ambiguous", feature_oid, cwd=seed)
|
||||
git("tag", "ambiguous", main_oid, cwd=seed)
|
||||
git("push", "origin", "refs/heads/ambiguous:refs/heads/ambiguous", cwd=seed)
|
||||
git("push", "origin", "refs/tags/ambiguous", cwd=seed)
|
||||
|
||||
discovery = RepositoryDiscovery(
|
||||
allowed_repositories=(REPOSITORY,), git_dir=bare, cursor_secret=SECRET,
|
||||
)
|
||||
return {"bare": bare, "seed": seed, "main_oid": main_oid, "feature_oid": feature_oid, "discovery": discovery}
|
||||
|
||||
|
||||
def test_exact_allowlist_and_verified_identity_reject_other_repositories(repository):
|
||||
discovery = repository["discovery"]
|
||||
with pytest.raises(RepositoryDiscoveryError, match="REPOSITORY_NOT_ALLOWED"):
|
||||
discovery.list_tree(repository="other/repo", revision="HEAD")
|
||||
|
||||
multiple = RepositoryDiscovery(
|
||||
allowed_repositories=(REPOSITORY, "other/repo"), git_dir=repository["bare"], cursor_secret=SECRET,
|
||||
)
|
||||
with pytest.raises(RepositoryDiscoveryError, match="REPOSITORY_IDENTITY_MISMATCH"):
|
||||
multiple.list_tree(repository="other/repo", revision="HEAD")
|
||||
|
||||
|
||||
def test_head_branch_tag_and_full_oid_resolve_to_one_immutable_commit(repository):
|
||||
discovery = repository["discovery"]
|
||||
expected = repository["main_oid"]
|
||||
assert discovery.resolve_revision(REPOSITORY, "HEAD") == expected
|
||||
assert discovery.resolve_revision(REPOSITORY, "main") == expected
|
||||
assert discovery.resolve_revision(REPOSITORY, "v1.0.0") == expected
|
||||
assert discovery.resolve_revision(REPOSITORY, expected) == expected
|
||||
for dangerous in ("HEAD~1", "main^{tree}", "--help", "../main", "refs/remotes/origin/main"):
|
||||
with pytest.raises(RepositoryDiscoveryError, match="INVALID_REVISION"):
|
||||
discovery.resolve_revision(REPOSITORY, dangerous)
|
||||
with pytest.raises(RepositoryDiscoveryError, match="AMBIGUOUS_REVISION"):
|
||||
discovery.resolve_revision(REPOSITORY, "ambiguous")
|
||||
|
||||
|
||||
def test_resolved_oid_remains_immutable_after_floating_head_moves(repository):
|
||||
discovery = repository["discovery"]
|
||||
original = discovery.read_file(
|
||||
repository=REPOSITORY,
|
||||
revision=repository["main_oid"],
|
||||
file_path="README.md",
|
||||
)
|
||||
assert original["resolved_oid"] == repository["main_oid"]
|
||||
|
||||
git(
|
||||
"--git-dir",
|
||||
str(repository["bare"]),
|
||||
"update-ref",
|
||||
"refs/heads/main",
|
||||
repository["feature_oid"],
|
||||
)
|
||||
moved = discovery.list_tree(
|
||||
repository=REPOSITORY, revision="main", prefix="docs", limit=10
|
||||
)
|
||||
assert moved["resolved_oid"] == repository["feature_oid"]
|
||||
with pytest.raises(RepositoryDiscoveryNotFound, match="FILE_NOT_FOUND"):
|
||||
discovery.read_file(
|
||||
repository=REPOSITORY, revision="main", file_path="README.md"
|
||||
)
|
||||
|
||||
still_original = discovery.read_file(
|
||||
repository=REPOSITORY,
|
||||
revision=original["resolved_oid"],
|
||||
file_path="README.md",
|
||||
)
|
||||
pinned_tree = discovery.list_tree(
|
||||
repository=REPOSITORY, revision=original["resolved_oid"], limit=100
|
||||
)
|
||||
pinned_search = discovery.search_source(
|
||||
repository=REPOSITORY,
|
||||
revision=original["resolved_oid"],
|
||||
query="JobStore.for_existing_readonly",
|
||||
path_globs=("mystery/**/*.py",),
|
||||
max_matches=5,
|
||||
max_bytes=4096,
|
||||
)
|
||||
pinned_diff = discovery.diff_index(
|
||||
repository=REPOSITORY,
|
||||
from_revision=original["resolved_oid"],
|
||||
to_revision=repository["feature_oid"],
|
||||
limit=1,
|
||||
)
|
||||
assert pinned_tree["resolved_oid"] == repository["main_oid"]
|
||||
assert any(item["path"] == "README.md" for item in pinned_tree["entries"])
|
||||
assert pinned_search["resolved_oid"] == repository["main_oid"]
|
||||
assert pinned_search["matches"]
|
||||
assert pinned_diff["resolved_from_oid"] == repository["main_oid"]
|
||||
assert pinned_diff["resolved_to_oid"] == repository["feature_oid"]
|
||||
assert still_original["resolved_oid"] == repository["main_oid"]
|
||||
assert still_original["blob_oid"] == original["blob_oid"]
|
||||
assert still_original["content"] == original["content"]
|
||||
|
||||
|
||||
def test_tree_pagination_cursor_is_signed_and_query_bound(repository):
|
||||
discovery = repository["discovery"]
|
||||
first = discovery.list_tree(repository=REPOSITORY, revision="main", limit=2)
|
||||
assert first["resolved_oid"] == repository["main_oid"]
|
||||
assert len(first["entries"]) == 2
|
||||
assert first["truncated"] is True
|
||||
cursor = first["next_cursor"]
|
||||
assert isinstance(cursor, str)
|
||||
second = discovery.list_tree(repository=REPOSITORY, revision=repository["main_oid"], limit=2, cursor=cursor)
|
||||
assert {item["path"] for item in first["entries"]}.isdisjoint({item["path"] for item in second["entries"]})
|
||||
with pytest.raises(RepositoryDiscoveryError, match="CURSOR_INVALID"):
|
||||
discovery.list_tree(repository=REPOSITORY, revision="main", limit=3, cursor=cursor)
|
||||
tampered_cursor = cursor[:-1] + ("0" if cursor[-1] != "0" else "1")
|
||||
with pytest.raises(RepositoryDiscoveryError, match="CURSOR_INVALID"):
|
||||
discovery.list_tree(repository=REPOSITORY, revision="main", limit=2, cursor=tampered_cursor)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ("../secret", "/etc/passwd", "nested/../a", "--output", "nested\\a", ":(glob)**", "nested/*.txt"))
|
||||
def test_path_traversal_and_git_option_like_paths_are_rejected(repository, value):
|
||||
discovery = repository["discovery"]
|
||||
with pytest.raises(RepositoryDiscoveryError, match="PATH_INVALID"):
|
||||
discovery.list_tree(repository=REPOSITORY, revision="main", prefix=value)
|
||||
with pytest.raises(RepositoryDiscoveryError, match="PATH_INVALID"):
|
||||
discovery.read_file(repository=REPOSITORY, revision="main", file_path=value)
|
||||
|
||||
|
||||
def test_source_search_finds_unknown_jobstore_path_with_literal_globs_and_bounds(repository):
|
||||
discovery = repository["discovery"]
|
||||
result = discovery.search_source(
|
||||
repository=REPOSITORY,
|
||||
revision="main",
|
||||
query="JobStore.for_existing_readonly",
|
||||
path_globs=("mystery/**/*.py",),
|
||||
max_matches=5,
|
||||
max_bytes=4096,
|
||||
)
|
||||
assert result["resolved_oid"] == repository["main_oid"]
|
||||
assert result["matches"]
|
||||
match = result["matches"][0]
|
||||
assert match["path"] == "mystery/jobstore/recovery.py"
|
||||
assert match["line_start"] == 2
|
||||
assert match["line_end"] == 2
|
||||
assert len(match["blob_oid"]) == 40
|
||||
assert "recovery marker" in match["snippet"]
|
||||
|
||||
bounded = discovery.search_source(
|
||||
repository=REPOSITORY, revision="main", query="x", max_matches=1, max_bytes=8,
|
||||
)
|
||||
assert bounded["scanned_bytes"] <= 8
|
||||
assert bounded["truncated"] is True
|
||||
|
||||
|
||||
def test_source_search_cursor_is_signed_and_binary_blobs_are_skipped(repository):
|
||||
discovery = repository["discovery"]
|
||||
first = discovery.search_source(
|
||||
repository=REPOSITORY,
|
||||
revision="main",
|
||||
query="P0_SEARCH_MARKER",
|
||||
path_globs=("search/*.py",),
|
||||
max_matches=1,
|
||||
max_bytes=4096,
|
||||
)
|
||||
assert len(first["matches"]) == 1
|
||||
assert first["truncated"] is True
|
||||
assert isinstance(first["next_cursor"], str)
|
||||
second = discovery.search_source(
|
||||
repository=REPOSITORY,
|
||||
revision="main",
|
||||
query="P0_SEARCH_MARKER",
|
||||
path_globs=("search/*.py",),
|
||||
max_matches=1,
|
||||
max_bytes=4096,
|
||||
cursor=first["next_cursor"],
|
||||
)
|
||||
assert second["matches"]
|
||||
assert first["matches"][0]["path"] != second["matches"][0]["path"]
|
||||
with pytest.raises(RepositoryDiscoveryError, match="CURSOR_INVALID"):
|
||||
discovery.search_source(
|
||||
repository=REPOSITORY,
|
||||
revision="main",
|
||||
query="different",
|
||||
path_globs=("search/*.py",),
|
||||
max_matches=1,
|
||||
max_bytes=4096,
|
||||
cursor=first["next_cursor"],
|
||||
)
|
||||
binary = discovery.search_source(
|
||||
repository=REPOSITORY,
|
||||
revision="main",
|
||||
query="binary-marker",
|
||||
path_globs=("binary.bin",),
|
||||
max_matches=5,
|
||||
max_bytes=4096,
|
||||
)
|
||||
assert binary["matches"] == []
|
||||
assert binary["binary_blobs_skipped"] == 1
|
||||
|
||||
|
||||
def test_diff_index_is_paginated_rename_aware_and_never_returns_patch(repository):
|
||||
discovery = repository["discovery"]
|
||||
first = discovery.diff_index(
|
||||
repository=REPOSITORY, from_revision="main", to_revision="feature/discovery", limit=5,
|
||||
)
|
||||
assert first["resolved_from_oid"] == repository["main_oid"]
|
||||
assert first["resolved_to_oid"] == repository["feature_oid"]
|
||||
assert len(first["entries"]) == 5
|
||||
assert first["truncated"] is True
|
||||
assert "patch" not in first
|
||||
assert all("additions" in item and "deletions" in item for item in first["entries"])
|
||||
assert any(item["old_path"] == "README.md" and item["path"] == "docs/README.md" for item in first["entries"])
|
||||
second = discovery.diff_index(
|
||||
repository=REPOSITORY, from_revision="main", to_revision="feature/discovery", limit=5, cursor=first["next_cursor"],
|
||||
)
|
||||
assert {item["path"] for item in first["entries"]}.isdisjoint({item["path"] for item in second["entries"]})
|
||||
scoped = discovery.diff_index(
|
||||
repository=REPOSITORY,
|
||||
from_revision="main",
|
||||
to_revision="feature/discovery",
|
||||
prefix="zz-diff-index",
|
||||
limit=100,
|
||||
)
|
||||
assert scoped["entries"]
|
||||
assert all(item["path"].startswith("zz-diff-index/") for item in scoped["entries"])
|
||||
assert "resolved_from_oid" in scoped and "resolved_to_oid" in scoped
|
||||
|
||||
|
||||
def test_read_file_returns_resolved_and_blob_identity_with_bounded_content(repository):
|
||||
discovery = repository["discovery"]
|
||||
result = discovery.read_file(
|
||||
repository=REPOSITORY, revision="main", file_path="README.md", max_bytes=5,
|
||||
)
|
||||
assert result["resolved_oid"] == repository["main_oid"]
|
||||
assert len(result["blob_oid"]) == 40
|
||||
assert result["content"] == "first"
|
||||
assert result["content_bytes"] == 5
|
||||
assert result["truncated"] is True
|
||||
with pytest.raises(RepositoryDiscoveryNotFound, match="FILE_NOT_FOUND"):
|
||||
discovery.read_file(repository=REPOSITORY, revision="main", file_path="missing.txt")
|
||||
|
||||
|
||||
def test_mapping_adapters_and_module_helpers_return_dicts_with_status_codes(repository):
|
||||
discovery = repository["discovery"]
|
||||
tree = discovery.list_repository_tree({"repository": REPOSITORY, "revision": "main", "limit": 2})
|
||||
assert isinstance(tree, dict)
|
||||
source = search_repository_source(
|
||||
{"repository": REPOSITORY, "revision": "main", "query": "recovery marker"}, discovery=discovery,
|
||||
)
|
||||
assert source["matches"]
|
||||
index = get_diff_index(
|
||||
{"repository": REPOSITORY, "from": "main", "to": "feature/discovery", "limit": 2}, discovery=discovery,
|
||||
)
|
||||
assert index["entries"]
|
||||
blob = read_repository_file(
|
||||
{"repository": REPOSITORY, "revision": "main", "path": "README.md", "max_bytes": 8}, discovery=discovery,
|
||||
)
|
||||
assert blob["path"] == "README.md"
|
||||
assert list_repository_tree({"repository": REPOSITORY, "revision": "main", "limit": 1}, discovery=discovery)["entries"]
|
||||
with pytest.raises(RepositoryDiscoveryError) as failure:
|
||||
discovery.read_repository_file({"repository": REPOSITORY, "revision": "main", "path": "../secret"})
|
||||
assert str(failure.value) == "REPOSITORY_DISCOVERY_PATH_INVALID"
|
||||
assert failure.value.status_code == 400
|
||||
|
||||
|
||||
def test_public_request_names_pin_ref_and_return_required_identity_fields(repository):
|
||||
discovery = repository["discovery"]
|
||||
tree = discovery.list_repository_tree(
|
||||
{"repository": REPOSITORY, "ref": "feature/discovery", "limit": 1}
|
||||
)
|
||||
assert tree["resolved_oid"] == repository["feature_oid"]
|
||||
assert {"path", "type", "object_oid"} <= set(tree["entries"][0])
|
||||
source = discovery.search_repository_source(
|
||||
{
|
||||
"repository": REPOSITORY,
|
||||
"ref": "main",
|
||||
"query": "P0_SEARCH_MARKER",
|
||||
"path_globs": ["search/*.py"],
|
||||
"max_matches": 2,
|
||||
"max_bytes": 4096,
|
||||
}
|
||||
)
|
||||
assert source["matches"][0]["line_start"] == source["matches"][0]["line_end"] == 1
|
||||
blob = discovery.read_repository_file(
|
||||
{"repository": REPOSITORY, "ref": "main", "path": "README.md", "max_bytes": 8}
|
||||
)
|
||||
assert blob["content_base64"]
|
||||
assert len(blob["blob_oid"]) == 40
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def large_diff_repository(tmp_path: Path):
|
||||
bare = tmp_path / "large.git"
|
||||
seed = tmp_path / "seed"
|
||||
git("init", "--bare", str(bare))
|
||||
git("init", "-b", "main", str(seed))
|
||||
git("config", "user.name", "Discovery Test", cwd=seed)
|
||||
git("config", "user.email", "discovery@example.invalid", cwd=seed)
|
||||
(seed / "README.md").write_text("base\n", encoding="utf-8")
|
||||
git("add", ".", cwd=seed)
|
||||
git("commit", "-m", "base", cwd=seed)
|
||||
main_oid = git("rev-parse", "HEAD", cwd=seed)
|
||||
git("remote", "add", "origin", str(bare), cwd=seed)
|
||||
git("push", "origin", "main", cwd=seed)
|
||||
git("--git-dir", str(bare), "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
for index in range(18_000):
|
||||
filename = "long-prefix-" + ("x" * 42) + f"-{index:05}.txt"
|
||||
(seed / "bulk" / filename).parent.mkdir(exist_ok=True)
|
||||
(seed / "bulk" / filename).write_text("large diff\n", encoding="utf-8")
|
||||
git("add", "bulk", cwd=seed)
|
||||
git("commit", "-m", "large diff", cwd=seed)
|
||||
feature_oid = git("rev-parse", "HEAD", cwd=seed)
|
||||
git("push", "origin", "main", cwd=seed)
|
||||
git("--git-dir", str(bare), "config", "remote.gitea.url", "https://g.example.invalid/git/aios/aios.git")
|
||||
return {
|
||||
"bare": bare,
|
||||
"main_oid": main_oid,
|
||||
"feature_oid": feature_oid,
|
||||
"discovery": RepositoryDiscovery(
|
||||
allowed_repositories=(REPOSITORY,), git_dir=bare, cursor_secret=SECRET
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def test_large_diff_index_is_streamed_and_page_bounded(large_diff_repository):
|
||||
result = large_diff_repository["discovery"].diff_index(
|
||||
repository=REPOSITORY,
|
||||
from_revision=large_diff_repository["main_oid"],
|
||||
to_revision=large_diff_repository["feature_oid"],
|
||||
limit=1,
|
||||
)
|
||||
assert len(result["entries"]) == 1
|
||||
assert result["entries"][0]["path"].startswith("bulk/")
|
||||
assert result["entries"][0]["additions"] == 1
|
||||
assert result["entries"][0]["deletions"] == 0
|
||||
assert result["truncated"] is True
|
||||
|
||||
|
||||
def test_bridge_http_discovery_routes_are_get_only_and_do_not_enter_write_handlers(repository):
|
||||
pytest.importorskip(
|
||||
"fcntl",
|
||||
reason="Bridge imports POSIX-only fcntl; Windows integration is unverified",
|
||||
)
|
||||
from http.client import HTTPConnection
|
||||
from http.server import ThreadingHTTPServer
|
||||
from threading import Thread
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from aios_chatgpt_bridge_v2 import BridgeV2Adapter
|
||||
from aios_chatgpt_bridge_v2_server import BridgeHandler
|
||||
|
||||
discovery = repository["discovery"]
|
||||
calls: list[str] = []
|
||||
|
||||
def invoke(operation: str, method: str):
|
||||
def handler(request):
|
||||
calls.append(operation)
|
||||
return getattr(discovery, method)(request)
|
||||
|
||||
return handler
|
||||
|
||||
adapter = BridgeV2Adapter(
|
||||
runtime_status={"deployment_state": "STAGING"},
|
||||
handlers={
|
||||
"listRepositoryTree": invoke("listRepositoryTree", "list_repository_tree"),
|
||||
"searchRepositorySource": invoke(
|
||||
"searchRepositorySource", "search_repository_source"
|
||||
),
|
||||
"getDiffIndex": invoke("getDiffIndex", "get_diff_index"),
|
||||
"readRepositoryFile": invoke("readRepositoryFile", "read_repository_file"),
|
||||
},
|
||||
)
|
||||
previous_adapter = BridgeHandler.adapter
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), BridgeHandler)
|
||||
BridgeHandler.adapter = adapter
|
||||
thread = Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
|
||||
def request(method: str, path: str, body: str | None = None):
|
||||
connection = HTTPConnection("127.0.0.1", server.server_port, timeout=10)
|
||||
headers = {"Content-Type": "application/json"} if body is not None else {}
|
||||
connection.request(method, path, body=body, headers=headers)
|
||||
response = connection.getresponse()
|
||||
raw = response.read()
|
||||
status = response.status
|
||||
connection.close()
|
||||
return status, raw
|
||||
|
||||
try:
|
||||
cases = [
|
||||
(
|
||||
"/v2/repository/tree",
|
||||
{"repository": REPOSITORY, "ref": "main", "limit": 2},
|
||||
),
|
||||
(
|
||||
"/v2/repository/search",
|
||||
{
|
||||
"repository": REPOSITORY,
|
||||
"ref": "main",
|
||||
"query": "recovery marker",
|
||||
"max_matches": 2,
|
||||
"max_bytes": 4096,
|
||||
},
|
||||
),
|
||||
(
|
||||
"/v2/repository/diff-index",
|
||||
{
|
||||
"repository": REPOSITORY,
|
||||
"from": "main",
|
||||
"to": "feature/discovery",
|
||||
"limit": 2,
|
||||
},
|
||||
),
|
||||
(
|
||||
"/v2/repository/file",
|
||||
{"repository": REPOSITORY, "ref": "main", "path": "README.md"},
|
||||
),
|
||||
]
|
||||
for path, values in cases:
|
||||
status, raw = request("GET", f"{path}?{urlencode(values)}")
|
||||
assert status == 200, raw.decode("utf-8", errors="replace")
|
||||
payload = json.loads(raw.decode("utf-8"))
|
||||
assert payload["repository"] == REPOSITORY
|
||||
assert "resolved_oid" in payload or "resolved_to_oid" in payload
|
||||
assert len(calls) == 4
|
||||
|
||||
status, _ = request("POST", "/v2/repository/tree", "{}")
|
||||
assert status == 405
|
||||
assert len(calls) == 4
|
||||
|
||||
duplicate = (
|
||||
"/v2/repository/tree?repository=aios%2Faios&ref=main&ref=main&limit=1"
|
||||
)
|
||||
status, _ = request("GET", duplicate)
|
||||
assert status == 400
|
||||
assert len(calls) == 4
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
BridgeHandler.adapter = previous_adapter
|
||||
@@ -0,0 +1,223 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
|
||||
from aios_chatgpt_bridge_v2 import ( # noqa: E402
|
||||
BridgeV2Adapter,
|
||||
BridgeV2Error,
|
||||
OPERATION_OPTIONAL_FIELDS,
|
||||
OPERATION_REQUIRED_FIELDS,
|
||||
READ_ONLY_OPERATIONS,
|
||||
operation_contracts,
|
||||
)
|
||||
|
||||
|
||||
DISCOVERY_CONTRACTS = {
|
||||
"listRepositoryTree": {
|
||||
"path": "/v2/repository/tree",
|
||||
"legacy_path": "/api/repository/tree",
|
||||
},
|
||||
"searchRepositorySource": {
|
||||
"path": "/v2/repository/search",
|
||||
"legacy_path": "/api/repository/search",
|
||||
},
|
||||
"getDiffIndex": {
|
||||
"path": "/v2/repository/diff-index",
|
||||
"legacy_path": "/api/repository/diff-index",
|
||||
},
|
||||
"readRepositoryFile": {
|
||||
"path": "/v2/repository/file",
|
||||
"legacy_path": "/api/repository/file",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _openapi_path_block(document: str, path: str) -> str:
|
||||
marker = f" {path}:\n"
|
||||
start = document.index(marker)
|
||||
end = document.find("\n /", start + len(marker))
|
||||
return document[start:] if end < 0 else document[start:end]
|
||||
|
||||
|
||||
def _assignment(source: str, name: str) -> ast.AST:
|
||||
module = ast.parse(source)
|
||||
for node in module.body:
|
||||
if isinstance(node, (ast.Assign, ast.AnnAssign)):
|
||||
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
||||
if any(isinstance(target, ast.Name) and target.id == name for target in targets):
|
||||
return node.value
|
||||
raise AssertionError(f"assignment {name!r} not found")
|
||||
|
||||
|
||||
def _literal_assignment(source: str, name: str):
|
||||
value = _assignment(source, name)
|
||||
if isinstance(value, ast.Call) and isinstance(value.func, ast.Name):
|
||||
assert value.func.id == "frozenset"
|
||||
assert len(value.args) == 1
|
||||
return set(ast.literal_eval(value.args[0]))
|
||||
return ast.literal_eval(value)
|
||||
|
||||
|
||||
def test_discovery_runtime_bridge_and_openapi_contracts_are_aligned():
|
||||
document = (ROOT / "docs" / "gitops" / "openapi-chatgpt-actions.yaml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
bridge_source = (ROOT / "active-runtime" / "app" / "aios_chatgpt_bridge_v2_server.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
actions_source = (ROOT / "active-runtime" / "app" / "aios_chatgpt_actions_api.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
routes = _literal_assignment(bridge_source, "ROUTES")
|
||||
discovery_actions = _literal_assignment(actions_source, "_DISCOVERY_ACTIONS")
|
||||
read_only_paths = _literal_assignment(actions_source, "READ_ONLY_GET_PATHS")
|
||||
runtime = operation_contracts()
|
||||
|
||||
for operation, expected in DISCOVERY_CONTRACTS.items():
|
||||
assert runtime[operation] == {
|
||||
"method": "GET",
|
||||
"path": expected["path"],
|
||||
"required_fields": list(OPERATION_REQUIRED_FIELDS[operation]),
|
||||
"optional_fields": list(OPERATION_OPTIONAL_FIELDS[operation]),
|
||||
"read_only": True,
|
||||
}
|
||||
assert operation in READ_ONLY_OPERATIONS
|
||||
assert routes[expected["path"]] == operation
|
||||
assert expected["legacy_path"] in read_only_paths
|
||||
assert expected["legacy_path"] in discovery_actions
|
||||
|
||||
block = _openapi_path_block(document, expected["path"])
|
||||
assert block.count(f"operationId: {operation}") == 1
|
||||
assert " get:\n" in block
|
||||
assert " security: []" in block
|
||||
assert " x-openai-isConsequential: false" in block
|
||||
assert " x-aios-read-only: true" in block
|
||||
assert " x-aios-bounded: true" in block
|
||||
assert " x-aios-repository-scoped: true" in block
|
||||
assert " x-aios-no-shell: true" in block
|
||||
assert f" x-aios-legacy-path: {expected['legacy_path']}" in block
|
||||
parameter_names = {
|
||||
line.split("name:", 1)[1].split(",", 1)[0].strip()
|
||||
for line in block.splitlines()
|
||||
if "name:" in line and "in: query" in line
|
||||
}
|
||||
assert parameter_names == set(OPERATION_REQUIRED_FIELDS[operation]) | set(
|
||||
OPERATION_OPTIONAL_FIELDS[operation]
|
||||
)
|
||||
|
||||
for operation in DISCOVERY_CONTRACTS:
|
||||
assert document.count(f"operationId: {operation}") == 1
|
||||
|
||||
class_nodes = [
|
||||
node
|
||||
for node in ast.parse(actions_source).body
|
||||
if isinstance(node, ast.ClassDef) and node.name == "H"
|
||||
]
|
||||
assert len(class_nodes) == 1
|
||||
post_methods = [
|
||||
node for node in class_nodes[0].body
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "do_POST"
|
||||
]
|
||||
assert post_methods
|
||||
effective_post = ast.get_source_segment(actions_source, post_methods[-1]) or ""
|
||||
assert "method_not_allowed" in effective_post
|
||||
assert "max_num_fields=64" in actions_source
|
||||
assert "query_too_large" in actions_source
|
||||
|
||||
|
||||
def test_discovery_adapter_rejects_task_principal_and_write_identity_fields():
|
||||
called = False
|
||||
|
||||
def handler(_request):
|
||||
nonlocal called
|
||||
called = True
|
||||
return {}
|
||||
|
||||
adapter = BridgeV2Adapter(
|
||||
runtime_status={}, handlers={"listRepositoryTree": handler}
|
||||
)
|
||||
request = {
|
||||
"repository": "aios/aios",
|
||||
"ref": "main",
|
||||
"limit": 1,
|
||||
"task_id": "must-not-enter-read-route",
|
||||
"principal": "must-not-enter-read-route",
|
||||
"writer_lease": "must-not-enter-read-route",
|
||||
}
|
||||
with pytest.raises(BridgeV2Error, match="BRIDGE_REQUEST_FIELDS_UNKNOWN"):
|
||||
adapter.listRepositoryTree(request)
|
||||
assert called is False
|
||||
|
||||
|
||||
def test_discovery_source_is_read_only_and_staging_freeze_remains_intact():
|
||||
source = (ROOT / "active-runtime" / "app" / "aios_repository_discovery.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert '_READ_ONLY_COMMANDS = frozenset({"cat-file", "diff", "ls-tree", "rev-parse"})' in source
|
||||
assert "shell=True" not in source
|
||||
assert "os.system" not in source
|
||||
assert "update-ref" not in source
|
||||
assert "git push" not in source
|
||||
|
||||
unit = (
|
||||
ROOT
|
||||
/ "deploy"
|
||||
/ "systemd"
|
||||
/ "aios-gitops-model-api-v2r2c1-staging.service"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "AIOS_GITOPS_WRITES_ENABLED=false" in unit
|
||||
assert "AIOS_GITOPS_PORT=18893" in unit
|
||||
assert "AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED=false" in unit
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "query"),
|
||||
[
|
||||
("/api/repository/tree", "repository=aios%2Faios&ref=main&limit=1"),
|
||||
(
|
||||
"/api/repository/search",
|
||||
"repository=aios%2Faios&ref=main&query=needle&max_matches=1&max_bytes=16",
|
||||
),
|
||||
(
|
||||
"/api/repository/diff-index",
|
||||
"repository=aios%2Faios&from=main&to=main&limit=1",
|
||||
),
|
||||
(
|
||||
"/api/repository/file",
|
||||
"repository=aios%2Faios&ref=main&path=README.md",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_actions_discovery_get_route_dispatches_and_post_is_rejected(
|
||||
monkeypatch: pytest.MonkeyPatch, path: str, query: str
|
||||
):
|
||||
if sys.platform == "win32":
|
||||
pytest.skip("Actions adapter imports POSIX-only fcntl; Windows integration is unverified")
|
||||
import aios_chatgpt_actions_api as actions_api
|
||||
observed: list[tuple[str, object]] = []
|
||||
|
||||
def fake_discovery_request(route: str, values: dict[str, list[str]]):
|
||||
observed.append((route, values))
|
||||
return {"route": route}
|
||||
|
||||
monkeypatch.setattr(actions_api, "repository_discovery_request", fake_discovery_request)
|
||||
handler = object.__new__(actions_api.H)
|
||||
responses: list[tuple[object, int]] = []
|
||||
handler.sendj = lambda value, code=200: responses.append((value, code))
|
||||
handler.path = f"{path}?{query}"
|
||||
|
||||
actions_api.H.do_GET(handler)
|
||||
assert observed[-1][0] == path
|
||||
assert responses[-1] == ({"route": path}, 200)
|
||||
|
||||
handler.path = path
|
||||
actions_api.H.do_POST(handler)
|
||||
assert responses[-1] == ({"error": "method_not_allowed"}, 405)
|
||||
@@ -110,6 +110,8 @@ class SignerContractCompatibilityTests(unittest.TestCase):
|
||||
signer = "/usr/local/libexec/aios-grant-signer-adapter"
|
||||
if not Path(signer).is_file():
|
||||
self.skipTest("deployed signer is not present")
|
||||
if os.geteuid() != 0:
|
||||
self.skipTest("deployed signer self-test requires root")
|
||||
help_result = subprocess.run([signer, "sign", "--help"], capture_output=True, text=True, check=False)
|
||||
self.assertEqual(help_result.returncode, 0)
|
||||
self.assertTrue(deployed_signer_help(help_result.stdout, help_result.stderr))
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP = ROOT / "active-runtime" / "app"
|
||||
if str(APP) not in sys.path:
|
||||
sys.path.insert(0, str(APP))
|
||||
|
||||
from aios_task_admission_bootstrap import ( # noqa: E402
|
||||
OwnerDelegationEpoch,
|
||||
TaskAdmissionBootstrapService,
|
||||
TaskAdmissionError,
|
||||
TaskAdmissionGrant,
|
||||
TaskAdmissionPolicy,
|
||||
TaskAdmissionStore,
|
||||
canonical_json_bytes,
|
||||
)
|
||||
from aios_task_admission_live_adapters import ( # noqa: E402
|
||||
AgentTaskAdmissionPort,
|
||||
BoundOwnerAuthorizationBackend,
|
||||
)
|
||||
|
||||
|
||||
BASE_OID = "9" * 40
|
||||
EXECUTION_DIGEST = "a" * 64
|
||||
TASK_ID = "repository-discovery-p0-20260903"
|
||||
BRANCH = "agent/repository-discovery-p0-20260902"
|
||||
IDEMPOTENCY = "repository-discovery-p0-admission-20260903"
|
||||
FILES = [
|
||||
"active-runtime/app/aios_repository_discovery.py",
|
||||
"active-runtime/app/aios_chatgpt_bridge_v2.py",
|
||||
"docs/gitops/openapi-chatgpt-actions.yaml",
|
||||
"tests/test_repository_discovery.py",
|
||||
]
|
||||
CAPS = [
|
||||
"governed.task.create",
|
||||
"agent-task:control",
|
||||
"task.principal.issue",
|
||||
"writer.lease",
|
||||
"governed.write.files",
|
||||
"git.read",
|
||||
"testing",
|
||||
"verification",
|
||||
]
|
||||
|
||||
|
||||
class FakeAuthorization:
|
||||
def __init__(self, *, policy_digest: str, now: int = 1_800_000_000):
|
||||
self.now = now
|
||||
self.signature_calls = 0
|
||||
self.epoch_calls = 0
|
||||
self.signature_ok = True
|
||||
self.epoch = OwnerDelegationEpoch(
|
||||
epoch_id="epoch-repository-discovery-20260903",
|
||||
owner_identity="owner",
|
||||
trust_domain="aios.owner",
|
||||
repository="aios/aios",
|
||||
allowed_branch_prefixes=("agent/repository-discovery-",),
|
||||
allowed_capabilities=frozenset(CAPS),
|
||||
allowed_file_prefixes=("active-runtime", "docs", "tests"),
|
||||
risk_ceiling="medium",
|
||||
issued_at=now - 60,
|
||||
not_before=now - 60,
|
||||
expires_at=now + 3600,
|
||||
revocation_epoch=0,
|
||||
policy_digest=policy_digest,
|
||||
)
|
||||
|
||||
def verify_delegation_epoch(self, epoch_id: str) -> OwnerDelegationEpoch:
|
||||
self.epoch_calls += 1
|
||||
if epoch_id != self.epoch.epoch_id:
|
||||
raise TaskAdmissionError("UNKNOWN_EPOCH", 403)
|
||||
return self.epoch
|
||||
|
||||
def verify_owner_signature(
|
||||
self,
|
||||
*,
|
||||
key_id: str,
|
||||
canonical_payload: bytes,
|
||||
signature_b64: str,
|
||||
) -> str:
|
||||
self.signature_calls += 1
|
||||
if key_id != "owner-key-1" or not self.signature_ok:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_OWNER_SIGNATURE_INVALID", 403)
|
||||
expected = base64.b64encode(
|
||||
hashlib.sha256(canonical_payload + b"\0owner-key-1").digest()
|
||||
).decode("ascii")
|
||||
if signature_b64 != expected:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_OWNER_SIGNATURE_INVALID", 403)
|
||||
return "owner"
|
||||
|
||||
|
||||
class FakeTaskPort:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
self.grants = []
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def admit(self, grant):
|
||||
with self._lock:
|
||||
self.calls += 1
|
||||
self.grants.append(grant)
|
||||
return {"task_id": grant.task_id, "state": "QUEUED"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def policy():
|
||||
return TaskAdmissionPolicy(
|
||||
allowed_capabilities=frozenset(CAPS),
|
||||
max_file_count=16,
|
||||
max_task_ttl_seconds=3600,
|
||||
max_envelope_ttl_seconds=900,
|
||||
max_risk="medium",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(tmp_path: Path, policy: TaskAdmissionPolicy):
|
||||
now = 1_800_000_000
|
||||
auth = FakeAuthorization(policy_digest=policy.normalized_policy_digest(), now=now)
|
||||
port = FakeTaskPort()
|
||||
svc = TaskAdmissionBootstrapService(
|
||||
store=TaskAdmissionStore(tmp_path / "admission.sqlite3"),
|
||||
authorization=auth,
|
||||
task_port=port,
|
||||
policy=policy,
|
||||
clock=lambda: now,
|
||||
)
|
||||
return svc, auth, port
|
||||
|
||||
|
||||
def request(**overrides):
|
||||
value = {
|
||||
"idempotency_key": IDEMPOTENCY,
|
||||
"task_id": TASK_ID,
|
||||
"repository": "aios/aios",
|
||||
"authority_base_oid": BASE_OID,
|
||||
"target_branch": BRANCH,
|
||||
"generation": 1,
|
||||
"execution_digest": EXECUTION_DIGEST,
|
||||
"requested_capabilities": list(CAPS),
|
||||
"file_allowlist": list(FILES),
|
||||
"risk": "medium",
|
||||
"expires_in_seconds": 600,
|
||||
}
|
||||
value.update(overrides)
|
||||
return value
|
||||
|
||||
|
||||
def test_envelope_exact_replay_returns_same_nonce_and_payload(service):
|
||||
svc, _, _ = service
|
||||
first = svc.create_envelope(request())
|
||||
second = svc.create_envelope(request())
|
||||
assert first["idempotent"] is False
|
||||
assert second["idempotent"] is True
|
||||
assert first["envelope"] == second["envelope"]
|
||||
assert first["canonical_payload_base64"] == second["canonical_payload_base64"]
|
||||
|
||||
|
||||
def test_envelope_idempotency_conflict_fails_closed(service):
|
||||
svc, _, _ = service
|
||||
svc.create_envelope(request())
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_IDEMPOTENCY_CONFLICT"):
|
||||
svc.create_envelope(request(risk="low"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value,code",
|
||||
[
|
||||
("repository", "other/repo", "TASK_ADMISSION_REPOSITORY_REJECTED"),
|
||||
("target_branch", "main", "TASK_ADMISSION_BRANCH_REJECTED"),
|
||||
("authority_base_oid", "HEAD", "TASK_ADMISSION_BASE_OID_INVALID"),
|
||||
("requested_capabilities", ["production.write"], "TASK_ADMISSION_PROHIBITED_CAPABILITY"),
|
||||
("requested_capabilities", ["git:*"], "TASK_ADMISSION_CAPABILITY_INVALID"),
|
||||
("file_allowlist", ["../secrets"], "TASK_ADMISSION_FILE_PATH_INVALID"),
|
||||
("file_allowlist", ["/etc/passwd"], "TASK_ADMISSION_FILE_PATH_INVALID"),
|
||||
("risk", "high", "TASK_ADMISSION_RISK_OUT_OF_POLICY"),
|
||||
],
|
||||
)
|
||||
def test_envelope_policy_rejects_scope_widening(service, field, value, code):
|
||||
svc, _, _ = service
|
||||
with pytest.raises(TaskAdmissionError, match=code):
|
||||
svc.create_envelope(request(**{field: value}))
|
||||
|
||||
|
||||
def test_valid_delegation_epoch_admits_exact_task_once(service):
|
||||
svc, auth, port = service
|
||||
envelope = svc.create_envelope(request())["envelope"]
|
||||
result = svc.authorize_and_admit(
|
||||
{
|
||||
"envelope_id": envelope["envelope_id"],
|
||||
"authorization_kind": "delegation_epoch",
|
||||
"delegation_epoch_id": auth.epoch.epoch_id,
|
||||
}
|
||||
)
|
||||
assert result["schema"] == "aios.task-admission-receipt.v1"
|
||||
assert result["task_id"] == TASK_ID
|
||||
assert result["task_state"] == "QUEUED"
|
||||
assert result["parent_epoch_id"] == auth.epoch.epoch_id
|
||||
assert port.calls == 1
|
||||
|
||||
replay = svc.authorize_and_admit(
|
||||
{
|
||||
"envelope_id": envelope["envelope_id"],
|
||||
"authorization_kind": "delegation_epoch",
|
||||
"delegation_epoch_id": auth.epoch.epoch_id,
|
||||
}
|
||||
)
|
||||
assert replay["task_id"] == TASK_ID
|
||||
assert replay["idempotent"] is True
|
||||
assert port.calls == 1
|
||||
|
||||
|
||||
def test_post_admission_projection_runs_after_exact_task_readback_once(
|
||||
tmp_path: Path, policy: TaskAdmissionPolicy
|
||||
):
|
||||
auth = FakeAuthorization(policy_digest=policy.normalized_policy_digest())
|
||||
port = FakeTaskPort()
|
||||
observations = []
|
||||
|
||||
def project(grant, task):
|
||||
observations.append((grant.task_id, task["task_id"], task["state"]))
|
||||
return {"job_id": "projected-job"}
|
||||
|
||||
svc = TaskAdmissionBootstrapService(
|
||||
store=TaskAdmissionStore(tmp_path / "admission.sqlite3"),
|
||||
authorization=auth,
|
||||
task_port=port,
|
||||
policy=policy,
|
||||
clock=lambda: 1_800_000_000,
|
||||
post_admission=project,
|
||||
)
|
||||
envelope_id = svc.create_envelope(request())["envelope"]["envelope_id"]
|
||||
proof = {
|
||||
"envelope_id": envelope_id,
|
||||
"authorization_kind": "delegation_epoch",
|
||||
"delegation_epoch_id": auth.epoch.epoch_id,
|
||||
}
|
||||
|
||||
first = svc.authorize_and_admit(proof)
|
||||
replay = svc.authorize_and_admit(proof)
|
||||
|
||||
assert first["task_id"] == TASK_ID
|
||||
assert replay["idempotent"] is True
|
||||
assert observations == [(TASK_ID, TASK_ID, "QUEUED")]
|
||||
|
||||
|
||||
def test_epoch_capability_widening_rejected_before_task_port(service):
|
||||
svc, auth, port = service
|
||||
# Envelope policy allows the capability, parent epoch deliberately does not.
|
||||
auth.epoch = OwnerDelegationEpoch(
|
||||
**{
|
||||
**auth.epoch.as_public_dict(),
|
||||
"allowed_capabilities": frozenset(
|
||||
item for item in CAPS if item != "writer.lease"
|
||||
),
|
||||
"allowed_branch_prefixes": auth.epoch.allowed_branch_prefixes,
|
||||
"allowed_file_prefixes": auth.epoch.allowed_file_prefixes,
|
||||
}
|
||||
)
|
||||
envelope = svc.create_envelope(request())["envelope"]
|
||||
with pytest.raises(
|
||||
TaskAdmissionError, match="TASK_ADMISSION_EPOCH_CAPABILITY_WIDENING"
|
||||
):
|
||||
svc.authorize_and_admit(
|
||||
{
|
||||
"envelope_id": envelope["envelope_id"],
|
||||
"authorization_kind": "delegation_epoch",
|
||||
"delegation_epoch_id": auth.epoch.epoch_id,
|
||||
}
|
||||
)
|
||||
assert port.calls == 0
|
||||
|
||||
|
||||
def test_epoch_file_scope_widening_rejected(service):
|
||||
svc, auth, port = service
|
||||
auth.epoch = OwnerDelegationEpoch(
|
||||
**{
|
||||
**auth.epoch.as_public_dict(),
|
||||
"allowed_capabilities": auth.epoch.allowed_capabilities,
|
||||
"allowed_branch_prefixes": auth.epoch.allowed_branch_prefixes,
|
||||
"allowed_file_prefixes": ("docs",),
|
||||
}
|
||||
)
|
||||
envelope = svc.create_envelope(request())["envelope"]
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_EPOCH_FILE_WIDENING"):
|
||||
svc.authorize_and_admit(
|
||||
{
|
||||
"envelope_id": envelope["envelope_id"],
|
||||
"authorization_kind": "delegation_epoch",
|
||||
"delegation_epoch_id": auth.epoch.epoch_id,
|
||||
}
|
||||
)
|
||||
assert port.calls == 0
|
||||
|
||||
|
||||
def test_revoked_or_expired_epoch_rejected(service):
|
||||
svc, auth, port = service
|
||||
auth.epoch = OwnerDelegationEpoch(
|
||||
**{
|
||||
**auth.epoch.as_public_dict(),
|
||||
"allowed_capabilities": auth.epoch.allowed_capabilities,
|
||||
"allowed_branch_prefixes": auth.epoch.allowed_branch_prefixes,
|
||||
"allowed_file_prefixes": auth.epoch.allowed_file_prefixes,
|
||||
"revocation_epoch": 1,
|
||||
}
|
||||
)
|
||||
envelope = svc.create_envelope(request())["envelope"]
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_EPOCH_REVOKED"):
|
||||
svc.authorize_and_admit(
|
||||
{
|
||||
"envelope_id": envelope["envelope_id"],
|
||||
"authorization_kind": "delegation_epoch",
|
||||
"delegation_epoch_id": auth.epoch.epoch_id,
|
||||
}
|
||||
)
|
||||
assert port.calls == 0
|
||||
|
||||
|
||||
def test_owner_signature_path_verifies_canonical_payload(service):
|
||||
svc, auth, port = service
|
||||
created = svc.create_envelope(request())
|
||||
payload = canonical_json_bytes(created["envelope"])
|
||||
signature = base64.b64encode(
|
||||
hashlib.sha256(payload + b"\0owner-key-1").digest()
|
||||
).decode("ascii")
|
||||
result = svc.authorize_and_admit(
|
||||
{
|
||||
"envelope_id": created["envelope"]["envelope_id"],
|
||||
"authorization_kind": "owner_signature",
|
||||
"owner_key_id": "owner-key-1",
|
||||
"signature_base64": signature,
|
||||
}
|
||||
)
|
||||
assert result["authorization_kind"] == "owner_signature"
|
||||
assert result["owner_identity"] == "owner"
|
||||
assert auth.signature_calls == 1
|
||||
assert port.calls == 1
|
||||
|
||||
|
||||
def test_bad_owner_signature_is_rejected(service):
|
||||
svc, _, port = service
|
||||
envelope = svc.create_envelope(request())["envelope"]
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_OWNER_SIGNATURE_INVALID"):
|
||||
svc.authorize_and_admit(
|
||||
{
|
||||
"envelope_id": envelope["envelope_id"],
|
||||
"authorization_kind": "owner_signature",
|
||||
"owner_key_id": "owner-key-1",
|
||||
"signature_base64": base64.b64encode(b"bad").decode("ascii"),
|
||||
}
|
||||
)
|
||||
assert port.calls == 0
|
||||
|
||||
|
||||
def test_expired_envelope_cannot_admit(tmp_path: Path, policy: TaskAdmissionPolicy):
|
||||
now = [1_800_000_000]
|
||||
auth = FakeAuthorization(policy_digest=policy.normalized_policy_digest(), now=now[0])
|
||||
port = FakeTaskPort()
|
||||
svc = TaskAdmissionBootstrapService(
|
||||
store=TaskAdmissionStore(tmp_path / "admission.sqlite3"),
|
||||
authorization=auth,
|
||||
task_port=port,
|
||||
policy=policy,
|
||||
clock=lambda: now[0],
|
||||
)
|
||||
envelope = svc.create_envelope(request(expires_in_seconds=60))["envelope"]
|
||||
now[0] += 61
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_ENVELOPE_EXPIRED"):
|
||||
svc.authorize_and_admit(
|
||||
{
|
||||
"envelope_id": envelope["envelope_id"],
|
||||
"authorization_kind": "delegation_epoch",
|
||||
"delegation_epoch_id": auth.epoch.epoch_id,
|
||||
}
|
||||
)
|
||||
assert port.calls == 0
|
||||
|
||||
|
||||
def test_concurrent_exact_authorization_calls_admit_once(service):
|
||||
svc, auth, port = service
|
||||
envelope_id = svc.create_envelope(request())["envelope"]["envelope_id"]
|
||||
body = {
|
||||
"envelope_id": envelope_id,
|
||||
"authorization_kind": "delegation_epoch",
|
||||
"delegation_epoch_id": auth.epoch.epoch_id,
|
||||
}
|
||||
with ThreadPoolExecutor(max_workers=16) as pool:
|
||||
results = list(pool.map(lambda _: svc.authorize_and_admit(body), range(16)))
|
||||
assert {item["task_id"] for item in results} == {TASK_ID}
|
||||
assert port.calls == 1
|
||||
|
||||
|
||||
def test_cross_instance_envelope_winner_is_the_durable_value(tmp_path: Path, policy: TaskAdmissionPolicy):
|
||||
auth = FakeAuthorization(policy_digest=policy.normalized_policy_digest())
|
||||
port = FakeTaskPort()
|
||||
stores = [TaskAdmissionStore(tmp_path / "shared.sqlite3") for _ in range(16)]
|
||||
services = [
|
||||
TaskAdmissionBootstrapService(
|
||||
store=store, authorization=auth, task_port=port, policy=policy,
|
||||
clock=lambda: 1_800_000_000,
|
||||
)
|
||||
for store in stores
|
||||
]
|
||||
with ThreadPoolExecutor(max_workers=16) as pool:
|
||||
values = list(pool.map(lambda svc: svc.create_envelope(request()), services))
|
||||
assert len({item["envelope"]["envelope_id"] for item in values}) == 1
|
||||
assert sum(not item["idempotent"] for item in values) == 1
|
||||
|
||||
|
||||
def test_cross_instance_authorization_calls_create_one_task(tmp_path: Path, policy: TaskAdmissionPolicy):
|
||||
auth = FakeAuthorization(policy_digest=policy.normalized_policy_digest())
|
||||
port = FakeTaskPort()
|
||||
database = tmp_path / "shared.sqlite3"
|
||||
first = TaskAdmissionBootstrapService(
|
||||
store=TaskAdmissionStore(database), authorization=auth, task_port=port,
|
||||
policy=policy, clock=lambda: 1_800_000_000,
|
||||
)
|
||||
envelope_id = first.create_envelope(request())["envelope"]["envelope_id"]
|
||||
services = [
|
||||
TaskAdmissionBootstrapService(
|
||||
store=TaskAdmissionStore(database), authorization=auth, task_port=port,
|
||||
policy=policy, clock=lambda: 1_800_000_000,
|
||||
)
|
||||
for _ in range(16)
|
||||
]
|
||||
body = {
|
||||
"envelope_id": envelope_id,
|
||||
"authorization_kind": "delegation_epoch",
|
||||
"delegation_epoch_id": auth.epoch.epoch_id,
|
||||
}
|
||||
with ThreadPoolExecutor(max_workers=16) as pool:
|
||||
values = list(pool.map(lambda svc: svc.authorize_and_admit(body), services))
|
||||
assert {item["task_id"] for item in values} == {TASK_ID}
|
||||
assert port.calls == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("file_name", ["docs//gitops/a.yaml", "docs/./gitops/a.yaml", "docs/a\x00.yaml"])
|
||||
def test_file_paths_must_already_be_canonical(service, file_name):
|
||||
svc, _, _ = service
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_FILE_PATH_INVALID"):
|
||||
svc.create_envelope(request(file_allowlist=[file_name]))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("generation", [True, "1", 1.0, 0])
|
||||
def test_generation_is_a_strict_positive_integer(service, generation):
|
||||
svc, _, _ = service
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_GENERATION_INVALID"):
|
||||
svc.create_envelope(request(generation=generation))
|
||||
|
||||
|
||||
def test_policy_digest_drift_and_changed_proof_cannot_replay(service):
|
||||
svc, auth, _ = service
|
||||
envelope_id = svc.create_envelope(request())["envelope"]["envelope_id"]
|
||||
body = {
|
||||
"envelope_id": envelope_id,
|
||||
"authorization_kind": "delegation_epoch",
|
||||
"delegation_epoch_id": auth.epoch.epoch_id,
|
||||
}
|
||||
svc.authorize_and_admit(body)
|
||||
svc.policy = TaskAdmissionPolicy(
|
||||
allowed_capabilities=frozenset(CAPS), policy_digest="policy-has-changed"
|
||||
)
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_POLICY_DIGEST_DRIFT"):
|
||||
svc.authorize_and_admit(body)
|
||||
|
||||
|
||||
def test_grant_expiry_is_clipped_to_parent_epoch(tmp_path: Path, policy: TaskAdmissionPolicy):
|
||||
now = 1_800_000_000
|
||||
auth = FakeAuthorization(policy_digest=policy.normalized_policy_digest(), now=now)
|
||||
auth.epoch = OwnerDelegationEpoch(
|
||||
**{**auth.epoch.as_public_dict(), "allowed_capabilities": auth.epoch.allowed_capabilities,
|
||||
"allowed_branch_prefixes": auth.epoch.allowed_branch_prefixes,
|
||||
"allowed_file_prefixes": auth.epoch.allowed_file_prefixes, "expires_at": now + 120}
|
||||
)
|
||||
port = FakeTaskPort()
|
||||
svc = TaskAdmissionBootstrapService(
|
||||
store=TaskAdmissionStore(tmp_path / "admission.sqlite3"), authorization=auth,
|
||||
task_port=port, policy=policy, clock=lambda: now,
|
||||
)
|
||||
result = svc.authorize_and_admit({
|
||||
"envelope_id": svc.create_envelope(request())["envelope"]["envelope_id"],
|
||||
"authorization_kind": "delegation_epoch", "delegation_epoch_id": auth.epoch.epoch_id,
|
||||
})
|
||||
assert port.grants[0].expires_at == now + 120
|
||||
assert result["task_id"] == TASK_ID
|
||||
|
||||
|
||||
def test_owner_backend_uses_bound_caller_and_decoded_armored_signature(policy):
|
||||
captured = {}
|
||||
|
||||
class Verifier:
|
||||
def verify(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return True
|
||||
|
||||
epoch = FakeAuthorization(policy_digest=policy.normalized_policy_digest()).epoch
|
||||
backend = BoundOwnerAuthorizationBackend(
|
||||
authenticated_caller={"uid": "bridge-service"},
|
||||
verified_epoch_provider=lambda **kwargs: captured.update(epoch_lookup=kwargs) or epoch,
|
||||
signature_verifier=Verifier(),
|
||||
)
|
||||
assert backend.verify_delegation_epoch(epoch.epoch_id) == epoch
|
||||
assert captured["epoch_lookup"]["caller"] == {"uid": "bridge-service"}
|
||||
assert backend.verify_owner_signature(
|
||||
key_id="owner-key-1", canonical_payload=b"exact", signature_b64=base64.b64encode(b"-----BEGIN SSH SIGNATURE-----").decode(),
|
||||
) == "aios-task-grant-signer"
|
||||
assert captured["namespace"] == "aios-owner-approval-v1"
|
||||
assert captured["payload"] == b"exact"
|
||||
|
||||
|
||||
def test_official_task_port_reconciles_ambiguous_create(tmp_path: Path, policy):
|
||||
grant = TaskAdmissionBootstrapService(
|
||||
store=TaskAdmissionStore(tmp_path / "admission.sqlite3"),
|
||||
authorization=FakeAuthorization(policy_digest=policy.normalized_policy_digest()),
|
||||
task_port=FakeTaskPort(), policy=policy, clock=lambda: 1_800_000_000,
|
||||
)
|
||||
# Construct via the public flow so the port sees a production-shaped grant.
|
||||
created = grant.create_envelope(request())["envelope"]
|
||||
envelope = grant._load_envelope(created["envelope_id"])
|
||||
task_grant = TaskAdmissionGrant(
|
||||
grant_id="task-grant-adapter-test", 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="owner_signature",
|
||||
owner_identity="aios-task-grant-signer", parent_epoch_id=None,
|
||||
issued_at=1_800_000_000, expires_at=1_800_000_600,
|
||||
)
|
||||
|
||||
class NotFound(Exception):
|
||||
status = 404
|
||||
|
||||
class Transport:
|
||||
def __init__(self): self.calls = []; self.task = None
|
||||
def request(self, method, path, body=None):
|
||||
self.calls.append((method, path, body))
|
||||
if method == "GET" and self.task is None: raise NotFound()
|
||||
if method == "POST":
|
||||
self.task = {key: value for key, value in body.items() if key != "idempotency_key"}
|
||||
self.task["status"] = "QUEUED"
|
||||
raise TimeoutError("response lost after side effect")
|
||||
return self.task
|
||||
|
||||
transport = Transport()
|
||||
plan = {
|
||||
"external_job_id": "fixture-job-adapter-01", "repository": task_grant.repository,
|
||||
"target_branch": task_grant.target_branch, "generation": task_grant.generation,
|
||||
"execution_digest": task_grant.execution_digest,
|
||||
}
|
||||
assert AgentTaskAdmissionPort(transport, execution_plan_provider=lambda _: plan).admit(task_grant) == {"task_id": TASK_ID, "state": "QUEUED"}
|
||||
assert [call[:2] for call in transport.calls] == [
|
||||
("GET", "/v1/tasks/" + TASK_ID), ("POST", "/v1/tasks"), ("GET", "/v1/tasks/" + TASK_ID),
|
||||
]
|
||||
|
||||
|
||||
def test_execution_identity_is_available_only_to_trusted_post_admission(
|
||||
tmp_path: Path,
|
||||
policy: TaskAdmissionPolicy,
|
||||
):
|
||||
class NotFound(Exception):
|
||||
status = 404
|
||||
|
||||
class Transport:
|
||||
def __init__(self):
|
||||
self.task = None
|
||||
|
||||
def request(self, method, path, body=None):
|
||||
if method == "GET" and self.task is None:
|
||||
raise NotFound()
|
||||
if method == "POST":
|
||||
self.task = {
|
||||
key: value for key, value in body.items()
|
||||
if key != "idempotency_key"
|
||||
}
|
||||
self.task["status"] = "QUEUED"
|
||||
return self.task
|
||||
|
||||
plan = {
|
||||
"external_job_id": "job-0123456789abcdef0123456789abcdef",
|
||||
"repository": "aios/aios",
|
||||
"target_branch": BRANCH,
|
||||
"generation": 1,
|
||||
"execution_digest": EXECUTION_DIGEST,
|
||||
}
|
||||
projected: dict[str, object] = {}
|
||||
auth = FakeAuthorization(policy_digest=policy.normalized_policy_digest())
|
||||
service = TaskAdmissionBootstrapService(
|
||||
store=TaskAdmissionStore(tmp_path / "admission.sqlite3"),
|
||||
authorization=auth,
|
||||
task_port=AgentTaskAdmissionPort(
|
||||
Transport(), execution_plan_provider=lambda _: plan
|
||||
),
|
||||
policy=policy,
|
||||
clock=lambda: 1_800_000_000,
|
||||
post_admission=lambda _grant, result: projected.update(result),
|
||||
)
|
||||
envelope_id = service.create_envelope(request())["envelope"]["envelope_id"]
|
||||
receipt = service.authorize_and_admit(
|
||||
{
|
||||
"envelope_id": envelope_id,
|
||||
"authorization_kind": "delegation_epoch",
|
||||
"delegation_epoch_id": auth.epoch.epoch_id,
|
||||
}
|
||||
)
|
||||
assert receipt["task_id"] == TASK_ID
|
||||
assert "external_job_id" not in receipt
|
||||
assert projected == {
|
||||
"task_id": TASK_ID,
|
||||
"state": "QUEUED",
|
||||
"external_job_id": plan["external_job_id"],
|
||||
}
|
||||
|
||||
|
||||
def test_status_is_read_only_and_reports_admitted_task(service):
|
||||
svc, auth, _ = service
|
||||
envelope_id = svc.create_envelope(request())["envelope"]["envelope_id"]
|
||||
before = svc.status(envelope_id)
|
||||
assert before["status"] == "PENDING_AUTHORIZATION"
|
||||
assert before["task"] is None
|
||||
svc.authorize_and_admit(
|
||||
{
|
||||
"envelope_id": envelope_id,
|
||||
"authorization_kind": "delegation_epoch",
|
||||
"delegation_epoch_id": auth.epoch.epoch_id,
|
||||
}
|
||||
)
|
||||
after = svc.status(envelope_id)
|
||||
assert after["status"] == "ADMITTED"
|
||||
assert after["task"]["task_id"] == TASK_ID
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP = ROOT / "active-runtime" / "app"
|
||||
if str(APP) not in sys.path:
|
||||
sys.path.insert(0, str(APP))
|
||||
|
||||
from aios_task_admission_bridge_contract import ( # noqa: E402
|
||||
TASK_ADMISSION_OPERATION_REQUIRED_FIELDS,
|
||||
TASK_ADMISSION_ROUTES,
|
||||
)
|
||||
|
||||
|
||||
def test_bootstrap_route_operation_mapping_is_exact():
|
||||
assert TASK_ADMISSION_ROUTES == {
|
||||
"/v2/task-admission/envelope": "getTaskAdmissionEnvelope",
|
||||
"/v2/task-admission/authorize": "submitTaskAdmissionAuthorization",
|
||||
"/v2/task-admission/status": "getTaskAdmissionStatus",
|
||||
}
|
||||
|
||||
|
||||
def test_bootstrap_operations_do_not_replace_writer_or_principal_gates():
|
||||
operations = set(TASK_ADMISSION_OPERATION_REQUIRED_FIELDS)
|
||||
assert "acquireWriterLease" not in operations
|
||||
assert "commitFilesGoverned" not in operations
|
||||
assert "issueTaskPrincipal" not in operations
|
||||
assert operations == {
|
||||
"getTaskAdmissionEnvelope",
|
||||
"submitTaskAdmissionAuthorization",
|
||||
"getTaskAdmissionStatus",
|
||||
}
|
||||
|
||||
|
||||
def test_openapi_fragment_matches_runtime_contract():
|
||||
text = (
|
||||
ROOT / "docs" / "gitops" / "openapi-task-admission-bootstrap-fragment.yaml"
|
||||
).read_text(encoding="utf-8")
|
||||
for route, operation in TASK_ADMISSION_ROUTES.items():
|
||||
assert f" {route}:" in text
|
||||
assert f"operationId: {operation}" in text
|
||||
assert "global write" not in text.lower()
|
||||
assert "runShell" not in text
|
||||
assert "force push" not in text.lower()
|
||||
@@ -0,0 +1,406 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from contextlib import contextmanager
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from http.server import ThreadingHTTPServer
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP = ROOT / "active-runtime" / "app"
|
||||
if str(APP) not in sys.path:
|
||||
sys.path.insert(0, str(APP))
|
||||
|
||||
from aios_chatgpt_bridge_v2 import ( # noqa: E402
|
||||
BridgeV2Adapter,
|
||||
TASK_ADMISSION_OPERATION_OPTIONAL_FIELDS,
|
||||
TASK_ADMISSION_OPERATION_REQUIRED_FIELDS,
|
||||
TASK_ADMISSION_OPERATIONS,
|
||||
)
|
||||
from aios_chatgpt_bridge_v2_server import ( # noqa: E402
|
||||
BridgeHandler,
|
||||
GovernedRuntime,
|
||||
build_bridge_adapter,
|
||||
)
|
||||
from aios_governed_task_bootstrap import BootstrapError # noqa: E402
|
||||
from aios_task_admission_bootstrap import TaskAdmissionError # noqa: E402
|
||||
|
||||
|
||||
class StubRuntime:
|
||||
def __init__(self, status: dict[str, object], handlers=None) -> None:
|
||||
self.status = status
|
||||
self._handlers = dict(handlers or {})
|
||||
|
||||
def runtime_status(self) -> dict[str, object]:
|
||||
return dict(self.status)
|
||||
|
||||
def handlers(self) -> dict[str, object]:
|
||||
return dict(self._handlers)
|
||||
|
||||
|
||||
class FakeOwnerPort:
|
||||
def __init__(self) -> None:
|
||||
self.envelopes: list[dict[str, object]] = []
|
||||
self.authorizations: list[dict[str, object]] = []
|
||||
|
||||
def create_envelope(self, request):
|
||||
self.envelopes.append(dict(request))
|
||||
return {"envelope_id": "env-1", "status": "PENDING", "http_status": 201}
|
||||
|
||||
def authorize(self, request, task_port):
|
||||
self.authorizations.append(dict(request))
|
||||
kind = request.get("authorization_kind")
|
||||
if kind == "unsigned":
|
||||
raise TaskAdmissionError("TASK_ADMISSION_AUTHORIZATION_REQUIRED", 400)
|
||||
if kind == "bad_proof":
|
||||
raise TaskAdmissionError("TASK_ADMISSION_OWNER_SIGNATURE_INVALID", 403)
|
||||
return task_port.admit(request)
|
||||
|
||||
|
||||
class FakeTaskPort:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, object]] = []
|
||||
|
||||
def admit(self, request):
|
||||
self.calls.append(dict(request))
|
||||
return {"task_id": request.get("task_id", "task-1"), "state": "QUEUED"}
|
||||
|
||||
|
||||
def _status(**overrides: object) -> dict[str, object]:
|
||||
value: dict[str, object] = {
|
||||
"status": "active",
|
||||
"health": 200,
|
||||
"governed_writes_enabled": False,
|
||||
"legacy_global_writes_enabled": False,
|
||||
"task_principal_writes_enabled": False,
|
||||
}
|
||||
value.update(overrides)
|
||||
return value
|
||||
|
||||
|
||||
def _admission_handlers(owner: FakeOwnerPort, task: FakeTaskPort):
|
||||
return {
|
||||
"getTaskAdmissionEnvelope": owner.create_envelope,
|
||||
"submitTaskAdmissionAuthorization": lambda request: owner.authorize(
|
||||
request, task
|
||||
),
|
||||
"getTaskAdmissionStatus": lambda request: {
|
||||
"envelope_id": request["envelope_id"],
|
||||
"status": "PENDING",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _post(base_url: str, path: str, body: dict[str, object]):
|
||||
request = Request(
|
||||
base_url + path,
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=3) as response:
|
||||
return response.status, json.loads(response.read())
|
||||
except HTTPError as exc:
|
||||
return exc.code, json.loads(exc.read())
|
||||
|
||||
|
||||
def _get(base_url: str, path: str):
|
||||
try:
|
||||
with urlopen(base_url + path, timeout=3) as response:
|
||||
return response.status, json.loads(response.read())
|
||||
except HTTPError as exc:
|
||||
return exc.code, json.loads(exc.read())
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _serve(adapter: BridgeV2Adapter):
|
||||
class TestBridgeHandler(BridgeHandler):
|
||||
pass
|
||||
|
||||
TestBridgeHandler.adapter = adapter
|
||||
TestBridgeHandler.principal_verifier = None
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), TestBridgeHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{server.server_port}"
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=3)
|
||||
|
||||
|
||||
def test_capabilities_expose_admission_only_for_complete_callable_handlers():
|
||||
runtime = StubRuntime(_status(), {"getRuntimeStatus": lambda request: request})
|
||||
without_backend = build_bridge_adapter(runtime)
|
||||
assert not set(TASK_ADMISSION_OPERATIONS) & set(
|
||||
without_backend.getGovernedWriteCapabilities()["operations"]
|
||||
)
|
||||
assert not set(TASK_ADMISSION_OPERATIONS) & set(
|
||||
without_backend.getGovernedWriteCapabilities()["operation_contracts"]
|
||||
)
|
||||
|
||||
handlers = _admission_handlers(FakeOwnerPort(), FakeTaskPort())
|
||||
complete = build_bridge_adapter(runtime, task_admission_handlers=handlers)
|
||||
capabilities = complete.getGovernedWriteCapabilities()
|
||||
assert set(TASK_ADMISSION_OPERATIONS) <= set(capabilities["operations"])
|
||||
expected_routes = {
|
||||
"getTaskAdmissionEnvelope": ("POST", "/v2/task-admission/envelope"),
|
||||
"submitTaskAdmissionAuthorization": ("POST", "/v2/task-admission/authorize"),
|
||||
"getTaskAdmissionStatus": ("GET", "/v2/task-admission/status"),
|
||||
}
|
||||
for operation in TASK_ADMISSION_OPERATIONS:
|
||||
contract = capabilities["operation_contracts"][operation]
|
||||
assert (contract["method"], contract["path"]) == expected_routes[operation]
|
||||
assert contract["required_fields"] == list(
|
||||
TASK_ADMISSION_OPERATION_REQUIRED_FIELDS[operation]
|
||||
)
|
||||
assert contract["optional_fields"] == list(
|
||||
TASK_ADMISSION_OPERATION_OPTIONAL_FIELDS[operation]
|
||||
)
|
||||
|
||||
|
||||
def test_partial_task_admission_handlers_are_rejected():
|
||||
runtime = StubRuntime(_status())
|
||||
try:
|
||||
build_bridge_adapter(
|
||||
runtime,
|
||||
task_admission_handlers={
|
||||
"getTaskAdmissionEnvelope": lambda request: request,
|
||||
},
|
||||
)
|
||||
except ValueError as exc:
|
||||
assert str(exc) == "TASK_ADMISSION_HANDLERS_INCOMPLETE"
|
||||
else:
|
||||
raise AssertionError("partial Task Admission handlers must be rejected")
|
||||
|
||||
|
||||
def test_http_admission_backend_missing_returns_404():
|
||||
adapter = build_bridge_adapter(StubRuntime(_status()))
|
||||
with _serve(adapter) as base_url:
|
||||
status, body = _post(
|
||||
base_url,
|
||||
"/v2/task-admission/envelope",
|
||||
{"unexpected": True},
|
||||
)
|
||||
assert status == 404
|
||||
assert body == {"error": "NOT_FOUND"}
|
||||
|
||||
|
||||
def test_http_admission_envelope_and_status_work_without_task_principal():
|
||||
owner = FakeOwnerPort()
|
||||
task = FakeTaskPort()
|
||||
adapter = build_bridge_adapter(
|
||||
StubRuntime(_status()),
|
||||
task_admission_handlers=_admission_handlers(owner, task),
|
||||
)
|
||||
envelope = {
|
||||
"idempotency_key": "test",
|
||||
"task_id": "task-1",
|
||||
"repository": "aios/aios",
|
||||
"authority_base_oid": "a" * 40,
|
||||
"target_branch": "agent/task-1",
|
||||
"generation": 1,
|
||||
"execution_digest": "b" * 64,
|
||||
"requested_capabilities": ["testing"],
|
||||
"file_allowlist": ["tests/test_task.py"],
|
||||
"risk": "low",
|
||||
"expires_in_seconds": 60,
|
||||
}
|
||||
with _serve(adapter) as base_url:
|
||||
status, body = _post(base_url, "/v2/task-admission/envelope", envelope)
|
||||
assert status == 201
|
||||
assert body["envelope_id"] == "env-1"
|
||||
status, body = _get(
|
||||
base_url, "/v2/task-admission/status?envelope_id=" + quote("env-1")
|
||||
)
|
||||
assert status == 200
|
||||
assert body == {"envelope_id": "env-1", "status": "PENDING"}
|
||||
assert len(owner.envelopes) == 1
|
||||
assert task.calls == []
|
||||
|
||||
|
||||
def test_http_admission_rejects_bad_queries_and_caller_principal():
|
||||
owner = FakeOwnerPort()
|
||||
task = FakeTaskPort()
|
||||
adapter = build_bridge_adapter(
|
||||
StubRuntime(_status()),
|
||||
task_admission_handlers=_admission_handlers(owner, task),
|
||||
)
|
||||
envelope = {
|
||||
"idempotency_key": "test",
|
||||
"task_id": "task-1",
|
||||
"repository": "aios/aios",
|
||||
"authority_base_oid": "a" * 40,
|
||||
"target_branch": "agent/task-1",
|
||||
"generation": 1,
|
||||
"execution_digest": "b" * 64,
|
||||
"requested_capabilities": ["testing"],
|
||||
"file_allowlist": ["tests/test_task.py"],
|
||||
"risk": "low",
|
||||
}
|
||||
with _serve(adapter) as base_url:
|
||||
for query in (
|
||||
"unknown=x",
|
||||
"envelope_id=env-1&envelope_id=env-2",
|
||||
"envelope_id=",
|
||||
):
|
||||
status, body = _get(base_url, "/v2/task-admission/status?" + query)
|
||||
assert status == 400
|
||||
assert body["error"] == "TASK_ADMISSION_QUERY_INVALID"
|
||||
|
||||
status, body = _post(
|
||||
base_url,
|
||||
"/v2/task-admission/envelope",
|
||||
{**envelope, "principal": {"task_id": "caller-controlled"}},
|
||||
)
|
||||
assert status == 400
|
||||
assert body == {"error": "CALLER_PRINCIPAL_FORBIDDEN"}
|
||||
assert owner.envelopes == []
|
||||
assert task.calls == []
|
||||
|
||||
|
||||
def test_http_admission_proof_failures_use_task_admission_status_and_do_not_mutate():
|
||||
owner = FakeOwnerPort()
|
||||
task = FakeTaskPort()
|
||||
adapter = build_bridge_adapter(
|
||||
StubRuntime(_status()),
|
||||
task_admission_handlers=_admission_handlers(owner, task),
|
||||
)
|
||||
with _serve(adapter) as base_url:
|
||||
for kind, expected_status, expected_error in (
|
||||
("unsigned", 400, "TASK_ADMISSION_AUTHORIZATION_REQUIRED"),
|
||||
("bad_proof", 403, "TASK_ADMISSION_OWNER_SIGNATURE_INVALID"),
|
||||
):
|
||||
status, body = _post(
|
||||
base_url,
|
||||
"/v2/task-admission/authorize",
|
||||
{"envelope_id": "env-1", "authorization_kind": kind},
|
||||
)
|
||||
assert status == expected_status
|
||||
assert body == {"error": expected_error}
|
||||
assert task.calls == []
|
||||
|
||||
|
||||
def test_existing_governed_mutation_gates_are_unchanged():
|
||||
request = {
|
||||
"schema": "aios.writer-lease-request.v2",
|
||||
"task_id": "task-1",
|
||||
"job_id": "job-1",
|
||||
"repository": "aios/aios",
|
||||
"branch_mode": "CREATE_NEW",
|
||||
"base_oid": "a" * 40,
|
||||
"expected_branch_oid": "a" * 40,
|
||||
"target_branch": "agent/task-1",
|
||||
"generation": 1,
|
||||
"idempotency_key": "test",
|
||||
"lease_seconds": 60,
|
||||
}
|
||||
runtime = StubRuntime(_status(), {"acquireWriterLease": lambda body: body})
|
||||
with _serve(build_bridge_adapter(runtime)) as base_url:
|
||||
status, body = _post(base_url, "/v2/writer-lease", request)
|
||||
assert status == 423
|
||||
assert body == {"error": "TASK_PRINCIPAL_WRITES_DISABLED"}
|
||||
|
||||
enabled_runtime = StubRuntime(
|
||||
_status(
|
||||
governed_writes_enabled=True,
|
||||
task_principal_writes_enabled=True,
|
||||
),
|
||||
{"acquireWriterLease": lambda body: body},
|
||||
)
|
||||
with _serve(build_bridge_adapter(enabled_runtime)) as base_url:
|
||||
status, body = _post(base_url, "/v2/writer-lease", request)
|
||||
assert status == 401
|
||||
assert body == {"error": "TASK_PRINCIPAL_VERIFIER_UNAVAILABLE"}
|
||||
|
||||
|
||||
def test_post_admission_projection_is_exact_and_idempotent():
|
||||
class Authority:
|
||||
def __init__(self):
|
||||
self.branch = None
|
||||
|
||||
@staticmethod
|
||||
def commit_exists(oid):
|
||||
return oid == "a" * 40
|
||||
|
||||
def read_ref(self, ref):
|
||||
assert ref == "refs/heads/agent/admitted-task"
|
||||
return self.branch
|
||||
|
||||
class Bootstrap:
|
||||
def __init__(self):
|
||||
self.binding = None
|
||||
|
||||
def get_binding(self, task_id):
|
||||
if task_id != "admitted-task" or self.binding is None:
|
||||
raise BootstrapError("TASK_EXECUTION_PROJECTION_MISSING")
|
||||
return self.binding
|
||||
|
||||
def _write(self, binding):
|
||||
assert self.binding is None
|
||||
self.binding = binding
|
||||
|
||||
runtime = object.__new__(GovernedRuntime)
|
||||
runtime.config = SimpleNamespace(repository="aios/aios")
|
||||
runtime.authority = Authority()
|
||||
runtime.bootstrap = Bootstrap()
|
||||
runtime.bindings = {}
|
||||
grant = SimpleNamespace(
|
||||
task_id="admitted-task",
|
||||
repository="aios/aios",
|
||||
authority_base_oid="a" * 40,
|
||||
target_branch="agent/admitted-task",
|
||||
risk="low",
|
||||
generation=1,
|
||||
)
|
||||
task_result = {"external_job_id": "job-0123456789abcdef0123456789abcdef"}
|
||||
|
||||
first = runtime.project_admitted_task(grant, task_result)
|
||||
assert first["branch_mode"] == "CREATE_NEW"
|
||||
assert first["expected_branch_oid"] == "a" * 40
|
||||
assert first["job_id"] == task_result["external_job_id"]
|
||||
assert runtime.project_admitted_task(grant, task_result)["idempotent"] is True
|
||||
|
||||
runtime.authority.branch = "b" * 40
|
||||
changed = SimpleNamespace(**{**vars(grant), "task_id": "other-task"})
|
||||
try:
|
||||
runtime.project_admitted_task(changed, task_result)
|
||||
except RuntimeError as exc:
|
||||
assert str(exc) == "TASK_ADMISSION_PROJECTION_BRANCH_CONFLICT"
|
||||
else:
|
||||
raise AssertionError("drifted branch must not create a projection")
|
||||
|
||||
|
||||
def test_live_bridge_preservation_when_source_root_is_supplied():
|
||||
configured_source = os.environ.get("AIOS_LIVE_BRIDGE_SOURCE")
|
||||
if not configured_source:
|
||||
return
|
||||
source = Path(configured_source)
|
||||
live_server = source / "aios_chatgpt_bridge_v2_server.py" if source.is_dir() else source
|
||||
candidate_server = APP / "aios_chatgpt_bridge_v2_server.py"
|
||||
live_tree = ast.parse(live_server.read_text(encoding="utf-8"))
|
||||
candidate_tree = ast.parse(candidate_server.read_text(encoding="utf-8"))
|
||||
|
||||
def selected(tree):
|
||||
return [
|
||||
node
|
||||
for node in ast.walk(tree)
|
||||
if (isinstance(node, ast.ClassDef) and node.name == "GovernedRuntime")
|
||||
or (isinstance(node, ast.FunctionDef) and node.name == "_verified_request")
|
||||
]
|
||||
|
||||
def without_attributes(nodes):
|
||||
return [ast.dump(node, include_attributes=False) for node in nodes]
|
||||
|
||||
assert without_attributes(selected(candidate_tree)) == without_attributes(
|
||||
selected(live_tree)
|
||||
)
|
||||
@@ -0,0 +1,474 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
import subprocess
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP = ROOT / "active-runtime" / "app"
|
||||
if str(APP) not in sys.path:
|
||||
sys.path.insert(0, str(APP))
|
||||
|
||||
from aios_task_admission_bootstrap import ( # noqa: E402
|
||||
OwnerDelegationEpoch,
|
||||
TaskAdmissionBootstrapService,
|
||||
TaskAdmissionError,
|
||||
TaskAdmissionPolicy,
|
||||
TaskAdmissionStore,
|
||||
)
|
||||
|
||||
|
||||
NOW = 1_800_000_000
|
||||
CAPS = ("governed.task.create", "agent-task:control")
|
||||
|
||||
|
||||
class Authorization:
|
||||
def __init__(self, digest: str):
|
||||
self.epoch = OwnerDelegationEpoch(
|
||||
epoch_id="epoch-recovery", owner_identity="owner", trust_domain="aios.owner",
|
||||
repository="aios/aios", allowed_branch_prefixes=("agent/recovery-",),
|
||||
allowed_capabilities=frozenset(CAPS), allowed_file_prefixes=("docs",),
|
||||
risk_ceiling="medium", issued_at=NOW - 1, not_before=NOW - 1,
|
||||
expires_at=NOW + 600, revocation_epoch=0, policy_digest=digest,
|
||||
)
|
||||
|
||||
def verify_delegation_epoch(self, epoch_id):
|
||||
if epoch_id != self.epoch.epoch_id:
|
||||
raise TaskAdmissionError("UNKNOWN_EPOCH", 403)
|
||||
return self.epoch
|
||||
|
||||
def verify_owner_signature(self, **_kwargs):
|
||||
return "owner"
|
||||
|
||||
|
||||
class RecoveringPort:
|
||||
def __init__(self, *, fail_after_effect=False, fail_before_effect=False):
|
||||
self.fail_after_effect = fail_after_effect
|
||||
self.fail_before_effect = fail_before_effect
|
||||
self.posts = 0
|
||||
self.task_ids = set()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def admit(self, grant):
|
||||
with self._lock:
|
||||
if grant.task_id in self.task_ids:
|
||||
return {"task_id": grant.task_id, "state": "QUEUED"}
|
||||
if self.fail_before_effect:
|
||||
self.fail_before_effect = False
|
||||
raise RuntimeError("before effect")
|
||||
self.posts += 1
|
||||
self.task_ids.add(grant.task_id)
|
||||
if self.fail_after_effect:
|
||||
self.fail_after_effect = False
|
||||
raise RuntimeError("after effect")
|
||||
return {"task_id": grant.task_id, "state": "QUEUED"}
|
||||
|
||||
|
||||
def request():
|
||||
return {
|
||||
"idempotency_key": "recovery-admission-key", "task_id": "recovery-task-20260903",
|
||||
"repository": "aios/aios", "authority_base_oid": "a" * 40,
|
||||
"target_branch": "agent/recovery-20260903", "generation": 1,
|
||||
"execution_digest": "b" * 64, "requested_capabilities": list(CAPS),
|
||||
"file_allowlist": ["docs/recovery.md"], "risk": "medium", "expires_in_seconds": 300,
|
||||
}
|
||||
|
||||
|
||||
def make_service(path, port):
|
||||
policy = TaskAdmissionPolicy(allowed_capabilities=frozenset(CAPS))
|
||||
auth = Authorization(policy.normalized_policy_digest())
|
||||
return TaskAdmissionBootstrapService(
|
||||
store=TaskAdmissionStore(path), authorization=auth, task_port=port,
|
||||
policy=policy, clock=lambda: NOW,
|
||||
), auth
|
||||
|
||||
|
||||
@pytest.mark.parametrize("after_effect", [True, False])
|
||||
def test_replay_recovers_crash_window_with_one_effect(tmp_path, after_effect):
|
||||
database = tmp_path / "admission.sqlite3"
|
||||
port = RecoveringPort(
|
||||
fail_after_effect=after_effect, fail_before_effect=not after_effect,
|
||||
)
|
||||
first, auth = make_service(database, port)
|
||||
envelope_id = first.create_envelope(request())["envelope"]["envelope_id"]
|
||||
proof = {"envelope_id": envelope_id, "authorization_kind": "delegation_epoch", "delegation_epoch_id": auth.epoch.epoch_id}
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_TASK_STATE_UNKNOWN"):
|
||||
first.authorize_and_admit(proof)
|
||||
|
||||
second, _ = make_service(database, port)
|
||||
replay = second.authorize_and_admit(proof)
|
||||
assert replay["task_id"] == "recovery-task-20260903"
|
||||
assert port.posts == 1
|
||||
|
||||
|
||||
def test_cross_instance_recovery_lock_allows_one_effect(tmp_path):
|
||||
database = tmp_path / "admission.sqlite3"
|
||||
port = RecoveringPort()
|
||||
first, auth = make_service(database, port)
|
||||
envelope_id = first.create_envelope(request())["envelope"]["envelope_id"]
|
||||
proof = {"envelope_id": envelope_id, "authorization_kind": "delegation_epoch", "delegation_epoch_id": auth.epoch.epoch_id}
|
||||
services = [make_service(database, port)[0] for _ in range(16)]
|
||||
with ThreadPoolExecutor(max_workers=16) as pool:
|
||||
receipts = list(pool.map(lambda svc: svc.authorize_and_admit(proof), services))
|
||||
assert {value["task_id"] for value in receipts} == {"recovery-task-20260903"}
|
||||
assert port.posts == 1
|
||||
|
||||
|
||||
def test_changed_valid_epoch_proof_conflicts_after_receipt(tmp_path):
|
||||
service, auth = make_service(tmp_path / "admission.sqlite3", RecoveringPort())
|
||||
envelope_id = service.create_envelope(request())["envelope"]["envelope_id"]
|
||||
first = {"envelope_id": envelope_id, "authorization_kind": "delegation_epoch", "delegation_epoch_id": auth.epoch.epoch_id}
|
||||
service.authorize_and_admit(first)
|
||||
auth.epoch = OwnerDelegationEpoch(
|
||||
**{**auth.epoch.as_public_dict(), "epoch_id": "epoch-recovery-reissued",
|
||||
"allowed_capabilities": auth.epoch.allowed_capabilities,
|
||||
"allowed_branch_prefixes": auth.epoch.allowed_branch_prefixes,
|
||||
"allowed_file_prefixes": auth.epoch.allowed_file_prefixes}
|
||||
)
|
||||
changed = {**first, "delegation_epoch_id": auth.epoch.epoch_id}
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_AUTHORIZATION_CONFLICT"):
|
||||
service.authorize_and_admit(changed)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["docs/a.md", b"docs/a.md", {"docs/a.md": True}, [], ["docs/a\x01.md"], ["docs\\a.md"]])
|
||||
def test_file_allowlist_requires_canonical_sequence(tmp_path, value):
|
||||
service, _ = make_service(tmp_path / "admission.sqlite3", RecoveringPort())
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_(FILE_ALLOWLIST_REJECTED|FILE_PATH_INVALID)"):
|
||||
service.create_envelope({**request(), "file_allowlist": value})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("branch", ["agent/.hidden-task", "agent/recovery.lock", "agent/recovery@{x", "agent/recovery..task"])
|
||||
def test_branch_components_reject_git_ambiguous_spelling(tmp_path, branch):
|
||||
service, _ = make_service(tmp_path / "admission.sqlite3", RecoveringPort())
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_BRANCH_REJECTED"):
|
||||
service.create_envelope({**request(), "target_branch": branch})
|
||||
|
||||
|
||||
def test_process_death_releases_admission_lock(tmp_path):
|
||||
database = tmp_path / "death.sqlite3"
|
||||
script = (
|
||||
"import sys,os;from pathlib import Path;"
|
||||
"from aios_task_admission_bootstrap import TaskAdmissionStore;"
|
||||
"s=TaskAdmissionStore(Path(sys.argv[1]));"
|
||||
"lock=s.admission_lock('death-envelope');lock.__enter__();os._exit(17)"
|
||||
)
|
||||
result = subprocess.run([sys.executable, "-c", script, str(database)], check=False)
|
||||
assert result.returncode == 17
|
||||
with TaskAdmissionStore(database).admission_lock("death-envelope", timeout_seconds=1):
|
||||
pass
|
||||
|
||||
|
||||
def test_persisted_grant_cannot_outlive_its_own_shorter_ttl(tmp_path):
|
||||
port = RecoveringPort(fail_before_effect=True)
|
||||
service, auth = make_service(tmp_path / "expiry.sqlite3", port)
|
||||
service.policy = replace(service.policy, max_task_ttl_seconds=60)
|
||||
auth.epoch = replace(auth.epoch, policy_digest=service.policy.normalized_policy_digest())
|
||||
envelope = service.create_envelope(request())["envelope"]
|
||||
proof = {"envelope_id": envelope["envelope_id"], "authorization_kind": "delegation_epoch", "delegation_epoch_id": auth.epoch.epoch_id}
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_TASK_STATE_UNKNOWN"):
|
||||
service.authorize_and_admit(proof)
|
||||
service.clock = lambda: NOW + 61
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_GRANT_EXPIRED"):
|
||||
service.authorize_and_admit(proof)
|
||||
assert port.posts == 0
|
||||
|
||||
|
||||
def _adapter_fixture(tmp_path):
|
||||
from aios_task_admission_live_adapters import AgentTaskAdmissionPort
|
||||
service, auth = make_service(tmp_path / "adapter.sqlite3", RecoveringPort())
|
||||
envelope = service.create_envelope(request())["envelope"]
|
||||
service.authorize_and_admit({"envelope_id": envelope["envelope_id"], "authorization_kind": "delegation_epoch", "delegation_epoch_id": auth.epoch.epoch_id})
|
||||
with service.store._connection() as con:
|
||||
import json
|
||||
from aios_task_admission_bootstrap import TaskAdmissionGrant
|
||||
raw = json.loads(con.execute("SELECT grant_json FROM task_admission_grants").fetchone()[0])
|
||||
grant = TaskAdmissionGrant(**{**raw, "capabilities": tuple(raw["capabilities"]), "file_allowlist": tuple(raw["file_allowlist"])})
|
||||
plan = {"external_job_id": "fixture-exact-job", "repository": grant.repository,
|
||||
"target_branch": grant.target_branch, "generation": grant.generation,
|
||||
"execution_digest": grant.execution_digest, "steps": [{"operation": "fixture-only"}]}
|
||||
class Transport:
|
||||
posts = 0
|
||||
task = None
|
||||
error = None
|
||||
def request(self, method, path, body=None):
|
||||
if method == "POST": self.posts += 1; raise AssertionError("unexpected create")
|
||||
if self.error: raise self.error
|
||||
return self.task
|
||||
transport = Transport()
|
||||
port = AgentTaskAdmissionPort(transport, execution_plan_provider=lambda _: plan)
|
||||
transport.task = {**port._body(grant, plan), "status": "QUEUED"}
|
||||
return port, transport, grant, plan
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["metadata", "instructions", "required_capabilities", "constraints", "deadline", "priority"])
|
||||
def test_existing_task_conflict_never_becomes_absence(tmp_path, field):
|
||||
port, transport, grant, _ = _adapter_fixture(tmp_path)
|
||||
transport.task[field] = None
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_TASK_IDENTITY_CONFLICT"):
|
||||
port.admit(grant)
|
||||
assert transport.posts == 0
|
||||
|
||||
|
||||
def test_unknown_get_does_not_create_task(tmp_path):
|
||||
port, transport, grant, _ = _adapter_fixture(tmp_path)
|
||||
transport.error = TimeoutError("fixture readback unavailable")
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_TASK_STATE_UNKNOWN"):
|
||||
port.admit(grant)
|
||||
assert transport.posts == 0
|
||||
|
||||
|
||||
def test_execution_provider_keeps_full_payload_and_rejects_type_drift(tmp_path):
|
||||
port, transport, grant, plan = _adapter_fixture(tmp_path)
|
||||
assert port._execution_plan(grant) == plan
|
||||
plan["generation"] = True
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_EXECUTION_PLAN_CONFLICT"):
|
||||
port.admit(grant)
|
||||
assert transport.posts == 0
|
||||
|
||||
|
||||
def test_signed_epoch_host_provider_reverifies_signature_and_policy(tmp_path):
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from aios_task_admission_bootstrap import canonical_json_bytes
|
||||
from aios_task_admission_live_adapters import (
|
||||
SignedEpochDirectoryProvider,
|
||||
task_admission_policy_from_epoch,
|
||||
)
|
||||
|
||||
class Verifier:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def verify(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
return True
|
||||
|
||||
now = int(time.time())
|
||||
epoch_value = {
|
||||
"schema": "aios.owner-delegation-epoch.v1",
|
||||
"epoch_id": "owner-epoch-host-test-20260903",
|
||||
"owner_identity": "aios-task-grant-signer",
|
||||
"trust_domain": "aios.owner",
|
||||
"repository": "aios/aios",
|
||||
"allowed_branch_prefixes": ["agent/p0-test"],
|
||||
"allowed_capabilities": ["git.read", "governed.write.file"],
|
||||
"allowed_file_prefixes": ["active-runtime/app/example.py"],
|
||||
"risk_ceiling": "medium",
|
||||
"issued_at": now - 1,
|
||||
"not_before": now - 1,
|
||||
"expires_at": now + 600,
|
||||
"revocation_epoch": 0,
|
||||
"policy_digest": "a" * 64,
|
||||
}
|
||||
root = tmp_path / "epoch"
|
||||
root.mkdir(mode=0o700)
|
||||
(root / "owner-delegation-epoch.canonical.json").write_bytes(
|
||||
canonical_json_bytes(epoch_value)
|
||||
)
|
||||
(root / "owner-delegation-epoch.sig").write_text(
|
||||
"-----BEGIN SSH SIGNATURE-----\nabc\n-----END SSH SIGNATURE-----\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "owner-delegation-epoch.key-id").write_text(
|
||||
"SHA256:test-key\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
revocations = tmp_path / "revocations.json"
|
||||
revocations.write_bytes(
|
||||
canonical_json_bytes({
|
||||
"schema": "aios.owner-delegation-revocations.v1",
|
||||
"revoked_epoch_ids": [],
|
||||
})
|
||||
)
|
||||
for path in (
|
||||
root / "owner-delegation-epoch.canonical.json",
|
||||
root / "owner-delegation-epoch.sig",
|
||||
root / "owner-delegation-epoch.key-id",
|
||||
revocations,
|
||||
):
|
||||
path.chmod(0o600)
|
||||
|
||||
verifier = Verifier()
|
||||
caller = {"service": "test", "trust_domain": "aios.owner"}
|
||||
provider = SignedEpochDirectoryProvider(
|
||||
root=root,
|
||||
verifier=verifier,
|
||||
revocation_file=revocations,
|
||||
expected_caller=caller,
|
||||
require_root_owner=False,
|
||||
clock=lambda: now,
|
||||
)
|
||||
epoch = provider(epoch_id=epoch_value["epoch_id"], caller=caller)
|
||||
policy = task_admission_policy_from_epoch(epoch)
|
||||
assert verifier.calls[0]["namespace"] == "aios-owner-approval-v1"
|
||||
assert verifier.calls[0]["payload"] == canonical_json_bytes(epoch_value)
|
||||
assert policy.allowed_capabilities == frozenset(
|
||||
epoch_value["allowed_capabilities"]
|
||||
)
|
||||
assert policy.policy_digest == epoch_value["policy_digest"]
|
||||
|
||||
|
||||
def test_signed_epoch_host_provider_rejects_revocation_and_wrong_caller(tmp_path):
|
||||
import time
|
||||
from aios_task_admission_bootstrap import canonical_json_bytes
|
||||
from aios_task_admission_live_adapters import SignedEpochDirectoryProvider
|
||||
|
||||
class Verifier:
|
||||
def verify(self, **_kwargs):
|
||||
return True
|
||||
|
||||
now = int(time.time())
|
||||
epoch_id = "owner-epoch-host-revoked-20260903"
|
||||
root = tmp_path / "epoch"
|
||||
root.mkdir(mode=0o700)
|
||||
value = {
|
||||
"schema": "aios.owner-delegation-epoch.v1",
|
||||
"epoch_id": epoch_id,
|
||||
"owner_identity": "aios-task-grant-signer",
|
||||
"trust_domain": "aios.owner",
|
||||
"repository": "aios/aios",
|
||||
"allowed_branch_prefixes": ["agent/p0-test"],
|
||||
"allowed_capabilities": ["git.read"],
|
||||
"allowed_file_prefixes": ["docs/test.md"],
|
||||
"risk_ceiling": "medium",
|
||||
"issued_at": now - 1,
|
||||
"not_before": now - 1,
|
||||
"expires_at": now + 600,
|
||||
"revocation_epoch": 0,
|
||||
"policy_digest": "b" * 64,
|
||||
}
|
||||
(root / "owner-delegation-epoch.canonical.json").write_bytes(
|
||||
canonical_json_bytes(value)
|
||||
)
|
||||
(root / "owner-delegation-epoch.sig").write_text(
|
||||
"sig", encoding="utf-8"
|
||||
)
|
||||
(root / "owner-delegation-epoch.key-id").write_text(
|
||||
"SHA256:test-key", encoding="utf-8"
|
||||
)
|
||||
revocations = tmp_path / "revocations.json"
|
||||
revocations.write_bytes(
|
||||
canonical_json_bytes({
|
||||
"schema": "aios.owner-delegation-revocations.v1",
|
||||
"revoked_epoch_ids": [epoch_id],
|
||||
})
|
||||
)
|
||||
for path in list(root.iterdir()) + [revocations]:
|
||||
path.chmod(0o600)
|
||||
|
||||
caller = {"service": "test", "trust_domain": "aios.owner"}
|
||||
provider = SignedEpochDirectoryProvider(
|
||||
root=root,
|
||||
verifier=Verifier(),
|
||||
revocation_file=revocations,
|
||||
expected_caller=caller,
|
||||
require_root_owner=False,
|
||||
clock=lambda: now,
|
||||
)
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_CALLER_BINDING_MISMATCH"):
|
||||
provider(epoch_id=epoch_id, caller={"service": "wrong"})
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_EPOCH_REVOKED"):
|
||||
provider(epoch_id=epoch_id, caller=caller)
|
||||
|
||||
|
||||
def test_execution_plan_host_provider_is_content_addressed(tmp_path):
|
||||
import hashlib
|
||||
from types import SimpleNamespace
|
||||
from aios_task_admission_bootstrap import canonical_json_bytes
|
||||
from aios_task_admission_live_adapters import ExecutionPlanDirectoryProvider
|
||||
|
||||
plan = {
|
||||
"task_id": "p0-task-admission-bootstrap-20260903",
|
||||
"repository": "aios/aios",
|
||||
"target_branch": "agent/p0-task-admission-bootstrap-repair-20260903",
|
||||
"generation": 1,
|
||||
"external_job_id": "job-host-test",
|
||||
"files": [],
|
||||
}
|
||||
digest = hashlib.sha256(canonical_json_bytes(plan)).hexdigest()
|
||||
root = tmp_path / "plans"
|
||||
root.mkdir(mode=0o700)
|
||||
path = root / (digest + ".json")
|
||||
path.write_bytes(canonical_json_bytes(plan))
|
||||
path.chmod(0o600)
|
||||
grant = SimpleNamespace(
|
||||
execution_digest=digest,
|
||||
task_id=plan["task_id"],
|
||||
repository=plan["repository"],
|
||||
target_branch=plan["target_branch"],
|
||||
generation=1,
|
||||
)
|
||||
provider = ExecutionPlanDirectoryProvider(
|
||||
root=root,
|
||||
require_root_owner=False,
|
||||
)
|
||||
value = provider(grant)
|
||||
assert value["execution_digest"] == digest
|
||||
assert value["external_job_id"] == "job-host-test"
|
||||
|
||||
|
||||
def test_bridge_persistent_consumer_is_opt_in_and_default_fail_closed():
|
||||
source = (
|
||||
ROOT / "active-runtime" / "app" / "aios_chatgpt_bridge_v2_server.py"
|
||||
).read_text(encoding="utf-8")
|
||||
assert 'os.environ.get("AIOS_TASK_ADMISSION_EPOCH_ROOT", "").strip()' in source
|
||||
assert "build_task_admission_handlers_from_environment" in source
|
||||
assert "task_admission_handlers is None" in source
|
||||
|
||||
|
||||
def test_bridge_persistent_host_is_opt_in_and_activation_file_is_fail_closed():
|
||||
text = (APP / "aios_chatgpt_bridge_v2_server.py").read_text(encoding="utf-8")
|
||||
assert 'AIOS_TASK_ADMISSION_EPOCH_ROOT' in text
|
||||
assert 'AIOS_TASK_ADMISSION_ACTIVATION_FILE' in text
|
||||
assert '/var/lib/aios-gitops/bridge-v2-staging/task-admission-activation.json' in text
|
||||
assert 'Path(activation_raw).is_file()' in text
|
||||
assert 'build_task_admission_handlers_from_activation_file' in text
|
||||
|
||||
|
||||
def test_host_default_state_database_stays_inside_existing_bridge_state_root():
|
||||
text = (APP / "aios_task_admission_live_adapters.py").read_text(encoding="utf-8")
|
||||
assert '/var/lib/aios-gitops/bridge-v2-staging/task-admission.sqlite3' in text
|
||||
assert '/var/lib/aios-task-admission/state.sqlite3' not in text
|
||||
|
||||
def test_host_posix_permission_gate_is_skipped_when_posix_metadata_is_not_authoritative(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
import aios_task_admission_live_adapters as live
|
||||
|
||||
root = tmp_path / "non-posix-mode"
|
||||
root.mkdir()
|
||||
target = root / "value.json"
|
||||
target.write_text("{}", encoding="utf-8")
|
||||
|
||||
# Deliberately make the synthesized mode permissive. On Windows this is
|
||||
# normal and must not be interpreted as a POSIX trust failure.
|
||||
root.chmod(0o777)
|
||||
target.chmod(0o666)
|
||||
|
||||
monkeypatch.setattr(live, "_host_posix_metadata_enforced", lambda: False)
|
||||
live._host_require_directory(root, root_owned=True)
|
||||
live._host_require_regular(target, root_owned=True)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
__import__("os").name != "posix",
|
||||
reason="POSIX mode-bit enforcement is a Linux/Unix trust boundary",
|
||||
)
|
||||
def test_host_posix_permission_gate_rejects_group_or_other_writable(tmp_path):
|
||||
import aios_task_admission_live_adapters as live
|
||||
|
||||
root = tmp_path / "posix-mode"
|
||||
root.mkdir(mode=0o700)
|
||||
root.chmod(0o777)
|
||||
with pytest.raises(
|
||||
live.TaskAdmissionHostError,
|
||||
match="SECURE_DIRECTORY_WRITABLE_BY_GROUP_OR_OTHER",
|
||||
):
|
||||
live._host_require_directory(root, root_owned=False)
|
||||
@@ -0,0 +1,315 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
|
||||
import aios_task_lineage_supersession as sut
|
||||
|
||||
NOW = datetime(2026, 9, 5, 4, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class Verifier:
|
||||
def verify(self, *, namespace, payload, signature, key_id):
|
||||
return (
|
||||
namespace == sut.OWNER_NAMESPACE
|
||||
and key_id == sut.OWNER_KEY_ID
|
||||
and signature == "valid-owner-signature"
|
||||
and json.loads(payload)["signature_namespace"] == sut.OWNER_NAMESPACE
|
||||
)
|
||||
|
||||
|
||||
def canon(value):
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def formal_identity(row):
|
||||
value = {
|
||||
"task_id": row["task_id"],
|
||||
"goal": row["goal"],
|
||||
"instructions": row["instructions"],
|
||||
"success_criteria": json.loads(row["success_criteria_json"]),
|
||||
"required_capabilities": json.loads(row["required_capabilities_json"]),
|
||||
"constraints": json.loads(row["constraints_json"]),
|
||||
"metadata": json.loads(row["metadata_json"]),
|
||||
"priority": row["priority"],
|
||||
"max_attempts": row["max_attempts"],
|
||||
}
|
||||
return hashlib.sha256(canon(value).encode()).hexdigest()
|
||||
|
||||
|
||||
def create_db(tmp_path):
|
||||
path = tmp_path / "task.sqlite3"
|
||||
connection = sqlite3.connect(path)
|
||||
connection.executescript("""
|
||||
CREATE TABLE tasks(
|
||||
task_id TEXT PRIMARY KEY, goal TEXT NOT NULL, instructions TEXT NOT NULL,
|
||||
success_criteria_json TEXT NOT NULL, required_capabilities_json TEXT NOT NULL,
|
||||
constraints_json TEXT NOT NULL, metadata_json TEXT NOT NULL, deadline TEXT,
|
||||
priority INTEGER NOT NULL, max_attempts INTEGER NOT NULL,
|
||||
attempt INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL, claimed_by TEXT,
|
||||
lease_token_hash TEXT, lease_expires_at TEXT, result_json TEXT,
|
||||
result_digest TEXT, result_idempotency_key TEXT, verification_json TEXT,
|
||||
revision INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE events(
|
||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT, agent_id TEXT,
|
||||
event_type TEXT NOT NULL, payload_json TEXT NOT NULL, created_at TEXT NOT NULL
|
||||
);
|
||||
""")
|
||||
execution = {
|
||||
"task_id": sut.SOURCE_TASK_ID,
|
||||
"repository": sut.REPOSITORY,
|
||||
"target_branch": sut.TARGET_BRANCH,
|
||||
"generation": sut.GENERATION,
|
||||
"execution_digest": sut.EXECUTION_DIGEST,
|
||||
"external_job_id": sut.EXTERNAL_JOB_ID,
|
||||
}
|
||||
connection.execute(
|
||||
"INSERT INTO tasks VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
sut.SOURCE_TASK_ID, "Build trust root", "Use the governed writer.",
|
||||
canon([{"id": "complete", "description": "complete"}]),
|
||||
canon(["task.execute"]), canon({"generation": 1, "production_write": False}),
|
||||
canon({"execution": execution, "projection": {"already": "historical"}}),
|
||||
"2026-09-04T00:00:00+00:00", 100, 20, 0, "EXPIRED", None, None, None,
|
||||
None, None, None, None, 12, "2026-09-04T00:00:00+00:00", "2026-09-04T01:00:00+00:00",
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO events(task_id,agent_id,event_type,payload_json,created_at) VALUES(?,?,?,?,?)",
|
||||
(sut.SOURCE_TASK_ID, None, "TASK_CREATED", "{}", "2026-09-04T00:00:00+00:00"),
|
||||
)
|
||||
connection.executescript((ROOT / "active-runtime" / "migrations" / "005_task_lineage_supersession.sql").read_text())
|
||||
connection.commit()
|
||||
return path
|
||||
|
||||
|
||||
def row(path, task_id=sut.SOURCE_TASK_ID):
|
||||
connection = sqlite3.connect(path)
|
||||
connection.row_factory = sqlite3.Row
|
||||
result = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.close()
|
||||
return result
|
||||
|
||||
|
||||
def snapshot(path, task_id=sut.SOURCE_TASK_ID):
|
||||
value = row(path, task_id)
|
||||
return None if value is None else tuple(value)
|
||||
|
||||
|
||||
def decision(path, **changes):
|
||||
source = row(path)
|
||||
metadata_raw = source["metadata_json"]
|
||||
value = {
|
||||
"schema": sut.DECISION_SCHEMA,
|
||||
"decision_id": "decision-singlehost-lineage-s1",
|
||||
"nonce": "nonce-singlehost-lineage-s1",
|
||||
"source_task_id": sut.SOURCE_TASK_ID,
|
||||
"source_task_identity_sha256": formal_identity(source),
|
||||
"source_metadata_sha256": hashlib.sha256(metadata_raw.encode()).hexdigest(),
|
||||
"source_expected_status": "EXPIRED",
|
||||
"source_expected_revision": int(source["revision"]),
|
||||
"source_expected_attempt": 0,
|
||||
"source_expected_claimed_by": None,
|
||||
"source_expected_lease_token_hash": None,
|
||||
"source_expected_lease_expires_at": None,
|
||||
"repository": sut.REPOSITORY,
|
||||
"target_branch": sut.TARGET_BRANCH,
|
||||
"generation": sut.GENERATION,
|
||||
"execution_digest": sut.EXECUTION_DIGEST,
|
||||
"external_job_id": sut.EXTERNAL_JOB_ID,
|
||||
"child_task_id": sut.CHILD_TASK_ID,
|
||||
"supersession_ordinal": 1,
|
||||
"canonical_projection_profile": sut.CANONICAL_PROFILE,
|
||||
"canonical_projection_version": sut.CANONICAL_PROFILE_VERSION,
|
||||
"supersession_reason": sut.SUPERSESSION_REASON,
|
||||
"owner_identity": sut.OWNER_PRINCIPAL,
|
||||
"signature_namespace": sut.OWNER_NAMESPACE,
|
||||
"signature_principal": sut.OWNER_PRINCIPAL,
|
||||
"key_id": sut.OWNER_KEY_ID,
|
||||
"issued_at": NOW.isoformat(),
|
||||
"not_before": NOW.isoformat(),
|
||||
"expires_at": (NOW + timedelta(seconds=90)).isoformat(),
|
||||
"one_time_use": True,
|
||||
"signature": "valid-owner-signature",
|
||||
}
|
||||
value.update(changes)
|
||||
return value
|
||||
|
||||
|
||||
def controller(path):
|
||||
return sut.TaskLineageSupersessionController(
|
||||
path, Verifier(), clock=lambda: NOW, identity_digest=formal_identity
|
||||
)
|
||||
|
||||
|
||||
def test_supersession_is_atomic_append_only_and_exact_replay(tmp_path):
|
||||
path = create_db(tmp_path)
|
||||
source_before = snapshot(path)
|
||||
request = decision(path)
|
||||
result = controller(path).supersede(request)
|
||||
assert result["status"] == "SUPERSEDED"
|
||||
assert result["idempotent"] is False
|
||||
assert snapshot(path) == source_before
|
||||
child = row(path, sut.CHILD_TASK_ID)
|
||||
assert (child["status"], child["revision"], child["attempt"]) == ("QUEUED", 1, 0)
|
||||
assert (child["claimed_by"], child["lease_token_hash"], child["lease_expires_at"]) == (None, None, None)
|
||||
assert json.loads(child["metadata_json"]) == {}
|
||||
assert json.loads(child["required_capabilities_json"]) == []
|
||||
connection = sqlite3.connect(path)
|
||||
assert connection.execute(
|
||||
"SELECT event_type FROM events WHERE task_id=?", (sut.CHILD_TASK_ID,)
|
||||
).fetchall() == [("TASK_CREATED",)]
|
||||
event = json.loads(connection.execute(
|
||||
"SELECT payload_json FROM events WHERE task_id=?", (sut.CHILD_TASK_ID,)
|
||||
).fetchone()[0])
|
||||
assert event["supersedes_task_id"] == sut.SOURCE_TASK_ID
|
||||
assert event["owner_supersession_decision_sha256"] == result["owner_decision_sha256"]
|
||||
stored_decision = json.loads(connection.execute(
|
||||
"SELECT decision_json FROM task_lineage_supersession_receipts"
|
||||
).fetchone()[0])
|
||||
assert stored_decision == request
|
||||
assert result["owner_signature_key_id"] == sut.OWNER_KEY_ID
|
||||
with pytest.raises(sqlite3.IntegrityError, match="APPEND_ONLY"):
|
||||
connection.execute("UPDATE task_lineage_supersessions SET child_task_id='x'")
|
||||
connection.close()
|
||||
replay = controller(path).supersede(request)
|
||||
assert replay["idempotent"] is True
|
||||
assert replay["owner_decision_sha256"] == result["owner_decision_sha256"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field,value,code", [
|
||||
("signature", "wrong", "DECISION_SIGNATURE_INVALID"),
|
||||
("signature_namespace", "wrong", "SIGNATURE_NAMESPACE_MISMATCH"),
|
||||
("signature_principal", "old-owner", "SIGNATURE_PRINCIPAL_MISMATCH"),
|
||||
("key_id", "SHA256:old", "KEY_ID_MISMATCH"),
|
||||
("source_task_id", "TASK-other", "SOURCE_TASK_ID_MISMATCH"),
|
||||
("source_expected_revision", 11, "SOURCE_TASK_EXACT_CAS_MISMATCH"),
|
||||
("source_metadata_sha256", "0" * 64, "SOURCE_METADATA_DIGEST_MISMATCH"),
|
||||
("execution_digest", "0" * 64, "EXECUTION_DIGEST_MISMATCH"),
|
||||
("external_job_id", "job-00000000000000000000000000000000", "EXTERNAL_JOB_ID_MISMATCH"),
|
||||
("repository", "other/repo", "REPOSITORY_MISMATCH"),
|
||||
("target_branch", "agent/other", "TARGET_BRANCH_MISMATCH"),
|
||||
("child_task_id", sut.CHILD_TASK_ID + "-other", "CHILD_TASK_ID_MISMATCH"),
|
||||
("one_time_use", False, "ONE_TIME_USE_MISMATCH"),
|
||||
])
|
||||
def test_signed_scope_mismatch_fails_without_mutation(tmp_path, field, value, code):
|
||||
path = create_db(tmp_path)
|
||||
before = snapshot(path)
|
||||
with pytest.raises(sut.TaskLineageSupersessionError, match=code):
|
||||
controller(path).supersede(decision(path, **{field: value}))
|
||||
assert snapshot(path) == before
|
||||
assert row(path, sut.CHILD_TASK_ID) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("column,value", [
|
||||
("status", "QUEUED"), ("attempt", 1), ("claimed_by", "worker"),
|
||||
("lease_token_hash", "hash"), ("lease_expires_at", "2099-01-01T00:00:00Z"),
|
||||
])
|
||||
def test_live_source_state_drift_rolls_back(tmp_path, column, value):
|
||||
path = create_db(tmp_path)
|
||||
request = decision(path)
|
||||
connection = sqlite3.connect(path)
|
||||
connection.execute(f"UPDATE tasks SET {column}=? WHERE task_id=?", (value, sut.SOURCE_TASK_ID))
|
||||
connection.commit()
|
||||
before = snapshot(path)
|
||||
with pytest.raises(sut.TaskLineageSupersessionError, match="SOURCE_TASK_EXACT_CAS_MISMATCH"):
|
||||
controller(path).supersede(request)
|
||||
assert snapshot(path) == before
|
||||
assert row(path, sut.CHILD_TASK_ID) is None
|
||||
|
||||
|
||||
def test_child_or_prior_supersession_conflicts_fail_closed(tmp_path):
|
||||
path = create_db(tmp_path)
|
||||
request = decision(path)
|
||||
connection = sqlite3.connect(path)
|
||||
source = row(path)
|
||||
values = list(source)
|
||||
values[0] = sut.CHILD_TASK_ID
|
||||
connection.execute("INSERT INTO tasks VALUES(" + ",".join("?" for _ in values) + ")", values)
|
||||
connection.commit()
|
||||
before = snapshot(path)
|
||||
with pytest.raises(sut.TaskLineageSupersessionError, match="CHILD_TASK_ALREADY_EXISTS"):
|
||||
controller(path).supersede(request)
|
||||
assert snapshot(path) == before
|
||||
|
||||
|
||||
def test_prior_supersession_is_rejected_without_creating_child(tmp_path):
|
||||
path = create_db(tmp_path)
|
||||
request = decision(path)
|
||||
connection = sqlite3.connect(path)
|
||||
connection.execute(
|
||||
"INSERT INTO task_lineage_supersessions VALUES(?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
sut.SOURCE_TASK_ID, 1, sut.CHILD_TASK_ID,
|
||||
request["source_task_identity_sha256"], request["source_metadata_sha256"],
|
||||
"f" * 64, "prior-decision", NOW.isoformat(),
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
before = snapshot(path)
|
||||
with pytest.raises(sut.TaskLineageSupersessionError, match="SUPERSESSION_ALREADY_EXISTS"):
|
||||
controller(path).supersede(request)
|
||||
assert snapshot(path) == before
|
||||
assert row(path, sut.CHILD_TASK_ID) is None
|
||||
|
||||
|
||||
def test_event_failure_rolls_back_child_lineage_and_receipt(tmp_path):
|
||||
path = create_db(tmp_path)
|
||||
request = decision(path)
|
||||
before = snapshot(path)
|
||||
connection = sqlite3.connect(path)
|
||||
connection.execute("""
|
||||
CREATE TRIGGER reject_child_event BEFORE INSERT ON events
|
||||
WHEN NEW.task_id = 'TASK-SINGLEHOST-TRUSTROOT-P0-20260904-S1'
|
||||
BEGIN SELECT RAISE(ABORT, 'TEST_EVENT_FAILURE'); END
|
||||
""")
|
||||
connection.commit()
|
||||
with pytest.raises(sqlite3.IntegrityError, match="TEST_EVENT_FAILURE"):
|
||||
controller(path).supersede(request)
|
||||
assert snapshot(path) == before
|
||||
assert row(path, sut.CHILD_TASK_ID) is None
|
||||
connection = sqlite3.connect(path)
|
||||
assert connection.execute("SELECT count(*) FROM task_lineage_supersessions").fetchone()[0] == 0
|
||||
assert connection.execute("SELECT count(*) FROM task_lineage_supersession_receipts").fetchone()[0] == 0
|
||||
connection.close()
|
||||
|
||||
|
||||
def test_nonce_reuse_and_changed_decision_are_rejected(tmp_path):
|
||||
path = create_db(tmp_path)
|
||||
request = decision(path)
|
||||
controller(path).supersede(request)
|
||||
changed = dict(request)
|
||||
changed["expires_at"] = (NOW + timedelta(seconds=120)).isoformat()
|
||||
with pytest.raises(sut.TaskLineageSupersessionError, match="DECISION_REPLAY_MISMATCH"):
|
||||
controller(path).supersede(changed)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("times", [
|
||||
{"expires_at": (NOW - timedelta(seconds=1)).isoformat()},
|
||||
{"not_before": (NOW + timedelta(seconds=1)).isoformat()},
|
||||
{"expires_at": (NOW + timedelta(minutes=6)).isoformat()},
|
||||
])
|
||||
def test_decision_time_window_fails_closed(tmp_path, times):
|
||||
path = create_db(tmp_path)
|
||||
with pytest.raises(sut.TaskLineageSupersessionError):
|
||||
controller(path).supersede(decision(path, **times))
|
||||
assert row(path, sut.CHILD_TASK_ID) is None
|
||||
|
||||
|
||||
def test_arbitrary_child_payload_is_rejected(tmp_path):
|
||||
path = create_db(tmp_path)
|
||||
request = decision(path)
|
||||
request["child_payload"] = {"status": "RUNNING", "revision": 99}
|
||||
with pytest.raises(sut.TaskLineageSupersessionError, match="DECISION_FIELDS_INVALID"):
|
||||
controller(path).supersede(request)
|
||||
assert row(path, sut.CHILD_TASK_ID) is None
|
||||
@@ -0,0 +1,333 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
|
||||
from aios_agent_task_initial_execution_projection import (
|
||||
InitialExecutionProjectionError,
|
||||
InitialExecutionProjectionRequest,
|
||||
InitialExecutionProjectionStore,
|
||||
)
|
||||
from aios_online_agent_execution import (
|
||||
GovernedExecutionBinding,
|
||||
project_execution_envelope,
|
||||
)
|
||||
import aios_task_lineage_supersession as supersession
|
||||
from aios_task_lineage_supersession_initial_projection import (
|
||||
PROJECTION_TABLE,
|
||||
SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
SupersessionChildInitialProjectionStore,
|
||||
SupersessionChildProjectionIntent,
|
||||
)
|
||||
from test_task_lineage_supersession import (
|
||||
controller,
|
||||
create_db,
|
||||
decision,
|
||||
snapshot,
|
||||
)
|
||||
|
||||
|
||||
AUTHORIZATION = "owner-supersession-child-initial-projection-20260905"
|
||||
CAPABILITY = "agent.runtime.openclaw"
|
||||
BASE_OID = "a" * 40
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _authorization(monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"AIOS_AGENT_TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZATION_ID",
|
||||
AUTHORIZATION,
|
||||
)
|
||||
|
||||
|
||||
def _sha_json(value):
|
||||
return hashlib.sha256(
|
||||
json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _apply_projection_migration(path: Path) -> None:
|
||||
sql = (
|
||||
ROOT
|
||||
/ "active-runtime"
|
||||
/ "migrations"
|
||||
/ "006_task_lineage_supersession_initial_projection.sql"
|
||||
).read_text()
|
||||
connection = sqlite3.connect(path)
|
||||
connection.executescript(sql)
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
|
||||
def _superseded_db(tmp_path: Path) -> tuple[Path, tuple, dict]:
|
||||
path = create_db(tmp_path)
|
||||
_apply_projection_migration(path)
|
||||
source_before = snapshot(path)
|
||||
supersession_receipt = controller(path).supersede(decision(path))
|
||||
return path, source_before, supersession_receipt
|
||||
|
||||
|
||||
def _intent(**changes):
|
||||
value = {
|
||||
"adapter": "openclaw",
|
||||
"required_capability": CAPABILITY,
|
||||
"authorization_id": AUTHORIZATION,
|
||||
}
|
||||
value.update(changes)
|
||||
return value
|
||||
|
||||
|
||||
def _resolver(task_id, job_id, capability, adapter, runtime_input):
|
||||
binding = GovernedExecutionBinding.from_mapping(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"job_id": job_id,
|
||||
"repository": supersession.REPOSITORY,
|
||||
"base_oid": BASE_OID,
|
||||
"expected_branch_oid": BASE_OID,
|
||||
"target_branch": supersession.TARGET_BRANCH,
|
||||
"branch_mode": "CREATE_NEW",
|
||||
"generation": supersession.GENERATION,
|
||||
"idempotency_key": "singlehost-lineage-s1-execution-envelope-v1",
|
||||
}
|
||||
)
|
||||
return project_execution_envelope(
|
||||
binding,
|
||||
{
|
||||
"action": "agent_runtime",
|
||||
"adapter": adapter,
|
||||
"capability_scope": [capability],
|
||||
"runtime_input": runtime_input,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_real_projector_abi_derives_distinct_child_digest_and_lineage_receipt(tmp_path):
|
||||
path, source_before, supersession_receipt = _superseded_db(tmp_path)
|
||||
result = SupersessionChildInitialProjectionStore(path, _resolver).project(
|
||||
_intent()
|
||||
)
|
||||
assert result["idempotent"] is False
|
||||
assert result["source_business_execution_digest"] == SOURCE_BUSINESS_EXECUTION_DIGEST
|
||||
assert result["canonical_child_execution_digest"] != SOURCE_BUSINESS_EXECUTION_DIGEST
|
||||
assert result["owner_supersession_decision_sha256"] == supersession_receipt["owner_decision_sha256"]
|
||||
assert snapshot(path) == source_before
|
||||
|
||||
connection = sqlite3.connect(path)
|
||||
connection.row_factory = sqlite3.Row
|
||||
child = connection.execute(
|
||||
"SELECT * FROM tasks WHERE task_id=?", (supersession.CHILD_TASK_ID,)
|
||||
).fetchone()
|
||||
assert (child["status"], child["revision"], child["attempt"]) == (
|
||||
"QUEUED",
|
||||
2,
|
||||
0,
|
||||
)
|
||||
assert child["claimed_by"] is None
|
||||
metadata = json.loads(child["metadata_json"])
|
||||
execution = metadata["execution"]
|
||||
assert execution["task_id"] == supersession.CHILD_TASK_ID
|
||||
assert execution["job_id"] == supersession.EXTERNAL_JOB_ID
|
||||
assert execution["repository"] == supersession.REPOSITORY
|
||||
assert execution["target_branch"] == supersession.TARGET_BRANCH
|
||||
assert execution["generation"] == supersession.GENERATION
|
||||
assert execution["execution_digest"] == result["canonical_child_execution_digest"]
|
||||
assert execution["execution_digest"] != supersession.EXECUTION_DIGEST
|
||||
assert execution["action"] == "agent_runtime"
|
||||
assert execution["change_set"] == []
|
||||
assert execution["verification"] == []
|
||||
assert json.loads(child["required_capabilities_json"]) == [CAPABILITY]
|
||||
|
||||
events = connection.execute(
|
||||
"SELECT event_type,payload_json FROM events WHERE task_id=? ORDER BY event_id",
|
||||
(supersession.CHILD_TASK_ID,),
|
||||
).fetchall()
|
||||
assert [row["event_type"] for row in events] == [
|
||||
"TASK_CREATED",
|
||||
"TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZED",
|
||||
]
|
||||
projected = json.loads(events[-1]["payload_json"])
|
||||
assert projected["source_business_execution_digest"] == supersession.EXECUTION_DIGEST
|
||||
assert projected["canonical_child_execution_digest"] == execution["execution_digest"]
|
||||
assert projected["owner_supersession_decision_sha256"] == supersession_receipt["owner_decision_sha256"]
|
||||
|
||||
durable = connection.execute(
|
||||
f"SELECT * FROM {PROJECTION_TABLE} WHERE task_id=?",
|
||||
(supersession.CHILD_TASK_ID,),
|
||||
).fetchone()
|
||||
assert durable["source_business_execution_digest"] == supersession.EXECUTION_DIGEST
|
||||
assert durable["canonical_child_execution_digest"] == execution["execution_digest"]
|
||||
assert durable["expected_revision"] == 1
|
||||
assert durable["applied_revision"] == 2
|
||||
with pytest.raises(sqlite3.IntegrityError, match="APPEND_ONLY"):
|
||||
connection.execute(
|
||||
f"UPDATE {PROJECTION_TABLE} SET adapter='hermes' WHERE task_id=?",
|
||||
(supersession.CHILD_TASK_ID,),
|
||||
)
|
||||
connection.rollback()
|
||||
connection.close()
|
||||
|
||||
|
||||
def test_exact_replay_needs_no_bridge_resolution(tmp_path):
|
||||
path, _, _ = _superseded_db(tmp_path)
|
||||
first = SupersessionChildInitialProjectionStore(path, _resolver).project(_intent())
|
||||
|
||||
def unavailable(*_args):
|
||||
raise AssertionError("resolver must not run for exact durable replay")
|
||||
|
||||
second = SupersessionChildInitialProjectionStore(path, unavailable).project(
|
||||
_intent()
|
||||
)
|
||||
assert second["idempotent"] is True
|
||||
assert second["canonical_child_execution_digest"] == first["canonical_child_execution_digest"]
|
||||
|
||||
|
||||
def test_generic_initial_projection_cannot_project_a_formal_lineage_child(tmp_path):
|
||||
path, _, _ = _superseded_db(tmp_path)
|
||||
execution = _resolver(
|
||||
supersession.CHILD_TASK_ID,
|
||||
supersession.EXTERNAL_JOB_ID,
|
||||
CAPABILITY,
|
||||
"openclaw",
|
||||
{
|
||||
"goal": "Build trust root",
|
||||
"instructions": "Use the governed writer.",
|
||||
"success_criteria": [{"id": "complete", "description": "complete"}],
|
||||
"constraints": {"generation": 1, "production_write": False},
|
||||
"scope": {"scope_type": "legacy", "tenant_id": None, "project_id": None},
|
||||
"trace_id": supersession.CHILD_TASK_ID,
|
||||
},
|
||||
)
|
||||
request = 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": supersession.EXTERNAL_JOB_ID,
|
||||
"expected_execution_digest": execution["execution_digest"],
|
||||
"required_capability": CAPABILITY,
|
||||
"adapter": "openclaw",
|
||||
"authorization_id": AUTHORIZATION,
|
||||
"idempotency_key": "forbidden-generic-child-projection-v1",
|
||||
"reason": "must use formal lineage projection endpoint",
|
||||
}
|
||||
)
|
||||
with pytest.raises(
|
||||
InitialExecutionProjectionError,
|
||||
match="SUPERSESSION_INITIAL_PROJECTION_ENDPOINT_REQUIRED",
|
||||
):
|
||||
InitialExecutionProjectionStore(path, _resolver).project(
|
||||
supersession.CHILD_TASK_ID, request
|
||||
)
|
||||
connection = sqlite3.connect(path)
|
||||
child = connection.execute(
|
||||
"SELECT revision,metadata_json FROM tasks WHERE task_id=?",
|
||||
(supersession.CHILD_TASK_ID,),
|
||||
).fetchone()
|
||||
assert child == (1, "{}")
|
||||
assert connection.execute(
|
||||
"SELECT count(*) FROM events WHERE task_id=?",
|
||||
(supersession.CHILD_TASK_ID,),
|
||||
).fetchone()[0] == 1
|
||||
connection.close()
|
||||
|
||||
|
||||
def test_intent_rejects_arbitrary_child_payload_and_mismatched_adapter():
|
||||
with pytest.raises(
|
||||
InitialExecutionProjectionError,
|
||||
match="SUPERSESSION_INITIAL_PROJECTION_FIELDS_INVALID",
|
||||
):
|
||||
SupersessionChildProjectionIntent.from_mapping(
|
||||
{**_intent(), "child_payload": {"metadata": {"execution_digest": "x"}}}
|
||||
)
|
||||
with pytest.raises(
|
||||
InitialExecutionProjectionError, match="RUNTIME_CAPABILITY_MISMATCH"
|
||||
):
|
||||
SupersessionChildProjectionIntent.from_mapping(
|
||||
_intent(adapter="hermes")
|
||||
)
|
||||
|
||||
|
||||
def test_wrong_canonical_binding_fails_without_task_mutation(tmp_path):
|
||||
path, _, _ = _superseded_db(tmp_path)
|
||||
|
||||
def wrong_binding(_task_id, job_id, capability, adapter, runtime_input):
|
||||
return _resolver(
|
||||
supersession.SOURCE_TASK_ID,
|
||||
job_id,
|
||||
capability,
|
||||
adapter,
|
||||
runtime_input,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InitialExecutionProjectionError,
|
||||
match="SUPERSESSION_CANONICAL_EXECUTION_BINDING_MISMATCH",
|
||||
):
|
||||
SupersessionChildInitialProjectionStore(path, wrong_binding).project(
|
||||
_intent()
|
||||
)
|
||||
connection = sqlite3.connect(path)
|
||||
child = connection.execute(
|
||||
"SELECT revision,metadata_json,required_capabilities_json FROM tasks "
|
||||
"WHERE task_id=?",
|
||||
(supersession.CHILD_TASK_ID,),
|
||||
).fetchone()
|
||||
assert child == (1, "{}", "[]")
|
||||
assert connection.execute(f"SELECT count(*) FROM {PROJECTION_TABLE}").fetchone()[0] == 0
|
||||
connection.close()
|
||||
|
||||
|
||||
def test_projection_receipt_failure_rolls_back_task_event_and_generic_receipt(tmp_path):
|
||||
path, _, _ = _superseded_db(tmp_path)
|
||||
connection = sqlite3.connect(path)
|
||||
connection.execute(
|
||||
f"""CREATE TRIGGER force_projection_receipt_failure
|
||||
BEFORE INSERT ON {PROJECTION_TABLE} BEGIN
|
||||
SELECT RAISE(ABORT, 'FORCED_PROJECTION_RECEIPT_FAILURE');
|
||||
END"""
|
||||
)
|
||||
connection.commit()
|
||||
connection.close()
|
||||
with pytest.raises(sqlite3.IntegrityError, match="FORCED_PROJECTION_RECEIPT_FAILURE"):
|
||||
SupersessionChildInitialProjectionStore(path, _resolver).project(_intent())
|
||||
connection = sqlite3.connect(path)
|
||||
child = connection.execute(
|
||||
"SELECT revision,metadata_json,required_capabilities_json FROM tasks "
|
||||
"WHERE task_id=?",
|
||||
(supersession.CHILD_TASK_ID,),
|
||||
).fetchone()
|
||||
assert child == (1, "{}", "[]")
|
||||
assert connection.execute(
|
||||
"SELECT count(*) FROM events WHERE task_id=?",
|
||||
(supersession.CHILD_TASK_ID,),
|
||||
).fetchone()[0] == 1
|
||||
assert connection.execute(
|
||||
"SELECT count(*) FROM task_initial_execution_projections WHERE task_id=?",
|
||||
(supersession.CHILD_TASK_ID,),
|
||||
).fetchone()[0] == 0
|
||||
assert connection.execute(f"SELECT count(*) FROM {PROJECTION_TABLE}").fetchone()[0] == 0
|
||||
connection.close()
|
||||
|
||||
|
||||
def test_projection_migration_is_idempotent(tmp_path):
|
||||
path = create_db(tmp_path)
|
||||
_apply_projection_migration(path)
|
||||
_apply_projection_migration(path)
|
||||
connection = sqlite3.connect(path)
|
||||
assert connection.execute(
|
||||
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?",
|
||||
(PROJECTION_TABLE,),
|
||||
).fetchone()[0] == 1
|
||||
connection.close()
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from aios_single_host_authority import ssh_fingerprint, ssh_public_line
|
||||
from aios_single_host_operational_authority import (
|
||||
OperationalAuthorityError,
|
||||
OperationalOwnerAuthority,
|
||||
OperationalVerifier,
|
||||
build_exact_policy,
|
||||
)
|
||||
from aios_task_lineage_supersession_owner_authority import (
|
||||
CHILD_TASK_ID,
|
||||
EXTERNAL_JOB_IDENTITY,
|
||||
GENERATION,
|
||||
REPOSITORY,
|
||||
SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
SOURCE_TASK_ID,
|
||||
SUPERSESSION_ORDINAL,
|
||||
TARGET_BRANCH,
|
||||
TaskLineageSupersessionOwnerAuthority,
|
||||
verify_task_lineage_supersession_scope,
|
||||
)
|
||||
|
||||
FIXED_NOW = 2_000_000_000
|
||||
SOURCE_IDENTITY = "1e6e543443203debc955b4fc5980433dc1b5e403a15a5d5a30f66dad76784bb9"
|
||||
METADATA_DIGEST = "e68cdae3ea85d2af74058fa435bab5cdba7f47059cbacfe03e0a21a5d034175f"
|
||||
|
||||
|
||||
def _write_key(path: Path, key: Ed25519PrivateKey) -> None:
|
||||
path.write_bytes(
|
||||
key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authority_env(tmp_path):
|
||||
key = Ed25519PrivateKey.generate()
|
||||
key_path = tmp_path / "owner.key"
|
||||
_write_key(key_path, key)
|
||||
task = {
|
||||
"task_id": SOURCE_TASK_ID,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"base_oid": "91f104ef62a3b688a9259b96e2ca247e0363b564",
|
||||
"generation": GENERATION,
|
||||
"execution_plan_sha256": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"external_job_identity": EXTERNAL_JOB_IDENTITY,
|
||||
}
|
||||
policy = build_exact_policy(task, worker_id="p0-task-principal-v15-20260830")
|
||||
assert policy.task_identity_sha256 == SOURCE_IDENTITY
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": "aios.single-host-signer-registry.v1",
|
||||
"authority_generation": 1,
|
||||
"signers": [
|
||||
{
|
||||
"signer_id": "local-owner-authority",
|
||||
"role": "OWNER_AUTHORITY",
|
||||
"provider": "local_unix_service",
|
||||
"fingerprint": ssh_fingerprint(key.public_key()),
|
||||
"public_key": ssh_public_line(key.public_key(), "owner"),
|
||||
"socket": "/run/aios/owner-authority.sock",
|
||||
"secret_ref": "aios-secret://owner",
|
||||
"status": "ACTIVE",
|
||||
"generation": 1,
|
||||
"parent_signer_id": "",
|
||||
"parent_fingerprint": "",
|
||||
}
|
||||
],
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
os.chmod(registry_path, 0o644)
|
||||
return key_path, registry_path, policy
|
||||
|
||||
|
||||
def test_fixed_constructor_uses_real_owner_signature_and_exact_scope(authority_env):
|
||||
key_path, registry_path, policy = authority_env
|
||||
tokens = iter(("a" * 32, "b" * 32))
|
||||
owner = TaskLineageSupersessionOwnerAuthority(
|
||||
key_path,
|
||||
policy,
|
||||
require_root_owner=False,
|
||||
clock=lambda: FIXED_NOW,
|
||||
token_hex=lambda _size: next(tokens),
|
||||
)
|
||||
artifact = owner.issue_task_lineage_supersession(
|
||||
source_expected_revision=12,
|
||||
source_task_identity_sha256=SOURCE_IDENTITY,
|
||||
source_metadata_sha256=METADATA_DIGEST,
|
||||
)
|
||||
payload = OperationalVerifier(
|
||||
registry_path,
|
||||
require_root_owner=False,
|
||||
).owner(artifact)
|
||||
verify_task_lineage_supersession_scope(payload, policy, now=FIXED_NOW)
|
||||
assert payload["source_task_id"] == SOURCE_TASK_ID
|
||||
assert payload["child_task_id"] == CHILD_TASK_ID
|
||||
assert payload["supersession_ordinal"] == SUPERSESSION_ORDINAL
|
||||
assert payload["source_business_execution_digest"] == SOURCE_BUSINESS_EXECUTION_DIGEST
|
||||
assert payload["decision_id"] == "task-lineage-supersession-" + "a" * 32
|
||||
assert payload["nonce"] == "task-lineage-supersession-nonce-" + "b" * 32
|
||||
assert (payload["issued_at"], payload["not_before"], payload["expires_at"]) == (
|
||||
FIXED_NOW,
|
||||
FIXED_NOW - 5,
|
||||
FIXED_NOW + 300,
|
||||
)
|
||||
|
||||
|
||||
def test_old_owner_signer_has_no_lineage_constructor(authority_env):
|
||||
key_path, _registry_path, policy = authority_env
|
||||
old_owner = OperationalOwnerAuthority(
|
||||
key_path,
|
||||
policy,
|
||||
require_root_owner=False,
|
||||
clock=lambda: FIXED_NOW,
|
||||
)
|
||||
assert not hasattr(old_owner, "issue_task_lineage_supersession")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("updates", "code"),
|
||||
[
|
||||
({"source_expected_revision": 0}, "TASK_LINEAGE_SUPERSESSION_REVISION_INVALID"),
|
||||
({"source_expected_revision": True}, "TASK_LINEAGE_SUPERSESSION_REVISION_INVALID"),
|
||||
({"source_task_identity_sha256": "0" * 64}, "TASK_LINEAGE_SUPERSESSION_SOURCE_IDENTITY_INVALID"),
|
||||
({"source_metadata_sha256": "X" * 64}, "TASK_LINEAGE_SUPERSESSION_METADATA_DIGEST_INVALID"),
|
||||
],
|
||||
)
|
||||
def test_dynamic_cas_inputs_fail_closed(authority_env, updates, code):
|
||||
key_path, _registry_path, policy = authority_env
|
||||
owner = TaskLineageSupersessionOwnerAuthority(
|
||||
key_path,
|
||||
policy,
|
||||
require_root_owner=False,
|
||||
clock=lambda: FIXED_NOW,
|
||||
)
|
||||
request = {
|
||||
"source_expected_revision": 12,
|
||||
"source_task_identity_sha256": SOURCE_IDENTITY,
|
||||
"source_metadata_sha256": METADATA_DIGEST,
|
||||
}
|
||||
request.update(updates)
|
||||
with pytest.raises(OperationalAuthorityError, match=code):
|
||||
owner.issue_task_lineage_supersession(**request)
|
||||
|
||||
|
||||
def test_stale_owner_signer_is_rejected_by_current_registry(authority_env):
|
||||
key_path, registry_path, policy = authority_env
|
||||
stale_key_path = key_path.parent / "stale-owner.key"
|
||||
_write_key(stale_key_path, Ed25519PrivateKey.generate())
|
||||
tokens = iter(("e" * 32, "f" * 32))
|
||||
stale_owner = TaskLineageSupersessionOwnerAuthority(
|
||||
stale_key_path,
|
||||
policy,
|
||||
require_root_owner=False,
|
||||
clock=lambda: FIXED_NOW,
|
||||
token_hex=lambda _size: next(tokens),
|
||||
)
|
||||
artifact = stale_owner.issue_task_lineage_supersession(
|
||||
source_expected_revision=12,
|
||||
source_task_identity_sha256=SOURCE_IDENTITY,
|
||||
source_metadata_sha256=METADATA_DIGEST,
|
||||
)
|
||||
verifier = OperationalVerifier(registry_path, require_root_owner=False)
|
||||
with pytest.raises(OperationalAuthorityError, match="SIGNED_ARTIFACT_KEY_ID_INVALID"):
|
||||
verifier.owner(artifact)
|
||||
@@ -0,0 +1,253 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from aios_single_host_authority import ssh_fingerprint, ssh_public_line
|
||||
from aios_single_host_operational_authority import OperationalVerifier, build_exact_policy
|
||||
from aios_single_host_operational_authority_server import (
|
||||
OperationalAuthorityService as OldOperationalAuthorityService,
|
||||
OperationalServiceError,
|
||||
)
|
||||
from aios_task_lineage_supersession_owner_authority import (
|
||||
EXTERNAL_JOB_IDENTITY,
|
||||
GENERATION,
|
||||
REPOSITORY,
|
||||
SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
SOURCE_TASK_ID,
|
||||
TARGET_BRANCH,
|
||||
TASK_LINEAGE_SUPERSESSION_OPERATION,
|
||||
TASK_LINEAGE_SUPERSESSION_SCHEMA,
|
||||
verify_task_lineage_supersession_scope,
|
||||
)
|
||||
from aios_task_lineage_supersession_owner_authority_server import OperationalAuthorityService
|
||||
|
||||
FIXED_NOW = 2_000_000_000
|
||||
SOURCE_IDENTITY = "1e6e543443203debc955b4fc5980433dc1b5e403a15a5d5a30f66dad76784bb9"
|
||||
METADATA_DIGEST = "e68cdae3ea85d2af74058fa435bab5cdba7f47059cbacfe03e0a21a5d034175f"
|
||||
|
||||
|
||||
def _write_key(path: Path, key: Ed25519PrivateKey) -> None:
|
||||
path.write_bytes(
|
||||
key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service_env(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
owner_path = tmp_path / "owner.key"
|
||||
machine_path = tmp_path / "machine.key"
|
||||
_write_key(owner_path, owner_key)
|
||||
_write_key(machine_path, machine_key)
|
||||
task = {
|
||||
"task_id": SOURCE_TASK_ID,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"base_oid": "91f104ef62a3b688a9259b96e2ca247e0363b564",
|
||||
"generation": GENERATION,
|
||||
"execution_plan_sha256": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"external_job_identity": EXTERNAL_JOB_IDENTITY,
|
||||
}
|
||||
policy = build_exact_policy(task, worker_id="p0-task-principal-v15-20260830")
|
||||
assert policy.task_identity_sha256 == SOURCE_IDENTITY
|
||||
policy_path = tmp_path / "policy.json"
|
||||
policy_path.write_text(json.dumps(asdict(policy), sort_keys=True) + "\n")
|
||||
os.chmod(policy_path, 0o600)
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": "aios.single-host-signer-registry.v1",
|
||||
"authority_generation": 1,
|
||||
"signers": [
|
||||
{
|
||||
"signer_id": "local-owner-authority",
|
||||
"role": "OWNER_AUTHORITY",
|
||||
"provider": "local_unix_service",
|
||||
"fingerprint": ssh_fingerprint(owner_key.public_key()),
|
||||
"public_key": ssh_public_line(owner_key.public_key(), "owner"),
|
||||
"socket": "/run/aios/owner-authority.sock",
|
||||
"secret_ref": "aios-secret://owner",
|
||||
"status": "ACTIVE",
|
||||
"generation": 1,
|
||||
"parent_signer_id": "",
|
||||
"parent_fingerprint": "",
|
||||
},
|
||||
{
|
||||
"signer_id": "machine-operational",
|
||||
"role": "MACHINE_OPERATIONAL",
|
||||
"provider": "local_unix_service",
|
||||
"fingerprint": ssh_fingerprint(machine_key.public_key()),
|
||||
"public_key": ssh_public_line(machine_key.public_key(), "machine"),
|
||||
"socket": "/run/aios/machine-signer.sock",
|
||||
"secret_ref": "aios-secret://machine",
|
||||
"status": "ACTIVE",
|
||||
"generation": 1,
|
||||
"parent_signer_id": "local-owner-authority",
|
||||
"parent_fingerprint": ssh_fingerprint(owner_key.public_key()),
|
||||
},
|
||||
],
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
os.chmod(registry_path, 0o644)
|
||||
return {
|
||||
"policy": policy,
|
||||
"policy_path": policy_path,
|
||||
"registry_path": registry_path,
|
||||
"owner_path": owner_path,
|
||||
"machine_path": machine_path,
|
||||
"ledger_path": tmp_path / "ledger.sqlite3",
|
||||
}
|
||||
|
||||
|
||||
def _service(env, *, role="owner", token_hex=None, ledger_path=None):
|
||||
return OperationalAuthorityService(
|
||||
role=role,
|
||||
policy_path=env["policy_path"],
|
||||
registry_path=env["registry_path"],
|
||||
owner_key_path=env["owner_path"],
|
||||
machine_key_path=env["machine_path"],
|
||||
socket_path=env["policy_path"].parent / (role + ".sock"),
|
||||
ledger_path=ledger_path or env["ledger_path"],
|
||||
allowed_uids=(1000,),
|
||||
clock=lambda: FIXED_NOW,
|
||||
require_root_owner=False,
|
||||
token_hex=token_hex,
|
||||
)
|
||||
|
||||
|
||||
def _lineage_request(**updates):
|
||||
payload = {
|
||||
"source_expected_revision": 12,
|
||||
"source_task_identity_sha256": SOURCE_IDENTITY,
|
||||
"source_metadata_sha256": METADATA_DIGEST,
|
||||
}
|
||||
payload.update(updates)
|
||||
return {"operation": TASK_LINEAGE_SUPERSESSION_OPERATION, "payload": payload}
|
||||
|
||||
|
||||
def test_fixed_operation_issues_once_and_persists_rejection_across_restart(service_env):
|
||||
tokens = iter(("c" * 32, "d" * 32))
|
||||
first = _service(service_env, token_hex=lambda _size: next(tokens))
|
||||
result = first.handle(_lineage_request(), peer_uid=1000)
|
||||
artifact = result["artifact"]
|
||||
payload = OperationalVerifier(
|
||||
service_env["registry_path"],
|
||||
require_root_owner=False,
|
||||
).owner(artifact)
|
||||
verify_task_lineage_supersession_scope(payload, service_env["policy"], now=FIXED_NOW)
|
||||
assert payload["schema"] == TASK_LINEAGE_SUPERSESSION_SCHEMA
|
||||
restarted = _service(service_env)
|
||||
with pytest.raises(OperationalServiceError, match="TASK_LINEAGE_SUPERSESSION_ALREADY_ISSUED"):
|
||||
restarted.handle(_lineage_request(), peer_uid=1000)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"override",
|
||||
[
|
||||
{"payload": {}},
|
||||
{"message": "sign this"},
|
||||
{"bytes": "00"},
|
||||
{"schema": "aios.other.v1"},
|
||||
{"source_task_id": "TASK-other"},
|
||||
{"child_task_id": SOURCE_TASK_ID + "-S2"},
|
||||
{"supersession_ordinal": 2},
|
||||
{"repository": "other/repo"},
|
||||
{"target_branch": "agent/other"},
|
||||
{"generation": 2},
|
||||
{"source_business_execution_digest": "0" * 64},
|
||||
{"external_job_identity": "job-00000000000000000000000000000000"},
|
||||
{"owner_namespace": "other-owner"},
|
||||
{"one_time_use": False},
|
||||
{"decision_id": "caller-id"},
|
||||
{"nonce": "caller-nonce"},
|
||||
{"issued_at": FIXED_NOW},
|
||||
{"not_before": FIXED_NOW},
|
||||
{"expires_at": FIXED_NOW + 60},
|
||||
],
|
||||
)
|
||||
def test_all_identity_and_generic_signing_overrides_are_rejected(service_env, override):
|
||||
service = _service(service_env)
|
||||
request = _lineage_request(**override)
|
||||
with pytest.raises(OperationalServiceError, match="REQUEST_FIELDS_INVALID"):
|
||||
service.handle(request, peer_uid=1000)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", ["sign", "sign_bytes", "sign_payload", "issue_signature"])
|
||||
def test_generic_signing_operations_remain_forbidden(service_env, operation):
|
||||
service = _service(service_env)
|
||||
with pytest.raises(OperationalServiceError, match="OWNER_OPERATION_FORBIDDEN"):
|
||||
service.handle({"operation": operation, "payload": {}}, peer_uid=1000)
|
||||
|
||||
|
||||
def test_old_server_rejects_lineage_operation(service_env):
|
||||
old = OldOperationalAuthorityService(
|
||||
role="owner",
|
||||
policy_path=service_env["policy_path"],
|
||||
registry_path=service_env["registry_path"],
|
||||
owner_key_path=service_env["owner_path"],
|
||||
machine_key_path=service_env["machine_path"],
|
||||
socket_path=service_env["policy_path"].parent / "old.sock",
|
||||
ledger_path=service_env["policy_path"].parent / "old-ledger.sqlite3",
|
||||
allowed_uids=(1000,),
|
||||
clock=lambda: FIXED_NOW,
|
||||
require_root_owner=False,
|
||||
)
|
||||
with pytest.raises(OperationalServiceError, match="OWNER_OPERATION_FORBIDDEN"):
|
||||
old.handle(_lineage_request(), peer_uid=1000)
|
||||
|
||||
|
||||
def test_existing_delegation_and_recovery_operations_are_unchanged(service_env):
|
||||
service = _service(service_env)
|
||||
delegation_request = {
|
||||
"operation": "issue_operational_delegation",
|
||||
"payload": {
|
||||
"delegation_id": "opdel-lineage-regression",
|
||||
"nonce": "nonce-lineage-regression-delegation",
|
||||
"ttl_seconds": 3600,
|
||||
},
|
||||
}
|
||||
delegation = service.handle(delegation_request, peer_uid=1000)["artifact"]
|
||||
recovery_request = {
|
||||
"operation": "issue_expired_task_recovery",
|
||||
"payload": {
|
||||
"delegation": delegation,
|
||||
"decision_id": "recovery-lineage-regression",
|
||||
"nonce": "nonce-lineage-regression-recovery",
|
||||
"expected_revision": 12,
|
||||
"expected_attempt": 0,
|
||||
"original_deadline": FIXED_NOW - 100,
|
||||
"new_deadline": FIXED_NOW + 3600,
|
||||
"ttl_seconds": 120,
|
||||
},
|
||||
}
|
||||
recovery = service.handle(recovery_request, peer_uid=1000)["artifact"]
|
||||
verifier = OperationalVerifier(service_env["registry_path"], require_root_owner=False)
|
||||
assert verifier.owner(delegation)["schema"] == "aios.owner-operational-delegation.v1"
|
||||
assert verifier.owner(recovery)["schema"] == "aios.expired-task-recovery-decision.v1"
|
||||
|
||||
|
||||
def test_lineage_operation_is_owner_only(service_env):
|
||||
service = _service(
|
||||
service_env,
|
||||
role="machine",
|
||||
ledger_path=service_env["policy_path"].parent / "machine-ledger.sqlite3",
|
||||
)
|
||||
with pytest.raises(OperationalServiceError, match="MACHINE_OPERATION_FORBIDDEN"):
|
||||
service.handle(_lineage_request(), peer_uid=1000)
|
||||
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
import aios_task_claim_guard_binding_v2 as guard
|
||||
from aios_online_agent_execution import GovernedExecutionBinding, project_execution_envelope
|
||||
from aios_task_lineage_supersession_initial_projection import (
|
||||
SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
SupersessionChildInitialProjectionStore,
|
||||
)
|
||||
from test_task_lineage_supersession import create_db, controller, decision
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def projection_authorization(monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"AIOS_AGENT_TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZATION_ID",
|
||||
"owner-supersession-child-initial-projection-20260905",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Policy:
|
||||
task_id: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
generation: int
|
||||
execution_plan_sha256: str
|
||||
external_job_identity: str
|
||||
allowed_worker_ids: tuple[str, ...]
|
||||
base_oid: str
|
||||
digest: str
|
||||
def validate(self): return None
|
||||
|
||||
|
||||
def apply_projection_migration(path):
|
||||
sql = (
|
||||
ROOT / "active-runtime" / "migrations"
|
||||
/ "006_task_lineage_supersession_initial_projection.sql"
|
||||
).read_text()
|
||||
connection = sqlite3.connect(path)
|
||||
connection.executescript(sql)
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
|
||||
def resolver(task_id, job_id, capability, adapter, runtime_input):
|
||||
binding = GovernedExecutionBinding.from_mapping({
|
||||
"task_id": task_id, "job_id": job_id, "repository": guard.REPOSITORY,
|
||||
"base_oid": "a" * 40, "expected_branch_oid": "a" * 40,
|
||||
"target_branch": guard.TARGET_BRANCH, "branch_mode": "CREATE_NEW",
|
||||
"generation": guard.GENERATION,
|
||||
"idempotency_key": "singlehost-lineage-s1-guard-test-v1",
|
||||
})
|
||||
return project_execution_envelope(binding, {
|
||||
"action": "agent_runtime", "adapter": adapter,
|
||||
"capability_scope": [capability], "runtime_input": runtime_input,
|
||||
})
|
||||
|
||||
|
||||
def projected(path):
|
||||
apply_projection_migration(path)
|
||||
result = SupersessionChildInitialProjectionStore(path, resolver).project({
|
||||
"adapter": "openclaw",
|
||||
"required_capability": "agent.runtime.openclaw",
|
||||
"authorization_id": "owner-supersession-child-initial-projection-20260905",
|
||||
})
|
||||
connection = sqlite3.connect(path)
|
||||
return connection, result
|
||||
|
||||
|
||||
def policy_value(connection, **changes):
|
||||
canonical_digest = connection.execute(
|
||||
"SELECT canonical_child_execution_digest FROM "
|
||||
"task_lineage_supersession_initial_projections WHERE task_id=?",
|
||||
(guard.CHILD_TASK_ID,),
|
||||
).fetchone()[0]
|
||||
value = {
|
||||
"task_id": guard.CHILD_TASK_ID, "repository": guard.REPOSITORY,
|
||||
"target_branch": guard.TARGET_BRANCH, "generation": guard.GENERATION,
|
||||
"execution_plan_sha256": canonical_digest,
|
||||
"external_job_identity": guard.EXTERNAL_JOB_ID,
|
||||
"allowed_worker_ids": [guard.WORKER_ID], "base_oid": "a" * 40,
|
||||
}
|
||||
value.update(changes)
|
||||
return value
|
||||
|
||||
|
||||
def factory(value):
|
||||
digest = hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
return Policy(
|
||||
task_id=value["task_id"], repository=value["repository"],
|
||||
target_branch=value["target_branch"], generation=value["generation"],
|
||||
execution_plan_sha256=value["execution_plan_sha256"],
|
||||
external_job_identity=value["external_job_identity"],
|
||||
allowed_worker_ids=tuple(value["allowed_worker_ids"]), base_oid=value["base_oid"], digest=digest,
|
||||
)
|
||||
|
||||
|
||||
def install(connection, **changes):
|
||||
return guard.install_child_binding(
|
||||
connection, policy=policy_value(connection, **changes), policy_factory=factory,
|
||||
runtime_id="runtime-v15", runtime_observation_digest="b" * 64,
|
||||
task_contract_version="aios.agent-task.v1", now="2026-09-05T04:02:00Z",
|
||||
)
|
||||
|
||||
|
||||
def test_binding_requires_canonical_projection(tmp_path):
|
||||
path = create_db(tmp_path)
|
||||
controller(path).supersede(decision(path))
|
||||
apply_projection_migration(path)
|
||||
connection = sqlite3.connect(path)
|
||||
connection.row_factory = sqlite3.Row
|
||||
with pytest.raises(
|
||||
guard.ClaimGuardBindingError,
|
||||
match="FORMAL_CHILD_INITIAL_PROJECTION_REQUIRED",
|
||||
):
|
||||
guard.install_child_binding(
|
||||
connection, policy={}, runtime_id="runtime-v15",
|
||||
runtime_observation_digest="b" * 64,
|
||||
task_contract_version="aios.agent-task.v1", policy_factory=factory,
|
||||
)
|
||||
assert connection.execute("SELECT count(*) FROM task_claim_guard_bindings_v2").fetchone()[0] == 0
|
||||
connection.close()
|
||||
|
||||
|
||||
def test_binding_is_server_derived_append_only_and_exact(tmp_path, monkeypatch):
|
||||
path = create_db(tmp_path)
|
||||
supersession = controller(path).supersede(decision(path))
|
||||
connection, projection = projected(path)
|
||||
connection.row_factory = sqlite3.Row
|
||||
result = install(connection)
|
||||
connection.commit()
|
||||
assert result["status"] == "BOUND"
|
||||
assert result["worker_id"] == guard.WORKER_ID
|
||||
assert result["source_business_execution_digest"] == SOURCE_BUSINESS_EXECUTION_DIGEST
|
||||
assert result["canonical_child_execution_digest"] == projection["canonical_child_execution_digest"]
|
||||
assert result["canonical_child_execution_digest"] != result["source_business_execution_digest"]
|
||||
assert result["owner_supersession_decision_sha256"] == supersession["owner_decision_sha256"]
|
||||
assert install(connection)["idempotent"] is True
|
||||
with pytest.raises(sqlite3.IntegrityError, match="APPEND_ONLY"):
|
||||
connection.execute("UPDATE task_claim_guard_bindings_v2 SET worker_id='other'")
|
||||
connection.rollback()
|
||||
ran = []
|
||||
monkeypatch.setattr(guard, "_run_child_guard", lambda conn, binding, policy, agent: ran.append((binding, agent)))
|
||||
guard.enforce_claim_guard(connection, task_id=guard.CHILD_TASK_ID, agent_id=guard.WORKER_ID)
|
||||
assert ran[0][0]["expected_revision"] == 2
|
||||
with pytest.raises(guard.ClaimGuardBindingError, match="WORKER_ID_MISMATCH"):
|
||||
guard.enforce_claim_guard(connection, task_id=guard.CHILD_TASK_ID, agent_id="other")
|
||||
connection.close()
|
||||
|
||||
|
||||
def test_binding_rejects_policy_widening_and_projection_drift(tmp_path):
|
||||
path = create_db(tmp_path)
|
||||
controller(path).supersede(decision(path))
|
||||
connection, projection = projected(path)
|
||||
connection.row_factory = sqlite3.Row
|
||||
with pytest.raises(guard.ClaimGuardBindingError, match="POLICY_WORKER_MISMATCH"):
|
||||
install(connection, allowed_worker_ids=[guard.WORKER_ID, "other"])
|
||||
assert connection.execute("SELECT count(*) FROM task_claim_guard_bindings_v2").fetchone()[0] == 0
|
||||
with pytest.raises(
|
||||
guard.ClaimGuardBindingError, match="OPERATIONAL_POLICY_SCOPE_MISMATCH"
|
||||
):
|
||||
install(connection, execution_plan_sha256=SOURCE_BUSINESS_EXECUTION_DIGEST)
|
||||
assert projection["canonical_child_execution_digest"] != SOURCE_BUSINESS_EXECUTION_DIGEST
|
||||
install(connection)
|
||||
connection.commit()
|
||||
connection.execute("UPDATE tasks SET revision=3 WHERE task_id=?", (guard.CHILD_TASK_ID,))
|
||||
connection.commit()
|
||||
with pytest.raises(guard.ClaimGuardBindingError, match="TASK_CAS_MISMATCH"):
|
||||
guard.enforce_claim_guard(connection, task_id=guard.CHILD_TASK_ID, agent_id=guard.WORKER_ID)
|
||||
connection.close()
|
||||
|
||||
|
||||
def test_exact_two_task_dispatch(tmp_path, monkeypatch):
|
||||
calls = []
|
||||
fake = types.SimpleNamespace(enforce_claim_guard=lambda *a, **kw: calls.append(kw))
|
||||
monkeypatch.setitem(sys.modules, "aios_singlehost_claim_guard_stage1", fake)
|
||||
guard.enforce_claim_guard(object(), task_id=guard.SOURCE_TASK_ID, agent_id="legacy")
|
||||
guard.enforce_claim_guard(object(), task_id="TASK-unrelated", agent_id="any")
|
||||
assert calls == [{"task_id": guard.SOURCE_TASK_ID, "agent_id": "legacy"}]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user