Compare commits
24
Commits
@@ -13,6 +13,7 @@ from hashlib import sha256
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from message_handoff import stable_event_id
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> str:
|
def utc_now() -> str:
|
||||||
@@ -55,13 +56,19 @@ class AgentEventBus:
|
|||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
correlation_id: str | None = None,
|
correlation_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
event_id = "evt_" + sha256(
|
event_id = stable_event_id(
|
||||||
json.dumps(
|
source=source, target=target, message_type=kind, payload=payload,
|
||||||
{"kind": kind, "source": source, "summary": summary, "payload": payload},
|
tenant_id=(payload.get('tenant_id') if isinstance(payload, dict) else None),
|
||||||
sort_keys=True,
|
user_id=(payload.get('user_id') if isinstance(payload, dict) else None),
|
||||||
ensure_ascii=False,
|
project_id=(payload.get('project_id') if isinstance(payload, dict) else None),
|
||||||
).encode("utf-8")
|
conversation_id=(payload.get('conversation_id') if isinstance(payload, dict) else None),
|
||||||
).hexdigest()[:16]
|
task_id=(payload.get('task_id') if isinstance(payload, dict) else None),
|
||||||
|
run_id=(payload.get('run_id') if isinstance(payload, dict) else None),
|
||||||
|
correlation_id=(payload.get('correlation_id') if isinstance(payload, dict) else None),
|
||||||
|
causation_id=(payload.get('causation_id') if isinstance(payload, dict) else None),
|
||||||
|
sequence=(payload.get('sequence') if isinstance(payload, dict) else None),
|
||||||
|
idempotency_key=(payload.get('idempotency_key') if isinstance(payload, dict) else None),
|
||||||
|
)
|
||||||
event = AgentEvent(event_id, kind, source, target, summary, payload, correlation_id=correlation_id)
|
event = AgentEvent(event_id, kind, source, target, summary, payload, correlation_id=correlation_id)
|
||||||
self._write("pending", event.to_dict())
|
self._write("pending", event.to_dict())
|
||||||
self._audit("created", event.to_dict())
|
self._audit("created", event.to_dict())
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ class AgentEventObservabilityBridge:
|
|||||||
def __init__(self, hooks: AgentObservabilityHooks | None = None):
|
def __init__(self, hooks: AgentObservabilityHooks | None = None):
|
||||||
self.hooks = hooks or AgentObservabilityHooks()
|
self.hooks = hooks or AgentObservabilityHooks()
|
||||||
|
|
||||||
def consume(self, event: dict):
|
def consume(self, event: dict, *, actor=None, trace=None):
|
||||||
kind = event.get("kind")
|
kind = event.get("kind")
|
||||||
payload = event.get("payload", {})
|
payload = event.get("payload", {})
|
||||||
if kind == "agent_started":
|
if kind == "agent_started":
|
||||||
@@ -16,6 +16,8 @@ class AgentEventObservabilityBridge:
|
|||||||
visible_scope=payload.get("scope", "self"),
|
visible_scope=payload.get("scope", "self"),
|
||||||
entry_agent=payload.get("agent", "unknown"),
|
entry_agent=payload.get("agent", "unknown"),
|
||||||
execution_id=event.get("id"),
|
execution_id=event.get("id"),
|
||||||
|
actor=actor,
|
||||||
|
trace=trace,
|
||||||
)
|
)
|
||||||
if kind == "agent_delegated":
|
if kind == "agent_delegated":
|
||||||
return self.hooks.delegate(event["execution_id"], payload.get("agent", "unknown"))
|
return self.hooks.delegate(event["execution_id"], payload.get("agent", "unknown"))
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ class AgentExecutionRecord:
|
|||||||
status: str = "completed"
|
status: str = "completed"
|
||||||
started_at: str = field(default_factory=utc_now)
|
started_at: str = field(default_factory=utc_now)
|
||||||
ended_at: str = field(default_factory=utc_now)
|
ended_at: str = field(default_factory=utc_now)
|
||||||
|
trace_id: str | None = None
|
||||||
|
span_id: str | None = None
|
||||||
|
parent_span_id: str | None = None
|
||||||
|
correlation_id: str | None = None
|
||||||
|
causation_id: str | None = None
|
||||||
|
tenant_id: str | None = None
|
||||||
|
project_id: str | None = None
|
||||||
|
conversation_id: str | None = None
|
||||||
|
task_id: str | None = None
|
||||||
|
claim_id: str | None = None
|
||||||
|
run_id: str | None = None
|
||||||
|
message_id: str | None = None
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return asdict(self)
|
return asdict(self)
|
||||||
|
|||||||
@@ -9,25 +9,49 @@ try:
|
|||||||
except Exception:
|
except Exception:
|
||||||
APIRouter = None
|
APIRouter = None
|
||||||
|
|
||||||
from .agent_observability import AgentExecutionRecord
|
from .agent_observability import AgentExecutionRecord, AgentObservability
|
||||||
from .agent_observability_dashboard import AgentObservabilityDashboard
|
from .agent_observability_dashboard import AgentObservabilityDashboard
|
||||||
from .agent_observability_store import AgentObservabilityStore
|
from .agent_observability_store import AgentObservabilityStore
|
||||||
|
from .trace_tool_credential_governance import GovernanceActor, TraceContext, authorize_scope
|
||||||
|
|
||||||
store = AgentObservabilityStore()
|
store = AgentObservabilityStore()
|
||||||
dashboard = AgentObservabilityDashboard(store.load())
|
dashboard = AgentObservabilityDashboard(store.load())
|
||||||
|
|
||||||
|
|
||||||
def record_agent_execution(payload: dict[str, Any]):
|
def record_agent_execution(payload: dict[str, Any], *, actor: GovernanceActor | dict[str, Any] | None = None, trace: TraceContext | None = None):
|
||||||
record = AgentExecutionRecord(**payload)
|
scoped = any(payload.get(name) is not None for name in (
|
||||||
|
"trace_id", "span_id", "tenant_id", "project_id", "conversation_id", "task_id", "run_id"
|
||||||
|
))
|
||||||
|
values = dict(payload)
|
||||||
|
if scoped:
|
||||||
|
if actor is None or trace is None:
|
||||||
|
raise RuntimeError("TRACE_ACTOR_CONTEXT_REQUIRED")
|
||||||
|
trusted = GovernanceActor.from_value(actor)
|
||||||
|
authorize_scope(trusted, trace.scope)
|
||||||
|
bound = {
|
||||||
|
"trace_id": trace.trace_id, "span_id": trace.span_id,
|
||||||
|
"parent_span_id": trace.parent_span_id, "correlation_id": trace.correlation_id,
|
||||||
|
"causation_id": trace.causation_id, "tenant_id": trace.scope.tenant_id,
|
||||||
|
"user_id": trace.scope.user_id, "project_id": trace.scope.project_id,
|
||||||
|
"conversation_id": trace.scope.conversation_id, "task_id": trace.task_id,
|
||||||
|
"claim_id": trace.claim_id, "run_id": trace.scope.run_id, "message_id": trace.message_id,
|
||||||
|
}
|
||||||
|
for name, authoritative in bound.items():
|
||||||
|
if values.get(name) not in (None, authoritative):
|
||||||
|
raise RuntimeError("TRACE_SCOPE_MISMATCH_DENY:" + name)
|
||||||
|
values[name] = authoritative
|
||||||
|
record = AgentExecutionRecord(**values)
|
||||||
return {"status": "ok", "record": store.append(record)}
|
return {"status": "ok", "record": store.append(record)}
|
||||||
|
|
||||||
|
|
||||||
def get_agent_observability(user_id: str | None = None, group_id: str | None = None, role: str = "user"):
|
def get_agent_observability(user_id: str | None = None, group_id: str | None = None, role: str = "user", *, actor: GovernanceActor | dict[str, Any] | None = None):
|
||||||
records = store.query(user_id, group_id, role)
|
records = store.query(user_id, group_id, role, actor=actor)
|
||||||
|
visible = AgentObservability()
|
||||||
|
visible.records = [AgentExecutionRecord(**record) for record in records]
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"records": records,
|
"records": records,
|
||||||
"summary": store.load().summary(user_id if role != "owner" else None),
|
"summary": visible.summary(None),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from .agent_observability import AgentExecutionRecord
|
from .agent_observability import AgentExecutionRecord
|
||||||
from .agent_observability_store import AgentObservabilityStore
|
from .agent_observability_store import AgentObservabilityStore
|
||||||
|
from .trace_tool_credential_governance import (
|
||||||
|
GovernanceActor,
|
||||||
|
TraceContext,
|
||||||
|
authorize_scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> str:
|
def utc_now() -> str:
|
||||||
@@ -36,6 +41,7 @@ class AgentRunContext:
|
|||||||
input_tokens: int = 0
|
input_tokens: int = 0
|
||||||
output_tokens: int = 0
|
output_tokens: int = 0
|
||||||
status: str = "running"
|
status: str = "running"
|
||||||
|
trace: TraceContext | None = None
|
||||||
|
|
||||||
def delegate(self, agent_name: str) -> None:
|
def delegate(self, agent_name: str) -> None:
|
||||||
if agent_name not in self.path:
|
if agent_name not in self.path:
|
||||||
@@ -59,7 +65,19 @@ class AgentObservabilityHooks:
|
|||||||
visible_scope: str,
|
visible_scope: str,
|
||||||
entry_agent: str,
|
entry_agent: str,
|
||||||
execution_id: str | None = None,
|
execution_id: str | None = None,
|
||||||
|
*,
|
||||||
|
actor: GovernanceActor | dict[str, Any] | None = None,
|
||||||
|
trace: TraceContext | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
if execution_id is not None and execution_id in self.active:
|
||||||
|
raise RuntimeError("OBSERVABILITY_EXECUTION_CONFLICT")
|
||||||
|
if trace is not None:
|
||||||
|
if actor is None:
|
||||||
|
raise RuntimeError("TRACE_ACTOR_CONTEXT_REQUIRED")
|
||||||
|
trusted_actor = GovernanceActor.from_value(actor)
|
||||||
|
authorize_scope(trusted_actor, trace.scope)
|
||||||
|
if trace.scope.user_id is not None and trace.scope.user_id != user_id:
|
||||||
|
raise RuntimeError("TRACE_SCOPE_MISMATCH_DENY:user_id")
|
||||||
run = AgentRunContext(
|
run = AgentRunContext(
|
||||||
execution_id=execution_id or f"exec_{uuid4().hex[:12]}",
|
execution_id=execution_id or f"exec_{uuid4().hex[:12]}",
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -67,6 +85,7 @@ class AgentObservabilityHooks:
|
|||||||
entry_agent=entry_agent,
|
entry_agent=entry_agent,
|
||||||
path=[entry_agent],
|
path=[entry_agent],
|
||||||
used_hermes=entry_agent.lower() == "hermes",
|
used_hermes=entry_agent.lower() == "hermes",
|
||||||
|
trace=trace,
|
||||||
)
|
)
|
||||||
self.active[run.execution_id] = run
|
self.active[run.execution_id] = run
|
||||||
return {"status": "started", "execution_id": run.execution_id, "run": run.__dict__}
|
return {"status": "started", "execution_id": run.execution_id, "run": run.__dict__}
|
||||||
@@ -106,6 +125,18 @@ class AgentObservabilityHooks:
|
|||||||
status=status,
|
status=status,
|
||||||
started_at=run.started_at,
|
started_at=run.started_at,
|
||||||
ended_at=ended_at,
|
ended_at=ended_at,
|
||||||
|
trace_id=run.trace.trace_id if run.trace else None,
|
||||||
|
span_id=run.trace.span_id if run.trace else None,
|
||||||
|
parent_span_id=run.trace.parent_span_id if run.trace else None,
|
||||||
|
correlation_id=run.trace.correlation_id if run.trace else None,
|
||||||
|
causation_id=run.trace.causation_id if run.trace else None,
|
||||||
|
tenant_id=run.trace.scope.tenant_id if run.trace else None,
|
||||||
|
project_id=run.trace.scope.project_id if run.trace else None,
|
||||||
|
conversation_id=run.trace.scope.conversation_id if run.trace else None,
|
||||||
|
task_id=run.trace.task_id if run.trace else None,
|
||||||
|
claim_id=run.trace.claim_id if run.trace else None,
|
||||||
|
run_id=run.trace.scope.run_id if run.trace else None,
|
||||||
|
message_id=run.trace.message_id if run.trace else None,
|
||||||
)
|
)
|
||||||
return {"status": "stored", "record": self.store.append(record)}
|
return {"status": "stored", "record": self.store.append(record)}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .agent_observability import AgentExecutionRecord, AgentObservability
|
from .agent_observability import AgentExecutionRecord, AgentObservability
|
||||||
|
from .trace_tool_credential_governance import (
|
||||||
|
GovernanceActor,
|
||||||
|
GovernanceError,
|
||||||
|
GovernanceScope,
|
||||||
|
authorize_scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AgentObservabilityStore:
|
class AgentObservabilityStore:
|
||||||
@@ -23,6 +29,11 @@ class AgentObservabilityStore:
|
|||||||
def append(self, record: AgentExecutionRecord) -> dict[str, Any]:
|
def append(self, record: AgentExecutionRecord) -> dict[str, Any]:
|
||||||
path = self.records_dir / f"{record.execution_id}.json"
|
path = self.records_dir / f"{record.execution_id}.json"
|
||||||
data = record.to_dict()
|
data = record.to_dict()
|
||||||
|
if path.exists():
|
||||||
|
existing = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if existing == data:
|
||||||
|
return existing
|
||||||
|
raise RuntimeError("OBSERVABILITY_EXECUTION_CONFLICT")
|
||||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -33,8 +44,36 @@ class AgentObservabilityStore:
|
|||||||
observability.records.append(AgentExecutionRecord(**raw))
|
observability.records.append(AgentExecutionRecord(**raw))
|
||||||
return observability
|
return observability
|
||||||
|
|
||||||
def query(self, user_id: str | None = None, group_id: str | None = None, role: str = "user") -> list[dict[str, Any]]:
|
def query(self, user_id: str | None = None, group_id: str | None = None, role: str = "user", *, actor: GovernanceActor | dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
||||||
records = [record.to_dict() for record in self.load().records]
|
records = [record.to_dict() for record in self.load().records]
|
||||||
|
scoped = [record for record in records if record.get("tenant_id") is not None]
|
||||||
|
legacy = [record for record in records if record.get("tenant_id") is None]
|
||||||
|
if actor is not None:
|
||||||
|
trusted = GovernanceActor.from_value(actor)
|
||||||
|
visible: list[dict[str, Any]] = []
|
||||||
|
for record in scoped:
|
||||||
|
try:
|
||||||
|
authorize_scope(
|
||||||
|
trusted,
|
||||||
|
GovernanceScope(
|
||||||
|
"tenant",
|
||||||
|
tenant_id=record.get("tenant_id"),
|
||||||
|
user_id=record.get("user_id"),
|
||||||
|
project_id=record.get("project_id"),
|
||||||
|
conversation_id=record.get("conversation_id"),
|
||||||
|
run_id=record.get("run_id"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except GovernanceError:
|
||||||
|
continue
|
||||||
|
visible.append(record)
|
||||||
|
if trusted.privileged:
|
||||||
|
visible.extend(legacy)
|
||||||
|
else:
|
||||||
|
visible.extend(record for record in legacy if record.get("user_id") == trusted.scope.user_id)
|
||||||
|
return visible
|
||||||
|
# Caller-supplied role remains a legacy compatibility path only.
|
||||||
|
records = legacy
|
||||||
if role == "owner":
|
if role == "owner":
|
||||||
return records
|
return records
|
||||||
if role == "group_admin" and group_id:
|
if role == "group_admin" and group_id:
|
||||||
|
|||||||
@@ -0,0 +1,722 @@
|
|||||||
|
"""Owner-controlled CAS-bound execution-envelope rebind for Online Agent Tasks.
|
||||||
|
|
||||||
|
This service exists for a narrow recovery case: a Task was projected/claimed with a read-only
|
||||||
|
source_capture envelope before its already-registered deterministic plan became authoritative.
|
||||||
|
|
||||||
|
It never creates Tasks/Jobs/branches and never accepts arbitrary metadata patches. It only replaces
|
||||||
|
metadata.execution + the derived execution_projection for the same governed binding, after an
|
||||||
|
official NEEDS_REWORK transition has returned the Task to QUEUED with no active lease.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from typing import Any, Callable, Mapping, Sequence
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
from aios_online_agent_execution import (
|
||||||
|
GovernedExecutionBinding,
|
||||||
|
validate_execution_envelope,
|
||||||
|
)
|
||||||
|
|
||||||
|
TASK_RE = re.compile(r"^[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}$")
|
||||||
|
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||||
|
MAX_BODY = 64 * 1024
|
||||||
|
AUTHORIZATION_ID_ENV = "AIOS_AGENT_TASK_EXECUTION_REBIND_AUTHORIZATION_ID"
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionRebindError(RuntimeError):
|
||||||
|
def __init__(self, code: str, status: int = 409) -> None:
|
||||||
|
super().__init__(code)
|
||||||
|
self.code = code
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical(value: object) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _sha(value: object) -> str:
|
||||||
|
return hashlib.sha256(_canonical(value)).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_string(
|
||||||
|
value: object,
|
||||||
|
code: str,
|
||||||
|
pattern: re.Pattern[str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
raise ExecutionRebindError(code, 400)
|
||||||
|
value = value.strip()
|
||||||
|
if pattern is not None and pattern.fullmatch(value) is None:
|
||||||
|
raise ExecutionRebindError(code, 400)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _required_authorization_id() -> str:
|
||||||
|
value = os.environ.get(AUTHORIZATION_ID_ENV, "").strip()
|
||||||
|
if not value:
|
||||||
|
raise RuntimeError("TASK_EXECUTION_REBIND_AUTHORIZATION_ID_MISSING")
|
||||||
|
if KEY_RE.fullmatch(value) is None:
|
||||||
|
raise RuntimeError("TASK_EXECUTION_REBIND_AUTHORIZATION_ID_INVALID")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExecutionRebindRequest:
|
||||||
|
expected_status: str
|
||||||
|
expected_revision: int
|
||||||
|
expected_attempt: int
|
||||||
|
previous_verification_sha256: str
|
||||||
|
expected_old_execution_digest: str
|
||||||
|
expected_new_execution_digest: str
|
||||||
|
authorization_id: str
|
||||||
|
idempotency_key: str
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mapping(cls, value: Mapping[str, Any]) -> "ExecutionRebindRequest":
|
||||||
|
allowed = {
|
||||||
|
"expected_status",
|
||||||
|
"expected_revision",
|
||||||
|
"expected_attempt",
|
||||||
|
"previous_verification_sha256",
|
||||||
|
"expected_old_execution_digest",
|
||||||
|
"expected_new_execution_digest",
|
||||||
|
"authorization_id",
|
||||||
|
"idempotency_key",
|
||||||
|
"reason",
|
||||||
|
}
|
||||||
|
if set(value) != allowed:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_FIELDS_INVALID", 400)
|
||||||
|
|
||||||
|
status = _require_string(value["expected_status"], "EXPECTED_STATUS_INVALID")
|
||||||
|
if status != "QUEUED":
|
||||||
|
raise ExecutionRebindError("EXPECTED_STATUS_INVALID", 400)
|
||||||
|
|
||||||
|
revision = value["expected_revision"]
|
||||||
|
attempt = value["expected_attempt"]
|
||||||
|
if (
|
||||||
|
not isinstance(revision, int)
|
||||||
|
or isinstance(revision, bool)
|
||||||
|
or revision < 0
|
||||||
|
or not isinstance(attempt, int)
|
||||||
|
or isinstance(attempt, bool)
|
||||||
|
or attempt < 1
|
||||||
|
):
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_INTEGER_INVALID", 400)
|
||||||
|
|
||||||
|
verification = _require_string(
|
||||||
|
value["previous_verification_sha256"], "VERIFICATION_SHA256_INVALID"
|
||||||
|
)
|
||||||
|
old_digest = _require_string(
|
||||||
|
value["expected_old_execution_digest"], "OLD_EXECUTION_DIGEST_INVALID"
|
||||||
|
)
|
||||||
|
new_digest = _require_string(
|
||||||
|
value["expected_new_execution_digest"], "NEW_EXECUTION_DIGEST_INVALID"
|
||||||
|
)
|
||||||
|
for digest, code in (
|
||||||
|
(verification, "VERIFICATION_SHA256_INVALID"),
|
||||||
|
(old_digest, "OLD_EXECUTION_DIGEST_INVALID"),
|
||||||
|
(new_digest, "NEW_EXECUTION_DIGEST_INVALID"),
|
||||||
|
):
|
||||||
|
if SHA_RE.fullmatch(digest) is None:
|
||||||
|
raise ExecutionRebindError(code, 400)
|
||||||
|
if old_digest == new_digest:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_DIGEST_UNCHANGED", 400)
|
||||||
|
|
||||||
|
authorization = _require_string(
|
||||||
|
value["authorization_id"], "AUTHORIZATION_ID_INVALID", KEY_RE
|
||||||
|
)
|
||||||
|
key = _require_string(value["idempotency_key"], "IDEMPOTENCY_KEY_INVALID", KEY_RE)
|
||||||
|
reason = _require_string(value["reason"], "EXECUTION_REBIND_REASON_INVALID")
|
||||||
|
if len(reason) > 512:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_REASON_INVALID", 400)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
status,
|
||||||
|
revision,
|
||||||
|
attempt,
|
||||||
|
verification,
|
||||||
|
old_digest,
|
||||||
|
new_digest,
|
||||||
|
authorization,
|
||||||
|
key,
|
||||||
|
reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
def public(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"expected_status": self.expected_status,
|
||||||
|
"expected_revision": self.expected_revision,
|
||||||
|
"expected_attempt": self.expected_attempt,
|
||||||
|
"previous_verification_sha256": self.previous_verification_sha256,
|
||||||
|
"expected_old_execution_digest": self.expected_old_execution_digest,
|
||||||
|
"expected_new_execution_digest": self.expected_new_execution_digest,
|
||||||
|
"authorization_id": self.authorization_id,
|
||||||
|
"idempotency_key": self.idempotency_key,
|
||||||
|
"reason": self.reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CanonicalBridgeExecutionResolver:
|
||||||
|
"""Resolve the already-registered plan from the canonical governed Bridge."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, control_token: str) -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.control_token = control_token
|
||||||
|
|
||||||
|
def __call__(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
required_capabilities: Sequence[str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
body = json.dumps(
|
||||||
|
{
|
||||||
|
"task_id": task_id,
|
||||||
|
"required_capabilities": list(required_capabilities),
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
request = urllib.request.Request(
|
||||||
|
self.base_url + "/v2/execution-envelope",
|
||||||
|
method="POST",
|
||||||
|
data=body,
|
||||||
|
headers={
|
||||||
|
"Authorization": "Bearer " + self.control_token,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=15) as response:
|
||||||
|
raw = response.read()
|
||||||
|
status = response.status
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raw = exc.read()
|
||||||
|
status = exc.code
|
||||||
|
try:
|
||||||
|
value = json.loads(raw.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise ExecutionRebindError("CANONICAL_EXECUTION_RESPONSE_INVALID", 502) from exc
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise ExecutionRebindError("CANONICAL_EXECUTION_RESPONSE_INVALID", 502)
|
||||||
|
if status != 200:
|
||||||
|
raise ExecutionRebindError(
|
||||||
|
str(value.get("error") or "CANONICAL_EXECUTION_RESOLUTION_FAILED"),
|
||||||
|
502 if status >= 500 else 409,
|
||||||
|
)
|
||||||
|
return dict(value)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionRebindStore:
|
||||||
|
"""Official owner-controlled mutation with CAS, plan resolution and durable audit."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
db_path: Path,
|
||||||
|
resolver: Callable[[str, Sequence[str]], Mapping[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
self.db_path = Path(db_path)
|
||||||
|
self.resolver = resolver
|
||||||
|
|
||||||
|
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 _json_value(raw: object, code: str) -> dict[str, Any]:
|
||||||
|
if isinstance(raw, Mapping):
|
||||||
|
return dict(raw)
|
||||||
|
if not isinstance(raw, str):
|
||||||
|
raise ExecutionRebindError(code, 409)
|
||||||
|
try:
|
||||||
|
value = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ExecutionRebindError(code, 409) from exc
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise ExecutionRebindError(code, 409)
|
||||||
|
return dict(value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _verification_hash(raw: object) -> str:
|
||||||
|
if raw in (None, ""):
|
||||||
|
value: object = {}
|
||||||
|
elif isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
value = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ExecutionRebindError("TASK_VERIFICATION_INVALID", 409) from exc
|
||||||
|
else:
|
||||||
|
value = raw
|
||||||
|
return _sha(value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _metadata_column(columns: Mapping[str, sqlite3.Row]) -> str:
|
||||||
|
for name in ("metadata_json", "metadata"):
|
||||||
|
if name in columns:
|
||||||
|
return name
|
||||||
|
raise ExecutionRebindError("TASK_METADATA_SCHEMA_UNSUPPORTED", 503)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _required_capabilities(
|
||||||
|
task: sqlite3.Row,
|
||||||
|
columns: Mapping[str, sqlite3.Row],
|
||||||
|
old_execution: Mapping[str, Any],
|
||||||
|
) -> list[str]:
|
||||||
|
for field in ("required_capabilities_json", "required_capabilities"):
|
||||||
|
if field not in columns:
|
||||||
|
continue
|
||||||
|
raw = task[field]
|
||||||
|
if raw in (None, ""):
|
||||||
|
continue
|
||||||
|
if isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
value = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
value = [item for item in raw.split(",") if item.strip()]
|
||||||
|
else:
|
||||||
|
value = raw
|
||||||
|
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
||||||
|
return [item.strip() for item in value if item.strip()]
|
||||||
|
value = old_execution.get("capability_scope")
|
||||||
|
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
||||||
|
return [item for item in value if item]
|
||||||
|
raise ExecutionRebindError("TASK_CAPABILITIES_UNAVAILABLE", 503)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _event_table(
|
||||||
|
connection: sqlite3.Connection,
|
||||||
|
) -> tuple[str, dict[str, sqlite3.Row]]:
|
||||||
|
tables = {
|
||||||
|
row["name"]
|
||||||
|
for row in connection.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for name in ("task_events", "events"):
|
||||||
|
if name not in tables:
|
||||||
|
continue
|
||||||
|
columns = ExecutionRebindStore._columns(connection, name)
|
||||||
|
if "task_id" in columns and ("event_type" in columns or "type" in columns):
|
||||||
|
return name, columns
|
||||||
|
raise ExecutionRebindError("TASK_EVENT_TABLE_UNSUPPORTED", 503)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _insert_event(
|
||||||
|
connection: sqlite3.Connection,
|
||||||
|
task_id: str,
|
||||||
|
payload: Mapping[str, Any],
|
||||||
|
) -> None:
|
||||||
|
table, columns = ExecutionRebindStore._event_table(connection)
|
||||||
|
values: dict[str, Any] = {"task_id": task_id}
|
||||||
|
if "event_type" in columns:
|
||||||
|
values["event_type"] = "TASK_EXECUTION_REBIND_AUTHORIZED"
|
||||||
|
else:
|
||||||
|
values["type"] = "TASK_EXECUTION_REBIND_AUTHORIZED"
|
||||||
|
payload_text = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||||
|
if "payload_json" in columns:
|
||||||
|
values["payload_json"] = payload_text
|
||||||
|
elif "payload" in columns:
|
||||||
|
values["payload"] = payload_text
|
||||||
|
else:
|
||||||
|
raise ExecutionRebindError("TASK_EVENT_PAYLOAD_UNSUPPORTED", 503)
|
||||||
|
if "created_at" in columns:
|
||||||
|
values["created_at"] = _now()
|
||||||
|
if "agent_id" in columns:
|
||||||
|
values["agent_id"] = "owner-control"
|
||||||
|
if "actor_id" in columns:
|
||||||
|
values["actor_id"] = "owner-control"
|
||||||
|
names = list(values)
|
||||||
|
quoted = ",".join('"' + name + '"' for name in names)
|
||||||
|
placeholders = ",".join("?" for _ in names)
|
||||||
|
connection.execute(
|
||||||
|
f'INSERT INTO "{table}" ({quoted}) VALUES ({placeholders})',
|
||||||
|
tuple(values[name] for name in names),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ensure_rebind_table(connection: sqlite3.Connection) -> None:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS task_execution_rebinds (
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
idempotency_key TEXT NOT NULL,
|
||||||
|
request_sha256 TEXT NOT NULL,
|
||||||
|
authorization_id TEXT NOT NULL,
|
||||||
|
previous_verification_sha256 TEXT NOT NULL,
|
||||||
|
old_execution_digest TEXT NOT NULL,
|
||||||
|
new_execution_digest TEXT NOT NULL,
|
||||||
|
expected_revision INTEGER NOT NULL,
|
||||||
|
applied_revision INTEGER NOT NULL,
|
||||||
|
attempt INTEGER NOT NULL,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(task_id,idempotency_key)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_and_validate(
|
||||||
|
self,
|
||||||
|
connection: sqlite3.Connection,
|
||||||
|
task_id: str,
|
||||||
|
request: ExecutionRebindRequest,
|
||||||
|
) -> tuple[sqlite3.Row, dict[str, sqlite3.Row], str, dict[str, Any], dict[str, Any]]:
|
||||||
|
columns = self._columns(connection, "tasks")
|
||||||
|
required = {
|
||||||
|
"task_id",
|
||||||
|
"status",
|
||||||
|
"attempt",
|
||||||
|
"revision",
|
||||||
|
"claimed_by",
|
||||||
|
"lease_token_hash",
|
||||||
|
"lease_expires_at",
|
||||||
|
"verification_json",
|
||||||
|
}
|
||||||
|
if not required.issubset(columns):
|
||||||
|
raise ExecutionRebindError("TASK_SCHEMA_EXECUTION_REBIND_UNSUPPORTED", 503)
|
||||||
|
metadata_column = self._metadata_column(columns)
|
||||||
|
task = connection.execute(
|
||||||
|
"SELECT * FROM tasks WHERE task_id=?",
|
||||||
|
(task_id,),
|
||||||
|
).fetchone()
|
||||||
|
if task is None:
|
||||||
|
raise ExecutionRebindError("TASK_NOT_FOUND", 404)
|
||||||
|
|
||||||
|
if (
|
||||||
|
str(task["status"]) != request.expected_status
|
||||||
|
or int(task["revision"]) != request.expected_revision
|
||||||
|
or int(task["attempt"]) != request.expected_attempt
|
||||||
|
):
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_CAS_MISMATCH", 409)
|
||||||
|
if task["lease_token_hash"] not in (None, "") or task["lease_expires_at"] not in (None, ""):
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_ACTIVE_LEASE_DENY", 409)
|
||||||
|
|
||||||
|
verification_sha = self._verification_hash(task["verification_json"])
|
||||||
|
if verification_sha != request.previous_verification_sha256:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_VERIFICATION_DRIFT", 409)
|
||||||
|
|
||||||
|
metadata = self._json_value(
|
||||||
|
task[metadata_column],
|
||||||
|
"TASK_METADATA_INVALID",
|
||||||
|
)
|
||||||
|
execution = metadata.get("execution")
|
||||||
|
if not isinstance(execution, Mapping):
|
||||||
|
raise ExecutionRebindError("TASK_EXECUTION_METADATA_MISSING", 409)
|
||||||
|
old_execution = validate_execution_envelope(dict(execution))
|
||||||
|
if old_execution["execution_digest"] != request.expected_old_execution_digest:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_OLD_DIGEST_DRIFT", 409)
|
||||||
|
if old_execution.get("action") != "source_capture":
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_SOURCE_CAPTURE_REQUIRED", 409)
|
||||||
|
|
||||||
|
projection = metadata.get("execution_projection")
|
||||||
|
if isinstance(projection, Mapping):
|
||||||
|
projected_digest = str(projection.get("execution_digest") or "")
|
||||||
|
if projected_digest and projected_digest != request.expected_old_execution_digest:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_PROJECTION_DRIFT", 409)
|
||||||
|
return task, columns, metadata_column, metadata, old_execution
|
||||||
|
|
||||||
|
def rebind(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
request: ExecutionRebindRequest,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
task_id = _require_string(task_id, "TASK_ID_INVALID", TASK_RE)
|
||||||
|
required_authorization = _required_authorization_id()
|
||||||
|
if request.authorization_id != required_authorization:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_AUTHORIZATION_ID_MISMATCH", 403)
|
||||||
|
|
||||||
|
request_sha = _sha(request.public())
|
||||||
|
|
||||||
|
# Idempotent replay must win before CAS validation because a successful
|
||||||
|
# rebind necessarily increments the Task revision and changes the persisted envelope.
|
||||||
|
with self._connect() as connection:
|
||||||
|
self._ensure_rebind_table(connection)
|
||||||
|
replay = connection.execute(
|
||||||
|
"SELECT * FROM task_execution_rebinds WHERE task_id=? AND idempotency_key=?",
|
||||||
|
(task_id, request.idempotency_key),
|
||||||
|
).fetchone()
|
||||||
|
if replay is not None:
|
||||||
|
if replay["request_sha256"] != request_sha:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_IDEMPOTENCY_CONFLICT", 409)
|
||||||
|
connection.commit()
|
||||||
|
return {
|
||||||
|
"task_id": task_id,
|
||||||
|
"status": "QUEUED",
|
||||||
|
"attempt": int(replay["attempt"]),
|
||||||
|
"revision": int(replay["applied_revision"]),
|
||||||
|
"old_execution_digest": replay["old_execution_digest"],
|
||||||
|
"new_execution_digest": replay["new_execution_digest"],
|
||||||
|
"authorization_id": replay["authorization_id"],
|
||||||
|
"idempotent": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Read/CAS snapshot first. Canonical Bridge resolution happens outside the DB write lock.
|
||||||
|
with self._connect() as connection:
|
||||||
|
task, columns, _, _, old_execution = self._load_and_validate(
|
||||||
|
connection, task_id, request
|
||||||
|
)
|
||||||
|
capabilities = self._required_capabilities(task, columns, old_execution)
|
||||||
|
|
||||||
|
binding = GovernedExecutionBinding.from_mapping(old_execution)
|
||||||
|
resolved = dict(self.resolver(task_id, capabilities))
|
||||||
|
new_execution = validate_execution_envelope(resolved, binding=binding)
|
||||||
|
if new_execution.get("action") != "deterministic":
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_DETERMINISTIC_PLAN_REQUIRED", 409)
|
||||||
|
if new_execution["execution_digest"] != request.expected_new_execution_digest:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_NEW_DIGEST_DRIFT", 409)
|
||||||
|
if new_execution["execution_digest"] == old_execution["execution_digest"]:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_DIGEST_UNCHANGED", 409)
|
||||||
|
|
||||||
|
# Re-read under BEGIN IMMEDIATE and apply only if the exact snapshot is unchanged.
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
self._ensure_rebind_table(connection)
|
||||||
|
|
||||||
|
replay = connection.execute(
|
||||||
|
"SELECT * FROM task_execution_rebinds WHERE task_id=? AND idempotency_key=?",
|
||||||
|
(task_id, request.idempotency_key),
|
||||||
|
).fetchone()
|
||||||
|
if replay is not None:
|
||||||
|
if replay["request_sha256"] != request_sha:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_IDEMPOTENCY_CONFLICT", 409)
|
||||||
|
connection.commit()
|
||||||
|
return {
|
||||||
|
"task_id": task_id,
|
||||||
|
"status": "QUEUED",
|
||||||
|
"attempt": int(replay["attempt"]),
|
||||||
|
"revision": int(replay["applied_revision"]),
|
||||||
|
"old_execution_digest": replay["old_execution_digest"],
|
||||||
|
"new_execution_digest": replay["new_execution_digest"],
|
||||||
|
"authorization_id": replay["authorization_id"],
|
||||||
|
"idempotent": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
task, columns, metadata_column, metadata, current_old = self._load_and_validate(
|
||||||
|
connection, task_id, request
|
||||||
|
)
|
||||||
|
if current_old["execution_digest"] != old_execution["execution_digest"]:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_OLD_DIGEST_DRIFT", 409)
|
||||||
|
|
||||||
|
new_metadata = dict(metadata)
|
||||||
|
new_metadata["execution"] = new_execution
|
||||||
|
new_metadata["execution_projection"] = {
|
||||||
|
"schema": "aios.online-agent.task-projection.v1",
|
||||||
|
"task_id": binding.task_id,
|
||||||
|
"job_id": binding.job_id,
|
||||||
|
"execution_digest": new_execution["execution_digest"],
|
||||||
|
}
|
||||||
|
applied_revision = request.expected_revision + 1
|
||||||
|
previous_claimed_by = task["claimed_by"]
|
||||||
|
|
||||||
|
cursor = connection.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE tasks
|
||||||
|
SET "{metadata_column}"=?, revision=?, claimed_by=NULL,
|
||||||
|
lease_token_hash=NULL, lease_expires_at=NULL
|
||||||
|
WHERE task_id=? AND status=? AND revision=? AND attempt=?
|
||||||
|
AND claimed_by IS ? AND lease_token_hash IS NULL AND lease_expires_at IS NULL
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
json.dumps(new_metadata, sort_keys=True, separators=(",", ":")),
|
||||||
|
applied_revision,
|
||||||
|
task_id,
|
||||||
|
request.expected_status,
|
||||||
|
request.expected_revision,
|
||||||
|
request.expected_attempt,
|
||||||
|
previous_claimed_by,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_CAS_MISMATCH", 409)
|
||||||
|
|
||||||
|
event_payload = {
|
||||||
|
"schema": "aios.task-execution-rebind.event.v1",
|
||||||
|
"task_id": task_id,
|
||||||
|
"job_id": binding.job_id,
|
||||||
|
"authorization_id": request.authorization_id,
|
||||||
|
"idempotency_key": request.idempotency_key,
|
||||||
|
"reason": request.reason,
|
||||||
|
"previous_verification_sha256": request.previous_verification_sha256,
|
||||||
|
"old_execution_digest": old_execution["execution_digest"],
|
||||||
|
"new_execution_digest": new_execution["execution_digest"],
|
||||||
|
"old_action": old_execution.get("action"),
|
||||||
|
"new_action": new_execution.get("action"),
|
||||||
|
"previous_revision": request.expected_revision,
|
||||||
|
"applied_revision": applied_revision,
|
||||||
|
"attempt": request.expected_attempt,
|
||||||
|
"request_sha256": request_sha,
|
||||||
|
"previous_claimed_by": previous_claimed_by,
|
||||||
|
"stale_claim_normalized": previous_claimed_by not in (None, ""),
|
||||||
|
}
|
||||||
|
self._insert_event(connection, task_id, event_payload)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO task_execution_rebinds(
|
||||||
|
task_id,idempotency_key,request_sha256,authorization_id,
|
||||||
|
previous_verification_sha256,old_execution_digest,new_execution_digest,
|
||||||
|
expected_revision,applied_revision,attempt,reason,created_at
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
task_id,
|
||||||
|
request.idempotency_key,
|
||||||
|
request_sha,
|
||||||
|
request.authorization_id,
|
||||||
|
request.previous_verification_sha256,
|
||||||
|
old_execution["execution_digest"],
|
||||||
|
new_execution["execution_digest"],
|
||||||
|
request.expected_revision,
|
||||||
|
applied_revision,
|
||||||
|
request.expected_attempt,
|
||||||
|
request.reason,
|
||||||
|
_now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
return {
|
||||||
|
"task_id": task_id,
|
||||||
|
"status": "QUEUED",
|
||||||
|
"attempt": request.expected_attempt,
|
||||||
|
"revision": applied_revision,
|
||||||
|
"old_execution_digest": old_execution["execution_digest"],
|
||||||
|
"new_execution_digest": new_execution["execution_digest"],
|
||||||
|
"authorization_id": request.authorization_id,
|
||||||
|
"idempotent": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionRebindHandler(BaseHTTPRequestHandler):
|
||||||
|
store: ExecutionRebindStore | None = None
|
||||||
|
control_token: str = ""
|
||||||
|
|
||||||
|
def log_message(self, *_args: Any) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _json(self, value: Mapping[str, Any], status: int) -> None:
|
||||||
|
data = json.dumps(
|
||||||
|
dict(value), sort_keys=True, separators=(",", ":")
|
||||||
|
).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
def _authorized(self) -> bool:
|
||||||
|
header = self.headers.get("Authorization", "")
|
||||||
|
return (
|
||||||
|
header.startswith("Bearer ")
|
||||||
|
and bool(self.control_token)
|
||||||
|
and hmac.compare_digest(header[7:], self.control_token)
|
||||||
|
)
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
if self.path == "/health":
|
||||||
|
self._json(
|
||||||
|
{"status": "ok", "service": "aios-agent-task-execution-rebind"},
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._json({"error": "NOT_FOUND"}, 404)
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
match = re.fullmatch(r"/v1/tasks/([^/]+)/execution-rebind", self.path)
|
||||||
|
if match is None:
|
||||||
|
self._json({"error": "NOT_FOUND"}, 404)
|
||||||
|
return
|
||||||
|
if not self._authorized():
|
||||||
|
self._json({"error": "UNAUTHORIZED"}, 401)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
except ValueError:
|
||||||
|
self._json({"error": "CONTENT_LENGTH_INVALID"}, 400)
|
||||||
|
return
|
||||||
|
if length < 1 or length > MAX_BODY:
|
||||||
|
self._json({"error": "REQUEST_SIZE_INVALID"}, 413)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
value = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise ValueError
|
||||||
|
request = ExecutionRebindRequest.from_mapping(value)
|
||||||
|
if self.store is None:
|
||||||
|
raise ExecutionRebindError("RUNTIME_UNAVAILABLE", 503)
|
||||||
|
result = self.store.rebind(match.group(1), request)
|
||||||
|
self._json(result, 200 if result["idempotent"] else 201)
|
||||||
|
except ExecutionRebindError as exc:
|
||||||
|
self._json({"error": exc.code}, exc.status)
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
||||||
|
self._json({"error": "REQUEST_INVALID"}, 400)
|
||||||
|
|
||||||
|
|
||||||
|
def load_control_token(path: Path) -> str:
|
||||||
|
if path.is_symlink() or not path.is_file():
|
||||||
|
raise RuntimeError("TASK_EXECUTION_REBIND_CONTROL_CREDENTIAL_UNAVAILABLE")
|
||||||
|
value = path.read_text(encoding="utf-8").strip()
|
||||||
|
if len(value) < 32:
|
||||||
|
raise RuntimeError("TASK_EXECUTION_REBIND_CONTROL_CREDENTIAL_INVALID")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def serve() -> None:
|
||||||
|
db = Path(os.environ.get("AIOS_AGENT_TASK_DB", "/var/lib/aios-agent-task/state.sqlite3"))
|
||||||
|
_required_authorization_id()
|
||||||
|
token_path = Path(
|
||||||
|
os.environ.get(
|
||||||
|
"AIOS_AGENT_TASK_CONTROL_TOKEN_FILE",
|
||||||
|
str(
|
||||||
|
Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials"))
|
||||||
|
/ "agent_task_control_token"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
token = load_control_token(token_path)
|
||||||
|
bridge = os.environ.get(
|
||||||
|
"AIOS_GOVERNED_WRITE_API_URL",
|
||||||
|
"http://127.0.0.1:18892",
|
||||||
|
)
|
||||||
|
host = os.environ.get("AIOS_TASK_EXECUTION_REBIND_HOST", "127.0.0.1")
|
||||||
|
port = int(os.environ.get("AIOS_TASK_EXECUTION_REBIND_PORT", "18942"))
|
||||||
|
ExecutionRebindHandler.store = ExecutionRebindStore(
|
||||||
|
db,
|
||||||
|
CanonicalBridgeExecutionResolver(bridge, token),
|
||||||
|
)
|
||||||
|
ExecutionRebindHandler.control_token = token
|
||||||
|
ThreadingHTTPServer((host, port), ExecutionRebindHandler).serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
serve()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,811 +0,0 @@
|
|||||||
"""Canonical Task projection intent and same-identity recovery primitives.
|
|
||||||
|
|
||||||
This module is intentionally Task-Plane-owned. It does not open a network
|
|
||||||
listener and it does not authenticate callers; existing control-plane services
|
|
||||||
must enforce their normal service-side ``agent-task:control`` boundary before
|
|
||||||
calling it.
|
|
||||||
|
|
||||||
The recovery primitive has one purpose: converge a durable, complete
|
|
||||||
ProjectionIntent to exactly one canonical Task row. It never fabricates task
|
|
||||||
semantics from a Bridge binding and it never generates a replacement task id.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import asdict, dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from enum import Enum
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
import sqlite3
|
|
||||||
from typing import Any, Mapping, Sequence
|
|
||||||
|
|
||||||
|
|
||||||
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
|
||||||
JOB_ID_RE = TASK_ID_RE
|
|
||||||
SHA40_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
||||||
SHA64_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
||||||
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9._/-]{1,220}$")
|
|
||||||
SCHEMA = "aios.canonical-task-projection-intent.v1"
|
|
||||||
RECEIPT_SCHEMA = "aios.canonical-task-projection-receipt.v1"
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectionRecoveryError(RuntimeError):
|
|
||||||
def __init__(self, code: str, status: int = 409) -> None:
|
|
||||||
super().__init__(code)
|
|
||||||
self.code = code
|
|
||||||
self.status = status
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectionState(str, Enum):
|
|
||||||
PENDING = "PENDING"
|
|
||||||
CONVERGED = "CONVERGED"
|
|
||||||
DRIFT = "DRIFT"
|
|
||||||
FAILED = "FAILED"
|
|
||||||
BLOCKED = "BLOCKED"
|
|
||||||
UNKNOWN = "UNKNOWN"
|
|
||||||
BACKFILL_REQUIRED = "BACKFILL_REQUIRED"
|
|
||||||
|
|
||||||
|
|
||||||
def _now() -> str:
|
|
||||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical(value: object) -> bytes:
|
|
||||||
return json.dumps(
|
|
||||||
value,
|
|
||||||
ensure_ascii=False,
|
|
||||||
sort_keys=True,
|
|
||||||
separators=(",", ":"),
|
|
||||||
).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def sha256_json(value: object) -> str:
|
|
||||||
return hashlib.sha256(_canonical(value)).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _string(value: object, code: str, pattern: re.Pattern[str] | None = None) -> str:
|
|
||||||
if not isinstance(value, str) or not value.strip():
|
|
||||||
raise ProjectionRecoveryError(code, 400)
|
|
||||||
result = value.strip()
|
|
||||||
if pattern is not None and pattern.fullmatch(result) is None:
|
|
||||||
raise ProjectionRecoveryError(code, 400)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _integer(value: object, code: str, *, minimum: int = 0) -> int:
|
|
||||||
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
|
||||||
raise ProjectionRecoveryError(code, 400)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _plain_mapping(value: object, code: str) -> dict[str, Any]:
|
|
||||||
if not isinstance(value, Mapping):
|
|
||||||
raise ProjectionRecoveryError(code, 400)
|
|
||||||
return {str(k): v for k, v in value.items()}
|
|
||||||
|
|
||||||
|
|
||||||
def _plain_list(value: object, code: str) -> list[Any]:
|
|
||||||
if not isinstance(value, list):
|
|
||||||
raise ProjectionRecoveryError(code, 400)
|
|
||||||
return list(value)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CanonicalTaskPayload:
|
|
||||||
task_id: str
|
|
||||||
goal: str
|
|
||||||
instructions: str
|
|
||||||
success_criteria: tuple[Mapping[str, Any], ...]
|
|
||||||
constraints: Mapping[str, Any]
|
|
||||||
required_capabilities: tuple[str, ...]
|
|
||||||
metadata: Mapping[str, Any]
|
|
||||||
priority: int = 100
|
|
||||||
max_attempts: int = 20
|
|
||||||
deadline: str | None = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_mapping(cls, value: Mapping[str, Any]) -> "CanonicalTaskPayload":
|
|
||||||
allowed = {
|
|
||||||
"task_id",
|
|
||||||
"goal",
|
|
||||||
"instructions",
|
|
||||||
"success_criteria",
|
|
||||||
"constraints",
|
|
||||||
"required_capabilities",
|
|
||||||
"metadata",
|
|
||||||
"priority",
|
|
||||||
"max_attempts",
|
|
||||||
"deadline",
|
|
||||||
}
|
|
||||||
if set(value) - allowed:
|
|
||||||
raise ProjectionRecoveryError("TASK_PAYLOAD_UNKNOWN_FIELDS", 400)
|
|
||||||
|
|
||||||
task_id = _string(value.get("task_id"), "TASK_ID_INVALID", TASK_ID_RE)
|
|
||||||
goal = _string(value.get("goal"), "TASK_GOAL_INVALID")
|
|
||||||
instructions = _string(value.get("instructions"), "TASK_INSTRUCTIONS_INVALID")
|
|
||||||
if len(goal) > 8192 or len(instructions) > 65536:
|
|
||||||
raise ProjectionRecoveryError("TASK_TEXT_TOO_LARGE", 413)
|
|
||||||
|
|
||||||
criteria_raw = _plain_list(value.get("success_criteria"), "TASK_SUCCESS_CRITERIA_INVALID")
|
|
||||||
criteria: list[Mapping[str, Any]] = []
|
|
||||||
for item in criteria_raw:
|
|
||||||
if not isinstance(item, Mapping):
|
|
||||||
raise ProjectionRecoveryError("TASK_SUCCESS_CRITERIA_INVALID", 400)
|
|
||||||
criteria.append(dict(item))
|
|
||||||
if not criteria:
|
|
||||||
raise ProjectionRecoveryError("TASK_SUCCESS_CRITERIA_REQUIRED", 400)
|
|
||||||
|
|
||||||
constraints = _plain_mapping(value.get("constraints"), "TASK_CONSTRAINTS_INVALID")
|
|
||||||
capabilities_raw = _plain_list(
|
|
||||||
value.get("required_capabilities", []),
|
|
||||||
"TASK_CAPABILITIES_INVALID",
|
|
||||||
)
|
|
||||||
capabilities: list[str] = []
|
|
||||||
for item in capabilities_raw:
|
|
||||||
capabilities.append(_string(item, "TASK_CAPABILITY_INVALID"))
|
|
||||||
|
|
||||||
# Recovery recreates the pristine canonical row. Initial execution
|
|
||||||
# projection remains the sole owner of runtime capabilities.
|
|
||||||
if capabilities:
|
|
||||||
raise ProjectionRecoveryError(
|
|
||||||
"TASK_RECOVERY_REQUIRES_PRISTINE_CAPABILITIES",
|
|
||||||
409,
|
|
||||||
)
|
|
||||||
|
|
||||||
metadata = _plain_mapping(value.get("metadata", {}), "TASK_METADATA_INVALID")
|
|
||||||
if "execution" in metadata or "execution_projection" in metadata:
|
|
||||||
raise ProjectionRecoveryError(
|
|
||||||
"TASK_RECOVERY_REQUIRES_PRISTINE_METADATA",
|
|
||||||
409,
|
|
||||||
)
|
|
||||||
|
|
||||||
priority = _integer(value.get("priority", 100), "TASK_PRIORITY_INVALID", minimum=0)
|
|
||||||
max_attempts = _integer(
|
|
||||||
value.get("max_attempts", 20),
|
|
||||||
"TASK_MAX_ATTEMPTS_INVALID",
|
|
||||||
minimum=1,
|
|
||||||
)
|
|
||||||
deadline = value.get("deadline")
|
|
||||||
if deadline is not None:
|
|
||||||
deadline = _string(deadline, "TASK_DEADLINE_INVALID")
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
task_id=task_id,
|
|
||||||
goal=goal,
|
|
||||||
instructions=instructions,
|
|
||||||
success_criteria=tuple(criteria),
|
|
||||||
constraints=constraints,
|
|
||||||
required_capabilities=tuple(capabilities),
|
|
||||||
metadata=metadata,
|
|
||||||
priority=priority,
|
|
||||||
max_attempts=max_attempts,
|
|
||||||
deadline=deadline,
|
|
||||||
)
|
|
||||||
|
|
||||||
def public(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"task_id": self.task_id,
|
|
||||||
"goal": self.goal,
|
|
||||||
"instructions": self.instructions,
|
|
||||||
"success_criteria": [dict(x) for x in self.success_criteria],
|
|
||||||
"constraints": dict(self.constraints),
|
|
||||||
"required_capabilities": list(self.required_capabilities),
|
|
||||||
"metadata": dict(self.metadata),
|
|
||||||
"priority": self.priority,
|
|
||||||
"max_attempts": self.max_attempts,
|
|
||||||
"deadline": self.deadline,
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def payload_sha256(self) -> str:
|
|
||||||
return sha256_json(self.public())
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class GovernedProjectionBinding:
|
|
||||||
governed_task_id: str
|
|
||||||
bridge_job_id: str
|
|
||||||
repository: str
|
|
||||||
branch: str
|
|
||||||
generation: int
|
|
||||||
base_oid: str
|
|
||||||
expected_branch_oid: str
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_mapping(cls, value: Mapping[str, Any]) -> "GovernedProjectionBinding":
|
|
||||||
allowed = {
|
|
||||||
"governed_task_id",
|
|
||||||
"bridge_job_id",
|
|
||||||
"repository",
|
|
||||||
"branch",
|
|
||||||
"generation",
|
|
||||||
"base_oid",
|
|
||||||
"expected_branch_oid",
|
|
||||||
}
|
|
||||||
if set(value) != allowed:
|
|
||||||
raise ProjectionRecoveryError("GOVERNED_BINDING_FIELDS_INVALID", 400)
|
|
||||||
repository = _string(value["repository"], "REPOSITORY_INVALID")
|
|
||||||
if repository != "aios/aios":
|
|
||||||
raise ProjectionRecoveryError("REPOSITORY_MISMATCH", 409)
|
|
||||||
return cls(
|
|
||||||
governed_task_id=_string(
|
|
||||||
value["governed_task_id"],
|
|
||||||
"GOVERNED_TASK_ID_INVALID",
|
|
||||||
TASK_ID_RE,
|
|
||||||
),
|
|
||||||
bridge_job_id=_string(
|
|
||||||
value["bridge_job_id"],
|
|
||||||
"BRIDGE_JOB_ID_INVALID",
|
|
||||||
JOB_ID_RE,
|
|
||||||
),
|
|
||||||
repository=repository,
|
|
||||||
branch=_string(value["branch"], "BRANCH_INVALID", BRANCH_RE),
|
|
||||||
generation=_integer(value["generation"], "GENERATION_INVALID", minimum=1),
|
|
||||||
base_oid=_string(value["base_oid"], "BASE_OID_INVALID", SHA40_RE),
|
|
||||||
expected_branch_oid=_string(
|
|
||||||
value["expected_branch_oid"],
|
|
||||||
"EXPECTED_OID_INVALID",
|
|
||||||
SHA40_RE,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def public(self) -> dict[str, Any]:
|
|
||||||
return asdict(self)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ProjectionSourceArtifact:
|
|
||||||
ref: str
|
|
||||||
sha256: str
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_mapping(cls, value: Mapping[str, Any]) -> "ProjectionSourceArtifact":
|
|
||||||
if set(value) != {"ref", "sha256"}:
|
|
||||||
raise ProjectionRecoveryError("SOURCE_ARTIFACT_FIELDS_INVALID", 400)
|
|
||||||
return cls(
|
|
||||||
ref=_string(value["ref"], "SOURCE_ARTIFACT_REF_INVALID"),
|
|
||||||
sha256=_string(value["sha256"], "SOURCE_ARTIFACT_SHA_INVALID", SHA64_RE),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CanonicalTaskProjectionIntent:
|
|
||||||
projection_id: str
|
|
||||||
projection_generation: int
|
|
||||||
idempotency_key: str
|
|
||||||
task: CanonicalTaskPayload
|
|
||||||
binding: GovernedProjectionBinding
|
|
||||||
source_artifact: ProjectionSourceArtifact
|
|
||||||
schema: str = SCHEMA
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_mapping(cls, value: Mapping[str, Any]) -> "CanonicalTaskProjectionIntent":
|
|
||||||
allowed = {
|
|
||||||
"schema",
|
|
||||||
"projection_id",
|
|
||||||
"projection_generation",
|
|
||||||
"idempotency_key",
|
|
||||||
"task",
|
|
||||||
"governed_binding",
|
|
||||||
"source_artifact",
|
|
||||||
"task_payload_sha256",
|
|
||||||
}
|
|
||||||
if set(value) != allowed:
|
|
||||||
raise ProjectionRecoveryError("PROJECTION_INTENT_FIELDS_INVALID", 400)
|
|
||||||
if value["schema"] != SCHEMA:
|
|
||||||
raise ProjectionRecoveryError("PROJECTION_INTENT_SCHEMA_INVALID", 400)
|
|
||||||
|
|
||||||
task = CanonicalTaskPayload.from_mapping(
|
|
||||||
_plain_mapping(value["task"], "TASK_PAYLOAD_INVALID")
|
|
||||||
)
|
|
||||||
binding = GovernedProjectionBinding.from_mapping(
|
|
||||||
_plain_mapping(value["governed_binding"], "GOVERNED_BINDING_INVALID")
|
|
||||||
)
|
|
||||||
source = ProjectionSourceArtifact.from_mapping(
|
|
||||||
_plain_mapping(value["source_artifact"], "SOURCE_ARTIFACT_INVALID")
|
|
||||||
)
|
|
||||||
if binding.governed_task_id != task.task_id:
|
|
||||||
raise ProjectionRecoveryError("PROJECTION_TASK_IDENTITY_MISMATCH", 409)
|
|
||||||
|
|
||||||
expected_hash = _string(
|
|
||||||
value["task_payload_sha256"],
|
|
||||||
"TASK_PAYLOAD_SHA_INVALID",
|
|
||||||
SHA64_RE,
|
|
||||||
)
|
|
||||||
if task.payload_sha256 != expected_hash:
|
|
||||||
raise ProjectionRecoveryError("TASK_PAYLOAD_SHA_MISMATCH", 409)
|
|
||||||
|
|
||||||
projection_id = _string(
|
|
||||||
value["projection_id"],
|
|
||||||
"PROJECTION_ID_INVALID",
|
|
||||||
TASK_ID_RE,
|
|
||||||
)
|
|
||||||
idempotency_key = _string(
|
|
||||||
value["idempotency_key"],
|
|
||||||
"IDEMPOTENCY_KEY_INVALID",
|
|
||||||
TASK_ID_RE,
|
|
||||||
)
|
|
||||||
generation = _integer(
|
|
||||||
value["projection_generation"],
|
|
||||||
"PROJECTION_GENERATION_INVALID",
|
|
||||||
minimum=1,
|
|
||||||
)
|
|
||||||
return cls(
|
|
||||||
projection_id=projection_id,
|
|
||||||
projection_generation=generation,
|
|
||||||
idempotency_key=idempotency_key,
|
|
||||||
task=task,
|
|
||||||
binding=binding,
|
|
||||||
source_artifact=source,
|
|
||||||
)
|
|
||||||
|
|
||||||
def public(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"schema": self.schema,
|
|
||||||
"projection_id": self.projection_id,
|
|
||||||
"projection_generation": self.projection_generation,
|
|
||||||
"idempotency_key": self.idempotency_key,
|
|
||||||
"task": self.task.public(),
|
|
||||||
"governed_binding": self.binding.public(),
|
|
||||||
"source_artifact": asdict(self.source_artifact),
|
|
||||||
"task_payload_sha256": self.task.payload_sha256,
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def request_sha256(self) -> str:
|
|
||||||
return sha256_json(self.public())
|
|
||||||
|
|
||||||
|
|
||||||
class CanonicalTaskProjectionStore:
|
|
||||||
"""Task-Plane-owned intent/recovery store.
|
|
||||||
|
|
||||||
The caller must already be inside the Task Plane authorization boundary.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, db_path: Path | str) -> None:
|
|
||||||
self.db_path = Path(db_path)
|
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
|
||||||
connection = sqlite3.connect(self.db_path, timeout=30.0)
|
|
||||||
connection.row_factory = sqlite3.Row
|
|
||||||
connection.execute("PRAGMA foreign_keys=ON")
|
|
||||||
connection.execute("PRAGMA busy_timeout=30000")
|
|
||||||
return connection
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _columns(connection: sqlite3.Connection, table: str) -> dict[str, sqlite3.Row]:
|
|
||||||
return {
|
|
||||||
str(row["name"]): row
|
|
||||||
for row in connection.execute(f'PRAGMA table_info("{table}")')
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _table_names(connection: sqlite3.Connection) -> set[str]:
|
|
||||||
return {
|
|
||||||
str(row[0])
|
|
||||||
for row in connection.execute(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _event_table(
|
|
||||||
cls,
|
|
||||||
connection: sqlite3.Connection,
|
|
||||||
) -> tuple[str, dict[str, sqlite3.Row]]:
|
|
||||||
tables = cls._table_names(connection)
|
|
||||||
for name in ("task_events", "events"):
|
|
||||||
if name in tables:
|
|
||||||
columns = cls._columns(connection, name)
|
|
||||||
if "task_id" in columns and "event_type" in columns:
|
|
||||||
return name, columns
|
|
||||||
raise ProjectionRecoveryError("TASK_EVENT_TABLE_UNSUPPORTED", 503)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _ensure_control_tables(connection: sqlite3.Connection) -> None:
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS task_projection_intents (
|
|
||||||
projection_id TEXT PRIMARY KEY,
|
|
||||||
task_id TEXT NOT NULL,
|
|
||||||
bridge_job_id TEXT NOT NULL,
|
|
||||||
projection_generation INTEGER NOT NULL,
|
|
||||||
idempotency_key TEXT NOT NULL,
|
|
||||||
request_sha256 TEXT NOT NULL,
|
|
||||||
task_payload_sha256 TEXT NOT NULL,
|
|
||||||
intent_json TEXT NOT NULL,
|
|
||||||
state TEXT NOT NULL,
|
|
||||||
last_error TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
UNIQUE(task_id, projection_generation),
|
|
||||||
UNIQUE(task_id, idempotency_key)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS task_projection_receipts (
|
|
||||||
projection_id TEXT PRIMARY KEY,
|
|
||||||
task_id TEXT NOT NULL,
|
|
||||||
bridge_job_id TEXT NOT NULL,
|
|
||||||
projection_generation INTEGER NOT NULL,
|
|
||||||
task_payload_sha256 TEXT NOT NULL,
|
|
||||||
canonical_revision INTEGER NOT NULL,
|
|
||||||
state TEXT NOT NULL,
|
|
||||||
created_new_row INTEGER NOT NULL,
|
|
||||||
observed_at TEXT NOT NULL,
|
|
||||||
receipt_json TEXT NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
def persist_intent(
|
|
||||||
self,
|
|
||||||
intent: CanonicalTaskProjectionIntent,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
now = _now()
|
|
||||||
request_sha = intent.request_sha256
|
|
||||||
intent_text = _canonical(intent.public()).decode("utf-8")
|
|
||||||
with self._connect() as connection:
|
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
|
||||||
self._ensure_control_tables(connection)
|
|
||||||
existing = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT * FROM task_projection_intents
|
|
||||||
WHERE task_id=? AND projection_generation=?
|
|
||||||
""",
|
|
||||||
(intent.task.task_id, intent.projection_generation),
|
|
||||||
).fetchone()
|
|
||||||
if existing is not None:
|
|
||||||
if (
|
|
||||||
str(existing["projection_id"]) != intent.projection_id
|
|
||||||
or str(existing["idempotency_key"]) != intent.idempotency_key
|
|
||||||
or str(existing["request_sha256"]) != request_sha
|
|
||||||
):
|
|
||||||
raise ProjectionRecoveryError(
|
|
||||||
"PROJECTION_INTENT_IDEMPOTENCY_CONFLICT",
|
|
||||||
409,
|
|
||||||
)
|
|
||||||
connection.commit()
|
|
||||||
return {
|
|
||||||
"projection_id": intent.projection_id,
|
|
||||||
"task_id": intent.task.task_id,
|
|
||||||
"state": str(existing["state"]),
|
|
||||||
"idempotent": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO task_projection_intents(
|
|
||||||
projection_id,task_id,bridge_job_id,projection_generation,
|
|
||||||
idempotency_key,request_sha256,task_payload_sha256,intent_json,
|
|
||||||
state,last_error,created_at,updated_at
|
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
intent.projection_id,
|
|
||||||
intent.task.task_id,
|
|
||||||
intent.binding.bridge_job_id,
|
|
||||||
intent.projection_generation,
|
|
||||||
intent.idempotency_key,
|
|
||||||
request_sha,
|
|
||||||
intent.task.payload_sha256,
|
|
||||||
intent_text,
|
|
||||||
ProjectionState.PENDING.value,
|
|
||||||
None,
|
|
||||||
now,
|
|
||||||
now,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
connection.commit()
|
|
||||||
return {
|
|
||||||
"projection_id": intent.projection_id,
|
|
||||||
"task_id": intent.task.task_id,
|
|
||||||
"state": ProjectionState.PENDING.value,
|
|
||||||
"idempotent": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _task_row_payload(
|
|
||||||
self,
|
|
||||||
row: sqlite3.Row,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
success = json.loads(row["success_criteria_json"])
|
|
||||||
constraints = json.loads(row["constraints_json"])
|
|
||||||
capabilities = json.loads(row["required_capabilities_json"])
|
|
||||||
metadata = json.loads(row["metadata_json"])
|
|
||||||
except (TypeError, json.JSONDecodeError) as exc:
|
|
||||||
raise ProjectionRecoveryError("CANONICAL_TASK_JSON_INVALID", 409) from exc
|
|
||||||
|
|
||||||
if (
|
|
||||||
not isinstance(success, list)
|
|
||||||
or not isinstance(constraints, Mapping)
|
|
||||||
or not isinstance(capabilities, list)
|
|
||||||
or not isinstance(metadata, Mapping)
|
|
||||||
):
|
|
||||||
raise ProjectionRecoveryError("CANONICAL_TASK_SHAPE_INVALID", 409)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"task_id": str(row["task_id"]),
|
|
||||||
"goal": str(row["goal"]),
|
|
||||||
"instructions": str(row["instructions"]),
|
|
||||||
"success_criteria": success,
|
|
||||||
"constraints": dict(constraints),
|
|
||||||
"required_capabilities": capabilities,
|
|
||||||
"metadata": dict(metadata),
|
|
||||||
"priority": int(row["priority"]),
|
|
||||||
"max_attempts": int(row["max_attempts"]),
|
|
||||||
"deadline": row["deadline"],
|
|
||||||
}
|
|
||||||
|
|
||||||
def _verify_existing_task(
|
|
||||||
self,
|
|
||||||
row: sqlite3.Row,
|
|
||||||
intent: CanonicalTaskProjectionIntent,
|
|
||||||
) -> int:
|
|
||||||
observed = self._task_row_payload(row)
|
|
||||||
observed_hash = sha256_json(observed)
|
|
||||||
if observed_hash != intent.task.payload_sha256:
|
|
||||||
raise ProjectionRecoveryError(
|
|
||||||
"CANONICAL_TASK_PROJECTION_IDENTITY_CONFLICT",
|
|
||||||
409,
|
|
||||||
)
|
|
||||||
return int(row["revision"])
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _required_task_columns() -> set[str]:
|
|
||||||
return {
|
|
||||||
"task_id",
|
|
||||||
"goal",
|
|
||||||
"instructions",
|
|
||||||
"success_criteria_json",
|
|
||||||
"required_capabilities_json",
|
|
||||||
"constraints_json",
|
|
||||||
"metadata_json",
|
|
||||||
"priority",
|
|
||||||
"max_attempts",
|
|
||||||
"attempt",
|
|
||||||
"status",
|
|
||||||
"claimed_by",
|
|
||||||
"lease_token_hash",
|
|
||||||
"lease_expires_at",
|
|
||||||
"result_json",
|
|
||||||
"verification_json",
|
|
||||||
"revision",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
}
|
|
||||||
|
|
||||||
def _insert_task(
|
|
||||||
self,
|
|
||||||
connection: sqlite3.Connection,
|
|
||||||
intent: CanonicalTaskProjectionIntent,
|
|
||||||
) -> int:
|
|
||||||
columns = self._columns(connection, "tasks")
|
|
||||||
if not self._required_task_columns().issubset(columns):
|
|
||||||
raise ProjectionRecoveryError("TASK_SCHEMA_RECOVERY_UNSUPPORTED", 503)
|
|
||||||
|
|
||||||
now = _now()
|
|
||||||
values: dict[str, Any] = {
|
|
||||||
"task_id": intent.task.task_id,
|
|
||||||
"goal": intent.task.goal,
|
|
||||||
"instructions": intent.task.instructions,
|
|
||||||
"success_criteria_json": _canonical(
|
|
||||||
[dict(x) for x in intent.task.success_criteria]
|
|
||||||
).decode("utf-8"),
|
|
||||||
"required_capabilities_json": "[]",
|
|
||||||
"constraints_json": _canonical(dict(intent.task.constraints)).decode("utf-8"),
|
|
||||||
"metadata_json": _canonical(dict(intent.task.metadata)).decode("utf-8"),
|
|
||||||
"deadline": intent.task.deadline,
|
|
||||||
"priority": intent.task.priority,
|
|
||||||
"max_attempts": intent.task.max_attempts,
|
|
||||||
"attempt": 0,
|
|
||||||
"status": "QUEUED",
|
|
||||||
"claimed_by": None,
|
|
||||||
"lease_token_hash": None,
|
|
||||||
"lease_expires_at": None,
|
|
||||||
"result_json": None,
|
|
||||||
"verification_json": None,
|
|
||||||
"revision": 1,
|
|
||||||
"created_at": now,
|
|
||||||
"updated_at": now,
|
|
||||||
}
|
|
||||||
for optional in (
|
|
||||||
"result_digest",
|
|
||||||
"result_idempotency_key",
|
|
||||||
):
|
|
||||||
if optional in columns:
|
|
||||||
values[optional] = None
|
|
||||||
|
|
||||||
names = [name for name in values if name in columns]
|
|
||||||
placeholders = ",".join("?" for _ in names)
|
|
||||||
connection.execute(
|
|
||||||
f'INSERT INTO tasks ({",".join(chr(34)+n+chr(34) for n in names)}) '
|
|
||||||
f"VALUES ({placeholders})",
|
|
||||||
tuple(values[name] for name in names),
|
|
||||||
)
|
|
||||||
self._insert_task_created_event(connection, intent)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
def _insert_task_created_event(
|
|
||||||
self,
|
|
||||||
connection: sqlite3.Connection,
|
|
||||||
intent: CanonicalTaskProjectionIntent,
|
|
||||||
) -> None:
|
|
||||||
table, columns = self._event_table(connection)
|
|
||||||
event = {
|
|
||||||
"schema": "aios.task-projection-recovery.event.v1",
|
|
||||||
"task_id": intent.task.task_id,
|
|
||||||
"bridge_job_id": intent.binding.bridge_job_id,
|
|
||||||
"projection_id": intent.projection_id,
|
|
||||||
"projection_generation": intent.projection_generation,
|
|
||||||
"task_payload_sha256": intent.task.payload_sha256,
|
|
||||||
"source_artifact_ref": intent.source_artifact.ref,
|
|
||||||
"source_artifact_sha256": intent.source_artifact.sha256,
|
|
||||||
"recovery": True,
|
|
||||||
}
|
|
||||||
values: dict[str, Any] = {
|
|
||||||
"task_id": intent.task.task_id,
|
|
||||||
"event_type": "TASK_CREATED",
|
|
||||||
}
|
|
||||||
payload_text = _canonical(event).decode("utf-8")
|
|
||||||
if "payload_json" in columns:
|
|
||||||
values["payload_json"] = payload_text
|
|
||||||
elif "payload" in columns:
|
|
||||||
values["payload"] = payload_text
|
|
||||||
else:
|
|
||||||
raise ProjectionRecoveryError("TASK_EVENT_PAYLOAD_UNSUPPORTED", 503)
|
|
||||||
if "created_at" in columns:
|
|
||||||
values["created_at"] = _now()
|
|
||||||
if "agent_id" in columns:
|
|
||||||
values["agent_id"] = "control-plane-projection-recovery"
|
|
||||||
if "actor_id" in columns:
|
|
||||||
values["actor_id"] = "control-plane-projection-recovery"
|
|
||||||
|
|
||||||
names = list(values)
|
|
||||||
connection.execute(
|
|
||||||
f'INSERT INTO "{table}" ({",".join(chr(34)+n+chr(34) for n in names)}) '
|
|
||||||
f'VALUES ({",".join("?" for _ in names)})',
|
|
||||||
tuple(values[name] for name in names),
|
|
||||||
)
|
|
||||||
|
|
||||||
def reconcile(
|
|
||||||
self,
|
|
||||||
intent: CanonicalTaskProjectionIntent,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
# Persist first so a crash before Task creation leaves a durable PENDING
|
|
||||||
# intent for startup/supervisor reconciliation.
|
|
||||||
self.persist_intent(intent)
|
|
||||||
|
|
||||||
with self._connect() as connection:
|
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
|
||||||
self._ensure_control_tables(connection)
|
|
||||||
existing_receipt = connection.execute(
|
|
||||||
"SELECT * FROM task_projection_receipts WHERE projection_id=?",
|
|
||||||
(intent.projection_id,),
|
|
||||||
).fetchone()
|
|
||||||
if existing_receipt is not None:
|
|
||||||
if (
|
|
||||||
str(existing_receipt["task_id"]) != intent.task.task_id
|
|
||||||
or str(existing_receipt["bridge_job_id"]) != intent.binding.bridge_job_id
|
|
||||||
or str(existing_receipt["task_payload_sha256"])
|
|
||||||
!= intent.task.payload_sha256
|
|
||||||
):
|
|
||||||
raise ProjectionRecoveryError(
|
|
||||||
"PROJECTION_RECEIPT_IDENTITY_CONFLICT",
|
|
||||||
409,
|
|
||||||
)
|
|
||||||
connection.commit()
|
|
||||||
receipt = json.loads(existing_receipt["receipt_json"])
|
|
||||||
receipt["idempotent"] = True
|
|
||||||
return receipt
|
|
||||||
|
|
||||||
row = connection.execute(
|
|
||||||
"SELECT * FROM tasks WHERE task_id=?",
|
|
||||||
(intent.task.task_id,),
|
|
||||||
).fetchone()
|
|
||||||
created_new = False
|
|
||||||
if row is None:
|
|
||||||
revision = self._insert_task(connection, intent)
|
|
||||||
created_new = True
|
|
||||||
row = connection.execute(
|
|
||||||
"SELECT * FROM tasks WHERE task_id=?",
|
|
||||||
(intent.task.task_id,),
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
raise ProjectionRecoveryError(
|
|
||||||
"TASK_RECOVERY_OBSERVATION_FAILED",
|
|
||||||
503,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
revision = self._verify_existing_task(row, intent)
|
|
||||||
|
|
||||||
# Observation after mutation is mandatory.
|
|
||||||
revision = self._verify_existing_task(row, intent)
|
|
||||||
observed_at = _now()
|
|
||||||
receipt = {
|
|
||||||
"schema": RECEIPT_SCHEMA,
|
|
||||||
"projection_id": intent.projection_id,
|
|
||||||
"governed_task_id": intent.binding.governed_task_id,
|
|
||||||
"canonical_task_id": intent.task.task_id,
|
|
||||||
"bridge_job_id": intent.binding.bridge_job_id,
|
|
||||||
"projection_generation": intent.projection_generation,
|
|
||||||
"task_payload_sha256": intent.task.payload_sha256,
|
|
||||||
"canonical_revision": revision,
|
|
||||||
"state": ProjectionState.CONVERGED.value,
|
|
||||||
"created_new_row": created_new,
|
|
||||||
"source_artifact": asdict(intent.source_artifact),
|
|
||||||
"observed_at": observed_at,
|
|
||||||
"idempotent": False,
|
|
||||||
}
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO task_projection_receipts(
|
|
||||||
projection_id,task_id,bridge_job_id,projection_generation,
|
|
||||||
task_payload_sha256,canonical_revision,state,created_new_row,
|
|
||||||
observed_at,receipt_json
|
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
intent.projection_id,
|
|
||||||
intent.task.task_id,
|
|
||||||
intent.binding.bridge_job_id,
|
|
||||||
intent.projection_generation,
|
|
||||||
intent.task.payload_sha256,
|
|
||||||
revision,
|
|
||||||
ProjectionState.CONVERGED.value,
|
|
||||||
1 if created_new else 0,
|
|
||||||
observed_at,
|
|
||||||
_canonical(receipt).decode("utf-8"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
UPDATE task_projection_intents
|
|
||||||
SET state=?, last_error=NULL, updated_at=?
|
|
||||||
WHERE projection_id=?
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
ProjectionState.CONVERGED.value,
|
|
||||||
observed_at,
|
|
||||||
intent.projection_id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
connection.commit()
|
|
||||||
return receipt
|
|
||||||
|
|
||||||
def get_projection(
|
|
||||||
self,
|
|
||||||
task_id: str,
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
task_id = _string(task_id, "TASK_ID_INVALID", TASK_ID_RE)
|
|
||||||
with self._connect() as connection:
|
|
||||||
self._ensure_control_tables(connection)
|
|
||||||
intent = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT * FROM task_projection_intents
|
|
||||||
WHERE task_id=?
|
|
||||||
ORDER BY projection_generation DESC
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(task_id,),
|
|
||||||
).fetchone()
|
|
||||||
if intent is None:
|
|
||||||
return None
|
|
||||||
receipt = connection.execute(
|
|
||||||
"SELECT receipt_json FROM task_projection_receipts WHERE projection_id=?",
|
|
||||||
(intent["projection_id"],),
|
|
||||||
).fetchone()
|
|
||||||
return {
|
|
||||||
"projection_id": intent["projection_id"],
|
|
||||||
"task_id": task_id,
|
|
||||||
"bridge_job_id": intent["bridge_job_id"],
|
|
||||||
"projection_generation": int(intent["projection_generation"]),
|
|
||||||
"state": intent["state"],
|
|
||||||
"last_error": intent["last_error"],
|
|
||||||
"task_payload_sha256": intent["task_payload_sha256"],
|
|
||||||
"receipt": json.loads(receipt[0]) if receipt else None,
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
"""HTTP integration helpers for canonical Task projection recovery.
|
|
||||||
|
|
||||||
This module intentionally contains no HTTP server. Existing Task Plane /
|
|
||||||
projection-owner handlers should call ``recover_from_json`` only after their
|
|
||||||
normal service-side ``agent-task:control`` authorization succeeds.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
from aios_agent_task_projection_recovery import (
|
|
||||||
CanonicalTaskProjectionIntent,
|
|
||||||
CanonicalTaskProjectionStore,
|
|
||||||
ProjectionRecoveryError,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def recover_from_json(
|
|
||||||
db_path: Path | str,
|
|
||||||
task_id: str,
|
|
||||||
body: Mapping[str, Any],
|
|
||||||
) -> tuple[int, dict[str, Any]]:
|
|
||||||
try:
|
|
||||||
intent = CanonicalTaskProjectionIntent.from_mapping(body)
|
|
||||||
if intent.task.task_id != task_id:
|
|
||||||
raise ProjectionRecoveryError("PROJECTION_TASK_IDENTITY_MISMATCH", 409)
|
|
||||||
result = CanonicalTaskProjectionStore(db_path).reconcile(intent)
|
|
||||||
return (200 if result.get("idempotent") else 201), result
|
|
||||||
except ProjectionRecoveryError as exc:
|
|
||||||
return exc.status, {"error": exc.code}
|
|
||||||
|
|
||||||
|
|
||||||
def get_projection(
|
|
||||||
db_path: Path | str,
|
|
||||||
task_id: str,
|
|
||||||
) -> tuple[int, dict[str, Any]]:
|
|
||||||
try:
|
|
||||||
result = CanonicalTaskProjectionStore(db_path).get_projection(task_id)
|
|
||||||
except ProjectionRecoveryError as exc:
|
|
||||||
return exc.status, {"error": exc.code}
|
|
||||||
if result is None:
|
|
||||||
return 404, {"error": "TASK_PROJECTION_NOT_FOUND"}
|
|
||||||
return 200, result
|
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
"""Owner-controlled, CAS-bound retry extension for exhausted Online Agent Tasks."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
TASK_RE = re.compile(r"^[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}$")
|
||||||
|
MAX_BODY = 64 * 1024
|
||||||
|
AUTHORIZE_EXTENSION_ID_ENV = "AIOS_AGENT_TASK_AUTHORIZATION_ID"
|
||||||
|
|
||||||
|
|
||||||
|
class RetryExtensionError(RuntimeError):
|
||||||
|
def __init__(self, code: str, status: int = 409) -> None:
|
||||||
|
super().__init__(code)
|
||||||
|
self.code = code
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical(value: object) -> bytes:
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _sha(value: object) -> str:
|
||||||
|
return hashlib.sha256(_canonical(value)).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _required_authorization_id() -> str:
|
||||||
|
value = os.environ.get(AUTHORIZE_EXTENSION_ID_ENV, "").strip()
|
||||||
|
if not value:
|
||||||
|
raise RuntimeError("TASK_RETRY_AUTHORIZATION_ID_MISSING")
|
||||||
|
if KEY_RE.fullmatch(value) is None:
|
||||||
|
raise RuntimeError("TASK_RETRY_AUTHORIZATION_ID_INVALID")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_string(value: object, code: str, pattern: re.Pattern[str] | None = None) -> str:
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
raise RetryExtensionError(code, 400)
|
||||||
|
value = value.strip()
|
||||||
|
if pattern is not None and pattern.fullmatch(value) is None:
|
||||||
|
raise RetryExtensionError(code, 400)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RetryExtensionRequest:
|
||||||
|
expected_status: str
|
||||||
|
expected_revision: int
|
||||||
|
expected_attempt: int
|
||||||
|
expected_max_attempts: int
|
||||||
|
next_max_attempts: int
|
||||||
|
previous_verification_sha256: str
|
||||||
|
authorization_id: str
|
||||||
|
idempotency_key: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mapping(cls, value: Mapping[str, Any]) -> "RetryExtensionRequest":
|
||||||
|
allowed = {
|
||||||
|
"expected_status", "expected_revision", "expected_attempt", "expected_max_attempts",
|
||||||
|
"next_max_attempts", "previous_verification_sha256", "authorization_id", "idempotency_key",
|
||||||
|
}
|
||||||
|
if set(value) != allowed:
|
||||||
|
raise RetryExtensionError("RETRY_EXTENSION_FIELDS_INVALID", 400)
|
||||||
|
status = _require_string(value["expected_status"], "EXPECTED_STATUS_INVALID")
|
||||||
|
if status != "FAILED":
|
||||||
|
raise RetryExtensionError("EXPECTED_STATUS_INVALID", 400)
|
||||||
|
ints: dict[str, int] = {}
|
||||||
|
for field in ("expected_revision", "expected_attempt", "expected_max_attempts", "next_max_attempts"):
|
||||||
|
item = value[field]
|
||||||
|
if not isinstance(item, int) or isinstance(item, bool) or item < 0:
|
||||||
|
raise RetryExtensionError("RETRY_EXTENSION_INTEGER_INVALID", 400)
|
||||||
|
ints[field] = item
|
||||||
|
if ints["expected_attempt"] != ints["expected_max_attempts"]:
|
||||||
|
raise RetryExtensionError("RETRY_EXTENSION_NOT_EXHAUSTED", 409)
|
||||||
|
if ints["next_max_attempts"] != ints["expected_max_attempts"] + 1:
|
||||||
|
raise RetryExtensionError("RETRY_EXTENSION_MUST_BE_BOUNDED_ONE_STEP", 400)
|
||||||
|
if ints["next_max_attempts"] > 50:
|
||||||
|
raise RetryExtensionError("RETRY_EXTENSION_LIMIT_EXCEEDED", 400)
|
||||||
|
verification = _require_string(value["previous_verification_sha256"], "VERIFICATION_SHA256_INVALID")
|
||||||
|
if re.fullmatch(r"[0-9a-f]{64}", verification) is None:
|
||||||
|
raise RetryExtensionError("VERIFICATION_SHA256_INVALID", 400)
|
||||||
|
authorization = _require_string(value["authorization_id"], "AUTHORIZATION_ID_INVALID", KEY_RE)
|
||||||
|
key = _require_string(value["idempotency_key"], "IDEMPOTENCY_KEY_INVALID", KEY_RE)
|
||||||
|
return cls(
|
||||||
|
status,
|
||||||
|
ints["expected_revision"],
|
||||||
|
ints["expected_attempt"],
|
||||||
|
ints["expected_max_attempts"],
|
||||||
|
ints["next_max_attempts"],
|
||||||
|
verification,
|
||||||
|
authorization,
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
|
||||||
|
def public(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"expected_status": self.expected_status,
|
||||||
|
"expected_revision": self.expected_revision,
|
||||||
|
"expected_attempt": self.expected_attempt,
|
||||||
|
"expected_max_attempts": self.expected_max_attempts,
|
||||||
|
"next_max_attempts": self.next_max_attempts,
|
||||||
|
"previous_verification_sha256": self.previous_verification_sha256,
|
||||||
|
"authorization_id": self.authorization_id,
|
||||||
|
"idempotency_key": self.idempotency_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RetryExtensionStore:
|
||||||
|
"""Owns the official mutation. Direct/manual DB edits remain forbidden."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Path) -> 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 _event_table(connection: sqlite3.Connection) -> tuple[str, dict[str, sqlite3.Row]]:
|
||||||
|
tables = {
|
||||||
|
row["name"]
|
||||||
|
for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
}
|
||||||
|
for name in ("task_events", "events"):
|
||||||
|
if name not in tables:
|
||||||
|
continue
|
||||||
|
columns = RetryExtensionStore._columns(connection, name)
|
||||||
|
if "task_id" in columns and ("event_type" in columns or "type" in columns):
|
||||||
|
return name, columns
|
||||||
|
raise RetryExtensionError("TASK_EVENT_TABLE_UNSUPPORTED", 503)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _verification_hash(raw: object) -> str:
|
||||||
|
if raw in (None, ""):
|
||||||
|
value: object = {}
|
||||||
|
elif isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
value = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise RetryExtensionError("TASK_VERIFICATION_INVALID", 409) from exc
|
||||||
|
else:
|
||||||
|
value = raw
|
||||||
|
return _sha(value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ensure_extension_table(connection: sqlite3.Connection) -> None:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS task_retry_extensions (
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
idempotency_key TEXT NOT NULL,
|
||||||
|
request_sha256 TEXT NOT NULL,
|
||||||
|
authorization_id TEXT NOT NULL,
|
||||||
|
previous_verification_sha256 TEXT NOT NULL,
|
||||||
|
previous_max_attempts INTEGER NOT NULL,
|
||||||
|
next_max_attempts INTEGER NOT NULL,
|
||||||
|
expected_revision INTEGER NOT NULL,
|
||||||
|
applied_revision INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(task_id, idempotency_key)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _insert_event(
|
||||||
|
connection: sqlite3.Connection,
|
||||||
|
task_id: str,
|
||||||
|
payload: Mapping[str, Any],
|
||||||
|
) -> None:
|
||||||
|
table, columns = RetryExtensionStore._event_table(connection)
|
||||||
|
values: dict[str, Any] = {"task_id": task_id}
|
||||||
|
if "event_type" in columns:
|
||||||
|
values["event_type"] = "TASK_RETRY_EXTENSION_AUTHORIZED"
|
||||||
|
else:
|
||||||
|
values["type"] = "TASK_RETRY_EXTENSION_AUTHORIZED"
|
||||||
|
payload_text = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||||
|
if "payload_json" in columns:
|
||||||
|
values["payload_json"] = payload_text
|
||||||
|
elif "payload" in columns:
|
||||||
|
values["payload"] = payload_text
|
||||||
|
else:
|
||||||
|
raise RetryExtensionError("TASK_EVENT_PAYLOAD_UNSUPPORTED", 503)
|
||||||
|
if "created_at" in columns:
|
||||||
|
values["created_at"] = _now()
|
||||||
|
if "agent_id" in columns:
|
||||||
|
values["agent_id"] = "owner-control"
|
||||||
|
if "actor_id" in columns:
|
||||||
|
values["actor_id"] = "owner-control"
|
||||||
|
names = list(values)
|
||||||
|
sql = f'INSERT INTO "{table}" ({",".join(f"""\"{name}\"""" for name in names)}) VALUES ({",".join("?" for _ in names)})'
|
||||||
|
connection.execute(sql, tuple(values[name] for name in names))
|
||||||
|
|
||||||
|
def extend(self, task_id: str, request: RetryExtensionRequest) -> dict[str, Any]:
|
||||||
|
task_id = _require_string(task_id, "TASK_ID_INVALID", TASK_RE)
|
||||||
|
required_authorization_id = _required_authorization_id()
|
||||||
|
if request.authorization_id != required_authorization_id:
|
||||||
|
raise RetryExtensionError("RETRY_EXTENSION_AUTHORIZATION_ID_MISMATCH", 403)
|
||||||
|
request_payload = request.public()
|
||||||
|
request_sha = _sha(request_payload)
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
self._ensure_extension_table(connection)
|
||||||
|
replay = connection.execute(
|
||||||
|
"SELECT * FROM task_retry_extensions WHERE task_id=? AND idempotency_key=?",
|
||||||
|
(task_id, request.idempotency_key),
|
||||||
|
).fetchone()
|
||||||
|
if replay is not None:
|
||||||
|
if replay["request_sha256"] != request_sha:
|
||||||
|
raise RetryExtensionError("RETRY_EXTENSION_IDEMPOTENCY_CONFLICT", 409)
|
||||||
|
connection.commit()
|
||||||
|
return {
|
||||||
|
"task_id": task_id,
|
||||||
|
"status": "QUEUED",
|
||||||
|
"attempt": request.expected_attempt,
|
||||||
|
"max_attempts": int(replay["next_max_attempts"]),
|
||||||
|
"revision": int(replay["applied_revision"]),
|
||||||
|
"idempotent": True,
|
||||||
|
"authorization_id": replay["authorization_id"],
|
||||||
|
"previous_verification_sha256": replay["previous_verification_sha256"],
|
||||||
|
}
|
||||||
|
|
||||||
|
columns = self._columns(connection, "tasks")
|
||||||
|
required = {
|
||||||
|
"task_id", "status", "attempt", "max_attempts", "revision", "claimed_by",
|
||||||
|
"lease_token_hash", "lease_expires_at", "verification_json",
|
||||||
|
}
|
||||||
|
if not required.issubset(columns):
|
||||||
|
raise RetryExtensionError("TASK_SCHEMA_RETRY_EXTENSION_UNSUPPORTED", 503)
|
||||||
|
task = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||||
|
if task is None:
|
||||||
|
raise RetryExtensionError("TASK_NOT_FOUND", 404)
|
||||||
|
actual_verification_sha = self._verification_hash(task["verification_json"])
|
||||||
|
if actual_verification_sha != request.previous_verification_sha256:
|
||||||
|
raise RetryExtensionError("RETRY_EXTENSION_VERIFICATION_DRIFT", 409)
|
||||||
|
if (
|
||||||
|
task["status"] != request.expected_status
|
||||||
|
or int(task["revision"]) != request.expected_revision
|
||||||
|
or int(task["attempt"]) != request.expected_attempt
|
||||||
|
or int(task["max_attempts"]) != request.expected_max_attempts
|
||||||
|
):
|
||||||
|
raise RetryExtensionError("RETRY_EXTENSION_CAS_MISMATCH", 409)
|
||||||
|
for lease_field in ("lease_token_hash", "lease_expires_at"):
|
||||||
|
if lease_field in columns and task[lease_field] not in (None, ""):
|
||||||
|
raise RetryExtensionError("RETRY_EXTENSION_ACTIVE_LEASE_DENY", 409)
|
||||||
|
|
||||||
|
previous_claimed_by = task["claimed_by"]
|
||||||
|
applied_revision = request.expected_revision + 1
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE tasks
|
||||||
|
SET status='QUEUED', max_attempts=?, revision=?, claimed_by=NULL,
|
||||||
|
lease_token_hash=NULL, lease_expires_at=NULL
|
||||||
|
WHERE task_id=? AND status=? AND revision=? AND attempt=? AND max_attempts=?
|
||||||
|
AND claimed_by IS ?
|
||||||
|
AND lease_token_hash IS NULL AND lease_expires_at IS NULL
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
request.next_max_attempts,
|
||||||
|
applied_revision,
|
||||||
|
task_id,
|
||||||
|
request.expected_status,
|
||||||
|
request.expected_revision,
|
||||||
|
request.expected_attempt,
|
||||||
|
request.expected_max_attempts,
|
||||||
|
previous_claimed_by,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
raise RetryExtensionError("RETRY_EXTENSION_CAS_MISMATCH", 409)
|
||||||
|
|
||||||
|
event_payload = {
|
||||||
|
"schema": "aios.task-retry-extension.event.v1",
|
||||||
|
"task_id": task_id,
|
||||||
|
"authorization_id": request.authorization_id,
|
||||||
|
"idempotency_key": request.idempotency_key,
|
||||||
|
"previous_verification_sha256": request.previous_verification_sha256,
|
||||||
|
"previous_max_attempts": request.expected_max_attempts,
|
||||||
|
"next_max_attempts": request.next_max_attempts,
|
||||||
|
"previous_revision": request.expected_revision,
|
||||||
|
"applied_revision": applied_revision,
|
||||||
|
"request_sha256": request_sha,
|
||||||
|
"previous_claimed_by": previous_claimed_by,
|
||||||
|
"stale_terminal_claim_normalized": previous_claimed_by not in (None, ""),
|
||||||
|
}
|
||||||
|
self._insert_event(connection, task_id, event_payload)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO task_retry_extensions(
|
||||||
|
task_id,idempotency_key,request_sha256,authorization_id,previous_verification_sha256,
|
||||||
|
previous_max_attempts,next_max_attempts,expected_revision,applied_revision,created_at
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
task_id,
|
||||||
|
request.idempotency_key,
|
||||||
|
request_sha,
|
||||||
|
request.authorization_id,
|
||||||
|
request.previous_verification_sha256,
|
||||||
|
request.expected_max_attempts,
|
||||||
|
request.next_max_attempts,
|
||||||
|
request.expected_revision,
|
||||||
|
applied_revision,
|
||||||
|
_now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
return {
|
||||||
|
"task_id": task_id,
|
||||||
|
"status": "QUEUED",
|
||||||
|
"attempt": request.expected_attempt,
|
||||||
|
"max_attempts": request.next_max_attempts,
|
||||||
|
"revision": applied_revision,
|
||||||
|
"idempotent": False,
|
||||||
|
"authorization_id": request.authorization_id,
|
||||||
|
"previous_verification_sha256": request.previous_verification_sha256,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RetryExtensionHandler(BaseHTTPRequestHandler):
|
||||||
|
store: RetryExtensionStore | None = None
|
||||||
|
control_token: str = ""
|
||||||
|
|
||||||
|
def log_message(self, *_args: Any) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _json(self, value: Mapping[str, Any], status: int) -> None:
|
||||||
|
data = json.dumps(dict(value), sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
def _authorized(self) -> bool:
|
||||||
|
header = self.headers.get("Authorization", "")
|
||||||
|
return (
|
||||||
|
header.startswith("Bearer ")
|
||||||
|
and bool(self.control_token)
|
||||||
|
and hmac.compare_digest(header[7:], self.control_token)
|
||||||
|
)
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
if self.path == "/health":
|
||||||
|
self._json({"status": "ok", "service": "aios-agent-task-retry-extension"}, 200)
|
||||||
|
else:
|
||||||
|
self._json({"error": "NOT_FOUND"}, 404)
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
match = re.fullmatch(r"/v1/tasks/([^/]+)/retry-extension", self.path)
|
||||||
|
if match is None:
|
||||||
|
self._json({"error": "NOT_FOUND"}, 404)
|
||||||
|
return
|
||||||
|
if not self._authorized():
|
||||||
|
self._json({"error": "UNAUTHORIZED"}, 401)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
except ValueError:
|
||||||
|
self._json({"error": "CONTENT_LENGTH_INVALID"}, 400)
|
||||||
|
return
|
||||||
|
if length < 1 or length > MAX_BODY:
|
||||||
|
self._json({"error": "REQUEST_SIZE_INVALID"}, 413)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
value = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise ValueError
|
||||||
|
request = RetryExtensionRequest.from_mapping(value)
|
||||||
|
if self.store is None:
|
||||||
|
raise RetryExtensionError("RUNTIME_UNAVAILABLE", 503)
|
||||||
|
result = self.store.extend(match.group(1), request)
|
||||||
|
self._json(result, 200 if result["idempotent"] else 201)
|
||||||
|
except RetryExtensionError as exc:
|
||||||
|
self._json({"error": exc.code}, exc.status)
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
||||||
|
self._json({"error": "REQUEST_INVALID"}, 400)
|
||||||
|
|
||||||
|
|
||||||
|
def load_control_token(path: Path) -> str:
|
||||||
|
if path.is_symlink() or not path.is_file():
|
||||||
|
raise RuntimeError("TASK_RETRY_CONTROL_CREDENTIAL_UNAVAILABLE")
|
||||||
|
value = path.read_text(encoding="utf-8").strip()
|
||||||
|
if len(value) < 32:
|
||||||
|
raise RuntimeError("TASK_RETRY_CONTROL_CREDENTIAL_INVALID")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def serve() -> None:
|
||||||
|
db = Path(os.environ.get("AIOS_AGENT_TASK_DB", "/var/lib/aios-agent-task/state.sqlite3"))
|
||||||
|
_required_authorization_id()
|
||||||
|
token_path = Path(
|
||||||
|
os.environ.get(
|
||||||
|
"AIOS_AGENT_TASK_CONTROL_TOKEN_FILE",
|
||||||
|
str(Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials")) / "agent_task_control_token"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
host = os.environ.get("AIOS_TASK_RETRY_HOST", "127.0.0.1")
|
||||||
|
port = int(os.environ.get("AIOS_TASK_RETRY_PORT", "18941"))
|
||||||
|
RetryExtensionHandler.store = RetryExtensionStore(db)
|
||||||
|
RetryExtensionHandler.control_token = load_control_token(token_path)
|
||||||
|
ThreadingHTTPServer((host, port), RetryExtensionHandler).serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
serve()
|
||||||
@@ -94,23 +94,89 @@ class AuthorityGitExecutor:
|
|||||||
self._git(["worktree", "add", "--detach", str(path), base_oid])
|
self._git(["worktree", "add", "--detach", str(path), base_oid])
|
||||||
return path
|
return path
|
||||||
|
|
||||||
def create_task_branch(self, branch: str, base_oid: str) -> str:
|
def recover_failed_detached_worktree_exact(
|
||||||
"""Create an isolated agent branch once, using an empty-ref CAS."""
|
self,
|
||||||
ref = branch if branch.startswith("refs/heads/") else "refs/heads/" + branch
|
*,
|
||||||
if not TASK_BRANCH_RE.fullmatch(ref):
|
path: Path,
|
||||||
|
expected_oid: str,
|
||||||
|
stale_oid: str,
|
||||||
|
target_ref: str,
|
||||||
|
) -> str:
|
||||||
|
"""Recreate one exact clean, detached, single-attempt worktree at its durable head."""
|
||||||
|
_oid(expected_oid, "expected_oid")
|
||||||
|
_oid(stale_oid, "stale_oid")
|
||||||
|
if not TASK_BRANCH_RE.fullmatch(target_ref):
|
||||||
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
||||||
_oid(base_oid, "base_oid")
|
if self.read_ref(target_ref) is not None:
|
||||||
if not self.commit_exists(base_oid):
|
raise AuthorityGitError("AUTHORITY_TARGET_BRANCH_ALREADY_EXISTS")
|
||||||
raise AuthorityGitError("BASE_COMMIT_NOT_FOUND")
|
if (
|
||||||
current = self.read_ref(ref)
|
not path.is_absolute()
|
||||||
|
or path.parent != self.worktree_root
|
||||||
|
or path.name in {"", ".", ".."}
|
||||||
|
or path.is_symlink()
|
||||||
|
or not path.is_dir()
|
||||||
|
):
|
||||||
|
raise AuthorityGitError("WORKTREE_RECOVERY_PATH_INVALID")
|
||||||
|
head = subprocess.run(
|
||||||
|
["git", "-C", str(path), "rev-parse", "HEAD"],
|
||||||
|
text=True, capture_output=True, check=False,
|
||||||
|
)
|
||||||
|
if head.returncode != 0 or head.stdout.strip() != stale_oid or stale_oid == expected_oid:
|
||||||
|
raise AuthorityGitError("WORKTREE_RECOVERY_HEAD_INVALID")
|
||||||
|
symbolic = subprocess.run(
|
||||||
|
["git", "-C", str(path), "symbolic-ref", "-q", "HEAD"],
|
||||||
|
text=True, capture_output=True, check=False,
|
||||||
|
)
|
||||||
|
if symbolic.returncode != 1:
|
||||||
|
raise AuthorityGitError("WORKTREE_RECOVERY_NOT_DETACHED")
|
||||||
|
status = subprocess.run(
|
||||||
|
["git", "-C", str(path), "status", "--porcelain=v1", "--untracked-files=all"],
|
||||||
|
text=True, capture_output=True, check=False,
|
||||||
|
)
|
||||||
|
if status.returncode != 0 or status.stdout:
|
||||||
|
raise AuthorityGitError("WORKTREE_RECOVERY_NOT_CLEAN")
|
||||||
|
parents = self._git(["rev-list", "--parents", "-n", "1", stale_oid]).split()
|
||||||
|
if parents != [stale_oid, expected_oid]:
|
||||||
|
raise AuthorityGitError("WORKTREE_RECOVERY_LINEAGE_INVALID")
|
||||||
|
registered = [
|
||||||
|
line.removeprefix("worktree ")
|
||||||
|
for line in self._git(["worktree", "list", "--porcelain"]).splitlines()
|
||||||
|
if line.startswith("worktree ")
|
||||||
|
]
|
||||||
|
if registered.count(str(path)) != 1:
|
||||||
|
raise AuthorityGitError("WORKTREE_RECOVERY_REGISTRATION_INVALID")
|
||||||
|
self._git(["worktree", "remove", "--force", str(path)])
|
||||||
|
if path.exists() or path.is_symlink():
|
||||||
|
raise AuthorityGitError("WORKTREE_RECOVERY_REMOVE_FAILED")
|
||||||
|
recreated = self.create_detached_worktree(expected_oid, path.name, path.parent)
|
||||||
|
readback = subprocess.run(
|
||||||
|
["git", "-C", str(recreated), "rev-parse", "HEAD"],
|
||||||
|
text=True, capture_output=True, check=False,
|
||||||
|
)
|
||||||
|
detached = subprocess.run(
|
||||||
|
["git", "-C", str(recreated), "symbolic-ref", "-q", "HEAD"],
|
||||||
|
text=True, capture_output=True, check=False,
|
||||||
|
)
|
||||||
|
if readback.returncode != 0 or readback.stdout.strip() != expected_oid or detached.returncode != 1:
|
||||||
|
raise AuthorityGitError("WORKTREE_RECOVERY_READBACK_MISMATCH")
|
||||||
|
return stale_oid
|
||||||
|
|
||||||
|
def create_task_branch_CAS(self, branch: str, expected_base_oid: str, new_oid: str) -> str:
|
||||||
|
if not TASK_BRANCH_RE.fullmatch(branch):
|
||||||
|
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
||||||
|
_oid(expected_base_oid, "expected_base_oid")
|
||||||
|
_oid(new_oid, "new_oid")
|
||||||
|
current = self.read_ref(branch)
|
||||||
if current is not None:
|
if current is not None:
|
||||||
if current == base_oid:
|
if current == new_oid:
|
||||||
return current
|
return new_oid
|
||||||
raise AuthorityGitError("AUTHORITY_BRANCH_EXPECTATION_MISMATCH")
|
raise AuthorityGitError("AUTHORITY_TARGET_BRANCH_ALREADY_EXISTS")
|
||||||
self._git(["update-ref", ref, base_oid, "0" * 40])
|
if not self.ancestry_check(expected_base_oid, new_oid):
|
||||||
readback = self.read_ref(ref)
|
raise AuthorityGitError("NEW_OID_NOT_DESCENDANT")
|
||||||
if readback != base_oid:
|
self._git(["update-ref", branch, new_oid, "0" * 40])
|
||||||
raise AuthorityGitError("AUTHORITY_CAS_READBACK_MISMATCH")
|
readback = self.read_ref(branch)
|
||||||
|
if readback != new_oid:
|
||||||
|
raise AuthorityGitError("AUTHORITY_CREATE_READBACK_MISMATCH")
|
||||||
return readback
|
return readback
|
||||||
|
|
||||||
def advance_task_branch_CAS(self, branch: str, expected_oid: str, new_oid: str) -> str:
|
def advance_task_branch_CAS(self, branch: str, expected_oid: str, new_oid: str) -> str:
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""Canonical fail-closed capability semantics for task eligibility."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
_TASK_ALIASES: dict[str, frozenset[str]] = {
|
||||||
|
"repository_read": frozenset({"repo.read"}),
|
||||||
|
"git.read": frozenset({"repo.read"}),
|
||||||
|
"repo.read": frozenset({"repo.read"}),
|
||||||
|
"test_execution": frozenset({"test.execute"}),
|
||||||
|
"git.test": frozenset({"test.execute"}),
|
||||||
|
"test.execute": frozenset({"test.execute"}),
|
||||||
|
"governed_git_write": frozenset({"governed.git.write.branch", "governed.git.write.file"}),
|
||||||
|
"governed.git.write": frozenset({"governed.git.write.branch", "governed.git.write.file"}),
|
||||||
|
"governed.write.branch": frozenset({"governed.git.write.branch"}),
|
||||||
|
"governed.write.file": frozenset({"governed.git.write.file"}),
|
||||||
|
"shell": frozenset({"runtime.shell"}),
|
||||||
|
"git": frozenset({"runtime.git"}),
|
||||||
|
"python": frozenset({"runtime.python"}),
|
||||||
|
"code.read": frozenset({"legacy.code.read"}),
|
||||||
|
"code.review": frozenset({"legacy.code.review"}),
|
||||||
|
"code.write": frozenset({"legacy.code.write"}),
|
||||||
|
"code.write.patch": frozenset({"legacy.code.write.patch"}),
|
||||||
|
"test.python": frozenset({"legacy.test.python"}),
|
||||||
|
"test.shell.allowlisted": frozenset({"legacy.test.shell.allowlisted"}),
|
||||||
|
"git.commit": frozenset({"legacy.git.commit"}),
|
||||||
|
"git.diff.read": frozenset({"legacy.git.diff.read"}),
|
||||||
|
"git.force_push": frozenset({"legacy.git.force_push"}),
|
||||||
|
"deploy.production": frozenset({"legacy.deploy.production"}),
|
||||||
|
"secret.transform": frozenset({"legacy.secret.transform"}),
|
||||||
|
"secure_file_transformer": frozenset({"legacy.secure_file_transformer"}),
|
||||||
|
"runtime.shadow": frozenset({"legacy.runtime.shadow"}),
|
||||||
|
"review.security": frozenset({"legacy.review.security"}),
|
||||||
|
"review.api-contract": frozenset({"legacy.review.api-contract"}),
|
||||||
|
"gpu.train": frozenset({"legacy.gpu.train"}),
|
||||||
|
"agent.runtime.hermes": frozenset({"agent.runtime.hermes"}),
|
||||||
|
"agent.runtime.openclaw": frozenset({"agent.runtime.openclaw"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
_WORKER_ALIASES: dict[str, frozenset[str]] = {
|
||||||
|
"git.read": frozenset({"repo.read"}),
|
||||||
|
"repo.read": frozenset({"repo.read"}),
|
||||||
|
"repository_read": frozenset({"repo.read"}),
|
||||||
|
"git.test": frozenset({"test.execute"}),
|
||||||
|
"test.execute": frozenset({"test.execute"}),
|
||||||
|
"test_execution": frozenset({"test.execute"}),
|
||||||
|
"governed.write.branch": frozenset({"governed.git.write.branch"}),
|
||||||
|
"governed.git.write.branch": frozenset({"governed.git.write.branch"}),
|
||||||
|
"governed.write.file": frozenset({"governed.git.write.file"}),
|
||||||
|
"governed.git.write.file": frozenset({"governed.git.write.file"}),
|
||||||
|
"shell": frozenset({"runtime.shell"}),
|
||||||
|
"git": frozenset({"runtime.git"}),
|
||||||
|
"python": frozenset({"runtime.python"}),
|
||||||
|
"code.read": frozenset({"legacy.code.read"}),
|
||||||
|
"code.review": frozenset({"legacy.code.review"}),
|
||||||
|
"code.write": frozenset({"legacy.code.write"}),
|
||||||
|
"code.write.patch": frozenset({"legacy.code.write.patch"}),
|
||||||
|
"test.python": frozenset({"legacy.test.python"}),
|
||||||
|
"test.shell.allowlisted": frozenset({"legacy.test.shell.allowlisted"}),
|
||||||
|
"git.commit": frozenset({"legacy.git.commit"}),
|
||||||
|
"git.diff.read": frozenset({"legacy.git.diff.read"}),
|
||||||
|
"git.force_push": frozenset({"legacy.git.force_push"}),
|
||||||
|
"deploy.production": frozenset({"legacy.deploy.production"}),
|
||||||
|
"secret.transform": frozenset({"legacy.secret.transform"}),
|
||||||
|
"secure_file_transformer": frozenset({"legacy.secure_file_transformer"}),
|
||||||
|
"runtime.shadow": frozenset({"legacy.runtime.shadow"}),
|
||||||
|
"review.security": frozenset({"legacy.review.security"}),
|
||||||
|
"review.api-contract": frozenset({"legacy.review.api-contract"}),
|
||||||
|
"gpu.train": frozenset({"legacy.gpu.train"}),
|
||||||
|
"agent.runtime.hermes": frozenset({"agent.runtime.hermes"}),
|
||||||
|
"agent.runtime.openclaw": frozenset({"agent.runtime.openclaw"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
_UNKNOWN_PREFIX = "exact:"
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value: object) -> str:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError("capability must be a string")
|
||||||
|
text = value.strip().lower()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("capability must not be empty")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_required_capabilities(values: Iterable[object]) -> frozenset[str]:
|
||||||
|
"""Map task-facing names to one semantic vocabulary.
|
||||||
|
|
||||||
|
Unknown names remain explicit exact requirements. They are never treated
|
||||||
|
as wildcards or granted by a failed normalization.
|
||||||
|
"""
|
||||||
|
|
||||||
|
result: set[str] = set()
|
||||||
|
for value in values:
|
||||||
|
text = _clean(value)
|
||||||
|
if "*" in text:
|
||||||
|
result.add(_UNKNOWN_PREFIX + text)
|
||||||
|
else:
|
||||||
|
result.update(_TASK_ALIASES.get(text, frozenset({_UNKNOWN_PREFIX + text})))
|
||||||
|
return frozenset(result)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_worker_capabilities(values: Iterable[object]) -> frozenset[str]:
|
||||||
|
"""Map registered worker names to effective semantic capabilities."""
|
||||||
|
|
||||||
|
result: set[str] = set()
|
||||||
|
for value in values:
|
||||||
|
text = _clean(value)
|
||||||
|
if "*" in text:
|
||||||
|
continue
|
||||||
|
mapped = _WORKER_ALIASES.get(text)
|
||||||
|
if mapped is not None:
|
||||||
|
result.update(mapped)
|
||||||
|
if {
|
||||||
|
"governed.git.write.branch",
|
||||||
|
"governed.git.write.file",
|
||||||
|
}.issubset(result):
|
||||||
|
result.add("governed.git.write")
|
||||||
|
return frozenset(result)
|
||||||
|
|
||||||
|
|
||||||
|
def has_capabilities(
|
||||||
|
worker_capabilities: Iterable[object],
|
||||||
|
required_capabilities: Iterable[object],
|
||||||
|
) -> bool:
|
||||||
|
"""Return the single eligibility decision used by poll and claim."""
|
||||||
|
|
||||||
|
required = normalize_required_capabilities(required_capabilities)
|
||||||
|
effective = normalize_worker_capabilities(worker_capabilities)
|
||||||
|
if any(value.startswith(_UNKNOWN_PREFIX) for value in required):
|
||||||
|
return False
|
||||||
|
return required.issubset(effective)
|
||||||
|
|
||||||
|
|
||||||
|
def required_capabilities_subset(
|
||||||
|
granted_requirements: Iterable[object],
|
||||||
|
required_requirements: Iterable[object],
|
||||||
|
) -> bool:
|
||||||
|
"""Check semantic capability containment for delegation constraints."""
|
||||||
|
granted = normalize_required_capabilities(granted_requirements)
|
||||||
|
required = normalize_required_capabilities(required_requirements)
|
||||||
|
if any(value.startswith(_UNKNOWN_PREFIX) for value in granted | required):
|
||||||
|
return False
|
||||||
|
return required.issubset(granted)
|
||||||
|
|
||||||
|
|
||||||
|
def normalized_capability_matrix(
|
||||||
|
worker_capabilities: Iterable[object],
|
||||||
|
required_capabilities: Iterable[object],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Expose non-secret decision evidence for tests and diagnostics."""
|
||||||
|
|
||||||
|
worker_values = tuple(worker_capabilities)
|
||||||
|
required_values = tuple(required_capabilities)
|
||||||
|
worker = normalize_worker_capabilities(worker_values)
|
||||||
|
required = normalize_required_capabilities(required_values)
|
||||||
|
return {
|
||||||
|
"worker_effective": sorted(worker),
|
||||||
|
"task_required": sorted(required),
|
||||||
|
"eligible": has_capabilities(worker_values, required_values),
|
||||||
|
}
|
||||||
@@ -22,7 +22,6 @@ from gitea_tag_publication import (
|
|||||||
TagPublisher,
|
TagPublisher,
|
||||||
redact,
|
redact,
|
||||||
validate_version,
|
validate_version,
|
||||||
validate_gitea_api_url,
|
|
||||||
)
|
)
|
||||||
from orchestrator.action_monitor import ActionMonitor
|
from orchestrator.action_monitor import ActionMonitor
|
||||||
from silent_release_hardening import (
|
from silent_release_hardening import (
|
||||||
@@ -309,7 +308,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
|
raise ValueError(f'{field} does not resolve to the exact approved commit') from exc
|
||||||
|
|
||||||
def existing_tag_target(tag: str) -> str | None:
|
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:
|
try:
|
||||||
result = run_authoritative_git(
|
result = run_authoritative_git(
|
||||||
['rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'],
|
['rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'],
|
||||||
@@ -344,7 +344,7 @@ def required_authoritative_git_dir() -> Path:
|
|||||||
|
|
||||||
def publication_publisher() -> TagPublisher:
|
def publication_publisher() -> TagPublisher:
|
||||||
authoritative_repo = required_authoritative_git_dir()
|
authoritative_repo = required_authoritative_git_dir()
|
||||||
publisher = TagPublisher(
|
return TagPublisher(
|
||||||
WS,
|
WS,
|
||||||
GitTagBackend(WS, authoritative_repo=authoritative_repo),
|
GitTagBackend(WS, authoritative_repo=authoritative_repo),
|
||||||
GiteaTagClient(
|
GiteaTagClient(
|
||||||
@@ -355,10 +355,6 @@ def publication_publisher() -> TagPublisher:
|
|||||||
),
|
),
|
||||||
os.getenv('AIOS_GITOPS_GITEA_REMOTE','gitea'),
|
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):
|
def commit(paths, msg):
|
||||||
ensure_git(); paths = [relpath(p) for p in paths] if paths else ['.']; sh(['add'] + paths, True)
|
ensure_git(); paths = [relpath(p) for p in paths] if paths else ['.']; sh(['add'] + paths, True)
|
||||||
@@ -514,15 +510,7 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.sendj({'error':redact(exc)},413)
|
return self.sendj({'error':redact(exc)},413)
|
||||||
except (AuthoritativeGitError, RuntimeError) as exc:
|
except (AuthoritativeGitError, RuntimeError) as exc:
|
||||||
return self.sendj({'error':redact(exc)},503)
|
return self.sendj({'error':redact(exc)},503)
|
||||||
payload = {'from': frm, 'to': to, 'diff': diff}
|
return self.sendj({'from':frm,'to':to,'diff':diff})
|
||||||
payload.update({
|
|
||||||
key: diff[key]
|
|
||||||
for key in (
|
|
||||||
'from_oid', 'to_oid', 'changed_paths', 'stats',
|
|
||||||
'total_size_bytes', 'truncated', 'patch_limit_bytes',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return self.sendj(payload)
|
|
||||||
if path in ('/api/patch','/api/patches','/api/branches','/patch'):
|
if path in ('/api/patch','/api/patches','/api/branches','/patch'):
|
||||||
snapshot = repository_snapshot(
|
snapshot = repository_snapshot(
|
||||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||||
@@ -634,8 +622,7 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.sendj(
|
return self.sendj(
|
||||||
service.get_capability_status(candidate_id)
|
service.get_capability_status(candidate_id)
|
||||||
)
|
)
|
||||||
result = service.get_release_saga(candidate_id)
|
return self.sendj(service.get_release_saga(candidate_id))
|
||||||
return self.sendj(result, 404 if result.get('status') == 'NOT_FOUND' else 200)
|
|
||||||
if path == '/getRecoveryStatus':
|
if path == '/getRecoveryStatus':
|
||||||
return self.sendj(
|
return self.sendj(
|
||||||
silent_release_service().get_recovery_status()
|
silent_release_service().get_recovery_status()
|
||||||
@@ -786,10 +773,7 @@ class H(BaseHTTPRequestHandler):
|
|||||||
a=self.need('tag:create');
|
a=self.need('tag:create');
|
||||||
if not a: return
|
if not a: return
|
||||||
ver=data.get('version') or data.get('tag')
|
ver=data.get('version') or data.get('tag')
|
||||||
try:
|
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)
|
||||||
validate_version(ver)
|
|
||||||
except ValueError:
|
|
||||||
return self.sendj({'error':'version must match VxRxCx'},400)
|
|
||||||
if not data.get('target_oid') or not data.get('evidence_closure_oid') or not data.get('approval_plan_id'):
|
if not data.get('target_oid') or not data.get('evidence_closure_oid') or not data.get('approval_plan_id'):
|
||||||
return self.sendj({
|
return self.sendj({
|
||||||
'error':'production_tag_evidence_required',
|
'error':'production_tag_evidence_required',
|
||||||
@@ -963,7 +947,6 @@ 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/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():
|
def main():
|
||||||
validate_gitea_api_url(os.getenv('AIOS_GITOPS_GITEA_API_URL', ''))
|
|
||||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||||
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
||||||
if __name__ == '__main__': main()
|
if __name__ == '__main__': main()
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ BRIDGE_OPERATIONS = (
|
|||||||
"getRuntimeStatus",
|
"getRuntimeStatus",
|
||||||
"getGovernedWriteCapabilities",
|
"getGovernedWriteCapabilities",
|
||||||
"createGovernedTask",
|
"createGovernedTask",
|
||||||
"projectOnlineAgentExecution",
|
|
||||||
"acquireWriterLease",
|
"acquireWriterLease",
|
||||||
|
"bootstrapWriterLease",
|
||||||
|
"renewWriterLease",
|
||||||
"commitFileGoverned",
|
"commitFileGoverned",
|
||||||
"commitFilesGoverned",
|
"commitFilesGoverned",
|
||||||
"getGitOpsJob",
|
"getGitOpsJob",
|
||||||
@@ -31,8 +32,9 @@ BRIDGE_OPERATIONS = (
|
|||||||
|
|
||||||
OPERATION_REQUIRED_FIELDS = {
|
OPERATION_REQUIRED_FIELDS = {
|
||||||
"createGovernedTask": ("task_id", "repository", "base_oid", "target_branch", "risk", "description", "idempotency_key"),
|
"createGovernedTask": ("task_id", "repository", "base_oid", "target_branch", "risk", "description", "idempotency_key"),
|
||||||
"projectOnlineAgentExecution": ("task_id", "required_capabilities"),
|
|
||||||
"acquireWriterLease": ("schema", "task_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "lease_seconds"),
|
"acquireWriterLease": ("schema", "task_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "lease_seconds"),
|
||||||
|
"bootstrapWriterLease": ("schema", "task_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "lease_seconds"),
|
||||||
|
"renewWriterLease": ("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"),
|
"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"),
|
"commitFilesGoverned": ("task_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "files", "message"),
|
||||||
"getGitOpsJob": ("job_id",),
|
"getGitOpsJob": ("job_id",),
|
||||||
@@ -48,77 +50,48 @@ OPERATION_REQUIRED_FIELDS = {
|
|||||||
"getControlPlaneSnapshot": (),
|
"getControlPlaneSnapshot": (),
|
||||||
}
|
}
|
||||||
|
|
||||||
_OPERATION_PATHS = {
|
BRIDGE_OPERATION_CONTRACTS = {
|
||||||
"createGovernedTask": "/v2/governed-task",
|
"acquireWriterLease": {
|
||||||
"projectOnlineAgentExecution": "/v2/execution-envelope",
|
|
||||||
"acquireWriterLease": "/v2/writer-lease",
|
|
||||||
"commitFileGoverned": "/v2/commit-file",
|
|
||||||
"commitFilesGoverned": "/v2/commit-files",
|
|
||||||
"getGitOpsJob": "/v2/job",
|
|
||||||
"resumeGitOpsJob": "/v2/job/resume",
|
|
||||||
"reconcileGitOpsJob": "/v2/job/reconcile",
|
|
||||||
"getTagPublicationStatus": "/v2/tag/status",
|
|
||||||
"getReleaseConsistency": "/v2/release/consistency",
|
|
||||||
"runReleaseAcceptance": "/v2/release/acceptance",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def operation_contracts() -> dict[str, dict[str, object]]:
|
|
||||||
"""Return the runtime contract consumed by adapters and schema tooling."""
|
|
||||||
return {
|
|
||||||
operation: {
|
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": _OPERATION_PATHS.get(operation),
|
"path": "/v2/writer-lease",
|
||||||
"required_fields": list(fields),
|
"required_fields": OPERATION_REQUIRED_FIELDS["acquireWriterLease"],
|
||||||
|
"response_fields": (
|
||||||
|
"task_id", "job_id", "generation", "lease_seconds", "lease_expires_at",
|
||||||
|
"create_target_branch", "worktree_mode", "cas_ref_update_required",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"bootstrapWriterLease": {
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/v2/writer-lease/bootstrap",
|
||||||
|
"required_fields": OPERATION_REQUIRED_FIELDS["bootstrapWriterLease"],
|
||||||
|
"response_fields": (
|
||||||
|
"task_id", "job_id", "generation", "lease_seconds", "lease_expires_at",
|
||||||
|
"create_target_branch", "worktree_mode", "cas_ref_update_required",
|
||||||
|
"recovered", "bootstrapped", "runtime_rehydrated",
|
||||||
|
"durable_branch_lease_present", "idempotent",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"renewWriterLease": {
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/v2/writer-lease/renew",
|
||||||
|
"required_fields": OPERATION_REQUIRED_FIELDS["renewWriterLease"],
|
||||||
|
"response_fields": (
|
||||||
|
"task_id", "job_id", "generation", "lease_seconds", "lease_expires_at",
|
||||||
|
"recovered", "bootstrapped", "runtime_rehydrated",
|
||||||
|
"durable_branch_lease_present", "idempotent",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"commitFileGoverned": {
|
||||||
|
"method": "POST", "path": "/v2/commit-file",
|
||||||
|
"required_fields": OPERATION_REQUIRED_FIELDS["commitFileGoverned"],
|
||||||
|
"response_fields": ("job_id", "commit_oid", "commit_visible", "branch_mode", "target_branch", "generation"),
|
||||||
|
},
|
||||||
|
"commitFilesGoverned": {
|
||||||
|
"method": "POST", "path": "/v2/commit-files",
|
||||||
|
"required_fields": OPERATION_REQUIRED_FIELDS["commitFilesGoverned"],
|
||||||
|
"response_fields": ("job_id", "commit_oid", "commit_visible", "branch_mode", "target_branch", "generation"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for operation, fields in OPERATION_REQUIRED_FIELDS.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def render_openapi_contract_fragment() -> str:
|
|
||||||
required = "\n".join(
|
|
||||||
f" - {field}"
|
|
||||||
for field in OPERATION_REQUIRED_FIELDS["commitFilesGoverned"]
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
" /v2/commit-files:\n"
|
|
||||||
" post:\n"
|
|
||||||
" operationId: commitFilesGoverned\n"
|
|
||||||
" summary: Submit one atomic governed commit containing multiple files\n"
|
|
||||||
" requestBody:\n"
|
|
||||||
" required: true\n"
|
|
||||||
" content:\n"
|
|
||||||
" application/json:\n"
|
|
||||||
" schema:\n"
|
|
||||||
" type: object\n"
|
|
||||||
" additionalProperties: false\n"
|
|
||||||
" required:\n"
|
|
||||||
f"{required}\n"
|
|
||||||
" properties:\n"
|
|
||||||
" task_id: {type: string}\n"
|
|
||||||
" job_id: {type: string, description: External durable Job identity for exact completed-replay reconciliation}\n"
|
|
||||||
" source_lease_epoch: {type: integer, minimum: 1}\n"
|
|
||||||
" repository: {type: string, example: aios/aios}\n"
|
|
||||||
" expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}\n"
|
|
||||||
" target_branch: {type: string, pattern: '^agent/[A-Za-z0-9._/-]+$'}\n"
|
|
||||||
" generation: {type: integer, minimum: 1}\n"
|
|
||||||
" idempotency_key: {type: string}\n"
|
|
||||||
" files:\n"
|
|
||||||
" type: array\n"
|
|
||||||
" minItems: 1\n"
|
|
||||||
" items:\n"
|
|
||||||
" type: object\n"
|
|
||||||
" additionalProperties: false\n"
|
|
||||||
" required: [path, content_base64]\n"
|
|
||||||
" properties:\n"
|
|
||||||
" path: {type: string, minLength: 1, maxLength: 512}\n"
|
|
||||||
" content_base64: {type: string}\n"
|
|
||||||
" message: {type: string, minLength: 1, maxLength: 200}\n"
|
|
||||||
" responses:\n"
|
|
||||||
" '200': {description: Governed commit job and verified branch result}\n"
|
|
||||||
" '400': {description: Contract or policy rejection}\n"
|
|
||||||
" '409': {description: Branch CAS or idempotency conflict}\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RuntimeDriftError(RuntimeError):
|
class RuntimeDriftError(RuntimeError):
|
||||||
@@ -196,7 +169,14 @@ class BridgeV2Adapter:
|
|||||||
"legacy_writes_enabled": False,
|
"legacy_writes_enabled": False,
|
||||||
"governed_writes_enabled": True,
|
"governed_writes_enabled": True,
|
||||||
"operations": list(BRIDGE_OPERATIONS),
|
"operations": list(BRIDGE_OPERATIONS),
|
||||||
"operation_contracts": operation_contracts(),
|
"operation_contracts": {
|
||||||
|
operation: {
|
||||||
|
**contract,
|
||||||
|
"required_fields": list(contract["required_fields"]),
|
||||||
|
"response_fields": list(contract["response_fields"]),
|
||||||
|
}
|
||||||
|
for operation, contract in BRIDGE_OPERATION_CONTRACTS.items()
|
||||||
|
},
|
||||||
"force_push": False,
|
"force_push": False,
|
||||||
"main_write": False,
|
"main_write": False,
|
||||||
"tag_write": False,
|
"tag_write": False,
|
||||||
@@ -225,8 +205,9 @@ class BridgeV2Adapter:
|
|||||||
return handler(dict(request))
|
return handler(dict(request))
|
||||||
|
|
||||||
def createGovernedTask(self, request: Mapping[str, Any]) -> Any: return self._call("createGovernedTask", 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 acquireWriterLease(self, request: Mapping[str, Any]) -> Any: return self._call("acquireWriterLease", request)
|
||||||
|
def bootstrapWriterLease(self, request: Mapping[str, Any]) -> Any: return self._call("bootstrapWriterLease", request)
|
||||||
|
def renewWriterLease(self, request: Mapping[str, Any]) -> Any: return self._call("renewWriterLease", request)
|
||||||
def commitFileGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("commitFileGoverned", request)
|
def commitFileGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("commitFileGoverned", request)
|
||||||
def commitFilesGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("commitFilesGoverned", request)
|
def commitFilesGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("commitFilesGoverned", request)
|
||||||
def getGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("getGitOpsJob", request)
|
def getGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("getGitOpsJob", request)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -51,12 +51,14 @@ class AuthorityPort(Protocol):
|
|||||||
def branch_exists(self, branch: str) -> bool: ...
|
def branch_exists(self, branch: str) -> bool: ...
|
||||||
def branch_head(self, branch: str) -> str: ...
|
def branch_head(self, branch: str) -> str: ...
|
||||||
def is_ancestor(self, older: str, newer: str) -> bool: ...
|
def is_ancestor(self, older: str, newer: str) -> bool: ...
|
||||||
|
def create_task_branch_cas(self, branch: str, expected_base: str, new: str) -> str: ...
|
||||||
def compare_and_swap_branch(self, branch: str, expected: str, new: str) -> str: ...
|
def compare_and_swap_branch(self, branch: str, expected: str, new: str) -> str: ...
|
||||||
|
|
||||||
|
|
||||||
class RemotePort(Protocol):
|
class RemotePort(Protocol):
|
||||||
def repository_identity(self) -> str: ...
|
def repository_identity(self) -> str: ...
|
||||||
def push_exact(self, branch: str, expected: str, new: str, *, allow_absent: bool = False) -> None: ...
|
def push_new_exact(self, branch: str, new: str) -> None: ...
|
||||||
|
def push_exact(self, branch: str, expected: str, new: str) -> None: ...
|
||||||
def read_git_ref(self, branch: str) -> str | None: ...
|
def read_git_ref(self, branch: str) -> str | None: ...
|
||||||
def read_api_branch(self, branch: str) -> str | None: ...
|
def read_api_branch(self, branch: str) -> str | None: ...
|
||||||
def commit_visible(self, oid: str) -> bool: ...
|
def commit_visible(self, oid: str) -> bool: ...
|
||||||
@@ -103,6 +105,9 @@ class AuthorityGitAdapter:
|
|||||||
def is_ancestor(self, older: str, newer: str) -> bool:
|
def is_ancestor(self, older: str, newer: str) -> bool:
|
||||||
return self.executor.ancestry_check(older, newer)
|
return self.executor.ancestry_check(older, newer)
|
||||||
|
|
||||||
|
def create_task_branch_cas(self, branch: str, expected_base: str, new: str) -> str:
|
||||||
|
return self.executor.create_task_branch_CAS(self._ref(branch), expected_base, new)
|
||||||
|
|
||||||
def compare_and_swap_branch(self, branch: str, expected: str, new: str) -> str:
|
def compare_and_swap_branch(self, branch: str, expected: str, new: str) -> str:
|
||||||
return self.executor.advance_task_branch_CAS(self._ref(branch), expected, new)
|
return self.executor.advance_task_branch_CAS(self._ref(branch), expected, new)
|
||||||
|
|
||||||
@@ -258,11 +263,19 @@ class GiteaContinuationRemote:
|
|||||||
raise ContinuationExecutionError("GITEA_GIT_READBACK_INVALID")
|
raise ContinuationExecutionError("GITEA_GIT_READBACK_INVALID")
|
||||||
return oid
|
return oid
|
||||||
|
|
||||||
def push_exact(self, branch: str, expected: str, new: str, *, allow_absent: bool = False) -> None:
|
def push_new_exact(self, branch: str, new: str) -> None:
|
||||||
self._require_projected()
|
self._require_projected()
|
||||||
self.repository_identity()
|
self.repository_identity()
|
||||||
current = self.read_git_ref(branch)
|
if self.read_git_ref(branch) is not None:
|
||||||
if current != expected and not (allow_absent and current is None):
|
raise ContinuationExecutionError("GITEA_TARGET_BRANCH_ALREADY_EXISTS")
|
||||||
|
_run(["git", "push", self.remote_name, f"{new}:refs/heads/{branch}"], cwd=self.workspace)
|
||||||
|
if self.read_git_ref(branch) != new:
|
||||||
|
raise ContinuationExecutionError("GITEA_CREATE_READBACK_MISMATCH")
|
||||||
|
|
||||||
|
def push_exact(self, branch: str, expected: str, new: str) -> None:
|
||||||
|
self._require_projected()
|
||||||
|
self.repository_identity()
|
||||||
|
if self.read_git_ref(branch) != expected:
|
||||||
raise ContinuationExecutionError("GITEA_BRANCH_EXPECTATION_MISMATCH")
|
raise ContinuationExecutionError("GITEA_BRANCH_EXPECTATION_MISMATCH")
|
||||||
_run(["git", "push", self.remote_name, f"{new}:refs/heads/{branch}"], cwd=self.workspace)
|
_run(["git", "push", self.remote_name, f"{new}:refs/heads/{branch}"], cwd=self.workspace)
|
||||||
|
|
||||||
@@ -330,16 +343,13 @@ class ContinuationPermissions:
|
|||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ContinuationRuntimeBinding:
|
class ContinuationRuntimeBinding:
|
||||||
request: WriterLeaseRequestV2
|
request: WriterLeaseRequestV2
|
||||||
|
job_id: str
|
||||||
workspace_path: Path
|
workspace_path: Path
|
||||||
lease_not_before: datetime
|
lease_not_before: datetime
|
||||||
lease_expires_at: datetime
|
lease_expires_at: datetime
|
||||||
original_receipt_valid: bool
|
original_receipt_valid: bool
|
||||||
original_grant_consumed: bool
|
original_grant_consumed: bool
|
||||||
permissions: ContinuationPermissions
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -394,13 +404,20 @@ class ContinuationExecutor:
|
|||||||
def observe_generation(self, branch: str, generation: int) -> None:
|
def observe_generation(self, branch: str, generation: int) -> None:
|
||||||
self._observed_generations[branch] = max(generation, self._observed_generations.get(branch, 0))
|
self._observed_generations[branch] = max(generation, self._observed_generations.get(branch, 0))
|
||||||
|
|
||||||
def _max_generation(self, branch: str) -> int:
|
def _max_generation(self, branch: str, *, exclude_job_id: str | None = None) -> int:
|
||||||
observed = self._observed_generations.get(branch, 0)
|
observed = self._observed_generations.get(branch, 0)
|
||||||
with self.controller.store._connect() as connection:
|
with self.controller.store._connect() as connection:
|
||||||
|
if exclude_job_id is None:
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
"SELECT MAX(generation) AS generation FROM branch_leases WHERE branch=?",
|
"SELECT MAX(generation) AS generation FROM branch_leases WHERE branch=?",
|
||||||
(branch,),
|
(branch,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
else:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT MAX(generation) AS generation FROM branch_leases "
|
||||||
|
"WHERE branch=? AND job_id!=?",
|
||||||
|
(branch, exclude_job_id),
|
||||||
|
).fetchone()
|
||||||
durable = int(row["generation"] or 0)
|
durable = int(row["generation"] or 0)
|
||||||
return max(observed, durable)
|
return max(observed, durable)
|
||||||
|
|
||||||
@@ -409,6 +426,7 @@ class ContinuationExecutor:
|
|||||||
return {
|
return {
|
||||||
"schema": WriterLeaseRequestV2.schema,
|
"schema": WriterLeaseRequestV2.schema,
|
||||||
"task_id": request.task_id,
|
"task_id": request.task_id,
|
||||||
|
"job_id": request.job_id,
|
||||||
"repository": request.repository,
|
"repository": request.repository,
|
||||||
"branch_mode": request.branch_mode,
|
"branch_mode": request.branch_mode,
|
||||||
"base_oid": request.base_oid,
|
"base_oid": request.base_oid,
|
||||||
@@ -418,10 +436,21 @@ class ContinuationExecutor:
|
|||||||
"lease_seconds": request.lease_seconds,
|
"lease_seconds": request.lease_seconds,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _validate_preflight(self, binding: ContinuationRuntimeBinding) -> None:
|
def _validate_preflight(self, binding: ContinuationRuntimeBinding, *, job_id: str | None = None) -> None:
|
||||||
request = binding.request
|
request = binding.request
|
||||||
normalized = validate_writer_lease_request_v2(self._request_mapping(request), self.authority)
|
request_mapping = self._request_mapping(request)
|
||||||
if normalized != {**self._request_mapping(request), "create_target_branch": False, "worktree_mode": "detached_at_expected_head", "cas_ref_update_required": True}:
|
normalized = validate_writer_lease_request_v2(request_mapping, self.authority)
|
||||||
|
expected_normalized = {
|
||||||
|
**request_mapping,
|
||||||
|
"create_target_branch": request.branch_mode == "CREATE_NEW",
|
||||||
|
"worktree_mode": (
|
||||||
|
"detached_at_base_oid"
|
||||||
|
if request.branch_mode == "CREATE_NEW"
|
||||||
|
else "detached_at_expected_head"
|
||||||
|
),
|
||||||
|
"cas_ref_update_required": True,
|
||||||
|
}
|
||||||
|
if normalized != expected_normalized:
|
||||||
raise ContinuationExecutionError("CONTINUATION_NORMALIZATION_INVALID")
|
raise ContinuationExecutionError("CONTINUATION_NORMALIZATION_INVALID")
|
||||||
if not binding.original_receipt_valid or not binding.original_grant_consumed:
|
if not binding.original_receipt_valid or not binding.original_grant_consumed:
|
||||||
raise ContinuationExecutionError("ORIGINAL_GRANT_LINEAGE_INVALID")
|
raise ContinuationExecutionError("ORIGINAL_GRANT_LINEAGE_INVALID")
|
||||||
@@ -431,7 +460,7 @@ class ContinuationExecutor:
|
|||||||
raise ContinuationExecutionError("LEASE_NOT_ACTIVE")
|
raise ContinuationExecutionError("LEASE_NOT_ACTIVE")
|
||||||
if now >= binding.lease_expires_at:
|
if now >= binding.lease_expires_at:
|
||||||
raise ContinuationExecutionError("LEASE_EXPIRED")
|
raise ContinuationExecutionError("LEASE_EXPIRED")
|
||||||
if request.generation <= self._max_generation(request.target_branch):
|
if request.generation <= self._max_generation(request.target_branch, exclude_job_id=job_id):
|
||||||
raise ContinuationExecutionError("GENERATION_NOT_MONOTONIC")
|
raise ContinuationExecutionError("GENERATION_NOT_MONOTONIC")
|
||||||
if not self.workspace.is_detached():
|
if not self.workspace.is_detached():
|
||||||
raise ContinuationExecutionError("WORKTREE_NOT_DETACHED")
|
raise ContinuationExecutionError("WORKTREE_NOT_DETACHED")
|
||||||
@@ -455,70 +484,104 @@ class ContinuationExecutor:
|
|||||||
"payload_digest": hashlib.sha256(payload).hexdigest(),
|
"payload_digest": hashlib.sha256(payload).hexdigest(),
|
||||||
"lineage_anchor_oid": request.base_oid,
|
"lineage_anchor_oid": request.base_oid,
|
||||||
"generation": request.generation,
|
"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]:
|
def execute(
|
||||||
payload = commit.payload()
|
self,
|
||||||
submitted = self.controller.submit(self._job_request(binding, commit, payload), payload=payload)
|
binding: ContinuationRuntimeBinding,
|
||||||
current = self.controller.store.read(submitted.job_id)
|
commit: ContinuationCommit,
|
||||||
if JobState(current["state"]) == JobState.COMPLETED:
|
*,
|
||||||
|
before_complete: Callable[[str], None] | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
commit.payload()
|
||||||
|
if not binding.job_id or binding.request.job_id != binding.job_id:
|
||||||
|
raise ContinuationExecutionError("RUNTIME_LINEAGE_MISSING_JOB_ID")
|
||||||
|
current = self.controller.store.read(binding.job_id)
|
||||||
|
current_state = JobState(current["state"])
|
||||||
|
if current_state == JobState.COMPLETED:
|
||||||
return self.controller.store._receipt(current, duplicate=True).as_dict()
|
return self.controller.store._receipt(current, duplicate=True).as_dict()
|
||||||
self._validate_preflight(binding)
|
if current_state == JobState.REMOTE_VERIFIED:
|
||||||
|
receipt = json.loads(str(current["receipt_json"] or "{}"))
|
||||||
|
commit_oid = str(receipt.get("authority_oid") or "")
|
||||||
|
if not OID_RE.fullmatch(commit_oid):
|
||||||
|
raise ContinuationExecutionError("GOVERNED_COMMIT_OID_INVALID")
|
||||||
|
if before_complete is not None:
|
||||||
|
before_complete(commit_oid)
|
||||||
|
return self.controller.advance(
|
||||||
|
binding.job_id,
|
||||||
|
JobState.COMPLETED,
|
||||||
|
side_effect_state="REMOTE_VERIFIED",
|
||||||
|
receipt=receipt,
|
||||||
|
).as_dict()
|
||||||
|
self._validate_preflight(binding, job_id=binding.job_id)
|
||||||
request = binding.request
|
request = binding.request
|
||||||
projected = False
|
projected = False
|
||||||
try:
|
try:
|
||||||
self.credential.project()
|
self.credential.project()
|
||||||
projected = True
|
projected = True
|
||||||
row = self.controller.store.read(submitted.job_id)
|
row = self.controller.store.read(binding.job_id)
|
||||||
if JobState(row["state"]) == JobState.REQUESTED:
|
state = JobState(row["state"])
|
||||||
|
if state == JobState.REQUESTED:
|
||||||
self.controller.advance(
|
self.controller.advance(
|
||||||
submitted.job_id,
|
binding.job_id,
|
||||||
JobState.AUTHORIZED,
|
JobState.AUTHORIZED,
|
||||||
side_effect_state="AUTHORIZED",
|
side_effect_state="AUTHORIZED",
|
||||||
receipt={"task_id": request.task_id, "original_grant_consumed": True},
|
receipt={"task_id": request.task_id, "original_grant_consumed": True},
|
||||||
|
expected_revision=int(row["revision"]),
|
||||||
)
|
)
|
||||||
row = self.controller.store.read(submitted.job_id)
|
row = self.controller.store.read(binding.job_id)
|
||||||
if JobState(row["state"]) == JobState.AUTHORIZED:
|
state = JobState(row["state"])
|
||||||
|
if state == JobState.AUTHORIZED:
|
||||||
self.controller.lease_job(
|
self.controller.lease_job(
|
||||||
submitted.job_id,
|
binding.job_id,
|
||||||
lease_id=f"lease-{submitted.job_id}",
|
lease_id=f"lease-{binding.job_id}",
|
||||||
lineage_anchor_oid=request.base_oid,
|
lineage_anchor_oid=request.base_oid,
|
||||||
current_head_oid=request.expected_branch_oid,
|
current_head_oid=request.expected_branch_oid,
|
||||||
generation=request.generation,
|
generation=request.generation,
|
||||||
lease_expires_at=binding.lease_expires_at.astimezone(timezone.utc).isoformat(),
|
lease_expires_at=binding.lease_expires_at.astimezone(timezone.utc).isoformat(),
|
||||||
expected_revision=int(row["revision"]),
|
expected_revision=int(row["revision"]),
|
||||||
)
|
)
|
||||||
self.controller.advance(submitted.job_id, JobState.WORKTREE_READY, side_effect_state="WORKTREE_READY")
|
row = self.controller.store.read(binding.job_id)
|
||||||
self.controller.advance(submitted.job_id, JobState.EXECUTING, side_effect_state="IN_PROGRESS")
|
state = JobState(row["state"])
|
||||||
|
if state == JobState.LEASED:
|
||||||
|
self.controller.advance(binding.job_id, JobState.WORKTREE_READY,
|
||||||
|
side_effect_state="WORKTREE_READY", expected_revision=int(row["revision"]))
|
||||||
|
row = self.controller.store.read(binding.job_id)
|
||||||
|
state = JobState(row["state"])
|
||||||
|
if state == JobState.WORKTREE_READY:
|
||||||
|
self.controller.advance(binding.job_id, JobState.EXECUTING,
|
||||||
|
side_effect_state="IN_PROGRESS", expected_revision=int(row["revision"]))
|
||||||
|
elif state != JobState.EXECUTING:
|
||||||
|
raise ContinuationExecutionError("JOB_EXECUTION_STATE_INVALID")
|
||||||
new_oid = self.workspace.commit_files(commit.files, commit.message)
|
new_oid = self.workspace.commit_files(commit.files, commit.message)
|
||||||
if not OID_RE.fullmatch(new_oid) or not self.authority.is_ancestor(request.expected_branch_oid, new_oid):
|
if not OID_RE.fullmatch(new_oid) or not self.authority.is_ancestor(request.expected_branch_oid, new_oid):
|
||||||
raise ContinuationExecutionError("NON_FAST_FORWARD_COMMIT")
|
raise ContinuationExecutionError("NON_FAST_FORWARD_COMMIT")
|
||||||
|
if request.branch_mode == "CREATE_NEW":
|
||||||
|
authority_oid = self.authority.create_task_branch_cas(request.target_branch, request.base_oid, new_oid)
|
||||||
|
else:
|
||||||
authority_oid = self.authority.compare_and_swap_branch(request.target_branch, request.expected_branch_oid, new_oid)
|
authority_oid = self.authority.compare_and_swap_branch(request.target_branch, request.expected_branch_oid, new_oid)
|
||||||
if authority_oid != new_oid:
|
if authority_oid != new_oid:
|
||||||
raise ContinuationExecutionError("AUTHORITY_CAS_READBACK_MISMATCH")
|
raise ContinuationExecutionError("AUTHORITY_CAS_READBACK_MISMATCH")
|
||||||
self.controller.advance(
|
self.controller.advance(
|
||||||
submitted.job_id,
|
binding.job_id,
|
||||||
JobState.LOCAL_VERIFIED,
|
JobState.LOCAL_VERIFIED,
|
||||||
side_effect_state="LOCAL_VERIFIED",
|
side_effect_state="LOCAL_VERIFIED",
|
||||||
receipt={"authority_oid": authority_oid, "force_push": False},
|
receipt={"authority_oid": authority_oid, "force_push": False},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
self.controller.advance(submitted.job_id, JobState.REMOTE_SYNCING, side_effect_state="IN_PROGRESS")
|
self.controller.advance(binding.job_id, JobState.REMOTE_SYNCING, side_effect_state="IN_PROGRESS")
|
||||||
self.remote.push_exact(request.target_branch, request.expected_branch_oid, new_oid, allow_absent=binding.remote_branch_create_allowed)
|
if request.branch_mode == "CREATE_NEW":
|
||||||
|
self.remote.push_new_exact(request.target_branch, new_oid)
|
||||||
|
else:
|
||||||
|
self.remote.push_exact(request.target_branch, request.expected_branch_oid, new_oid)
|
||||||
except Exception:
|
except Exception:
|
||||||
return self._remote_pending(submitted.job_id, authority_oid, new_oid)
|
return self._remote_pending(binding.job_id, authority_oid, new_oid)
|
||||||
return self._finish_remote_verification(submitted.job_id, authority_oid, new_oid)
|
return self._finish_remote_verification(
|
||||||
|
binding.job_id,
|
||||||
|
authority_oid,
|
||||||
|
new_oid,
|
||||||
|
before_complete=before_complete,
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
if projected:
|
if projected:
|
||||||
self.credential.revoke()
|
self.credential.revoke()
|
||||||
@@ -529,7 +592,6 @@ class ContinuationExecutor:
|
|||||||
def _remote_pending(self, job_id: str, authority_oid: str, new_oid: str) -> dict[str, object]:
|
def _remote_pending(self, job_id: str, authority_oid: str, new_oid: str) -> dict[str, object]:
|
||||||
receipt = {
|
receipt = {
|
||||||
"authority_oid": authority_oid,
|
"authority_oid": authority_oid,
|
||||||
"commit_oid": new_oid,
|
|
||||||
"desired_remote_oid": new_oid,
|
"desired_remote_oid": new_oid,
|
||||||
"remote_verified": False,
|
"remote_verified": False,
|
||||||
"force_push": False,
|
"force_push": False,
|
||||||
@@ -542,7 +604,14 @@ class ContinuationExecutor:
|
|||||||
)
|
)
|
||||||
return result.as_dict()
|
return result.as_dict()
|
||||||
|
|
||||||
def _finish_remote_verification(self, job_id: str, authority_oid: str, new_oid: str) -> dict[str, object]:
|
def _finish_remote_verification(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
authority_oid: str,
|
||||||
|
new_oid: str,
|
||||||
|
*,
|
||||||
|
before_complete: Callable[[str], None] | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
row = self.controller.store.read(job_id)
|
row = self.controller.store.read(job_id)
|
||||||
branch = row["branch"]
|
branch = row["branch"]
|
||||||
request = json.loads(row["request_json"])["binding"]
|
request = json.loads(row["request_json"])["binding"]
|
||||||
@@ -552,7 +621,6 @@ class ContinuationExecutor:
|
|||||||
return self._remote_pending(job_id, authority_oid, new_oid)
|
return self._remote_pending(job_id, authority_oid, new_oid)
|
||||||
receipt = {
|
receipt = {
|
||||||
"authority_oid": authority_oid,
|
"authority_oid": authority_oid,
|
||||||
"commit_oid": new_oid,
|
|
||||||
"git_remote_oid": git_oid,
|
"git_remote_oid": git_oid,
|
||||||
"api_branch_oid": api_oid,
|
"api_branch_oid": api_oid,
|
||||||
"commit_visible": True,
|
"commit_visible": True,
|
||||||
@@ -560,8 +628,20 @@ class ContinuationExecutor:
|
|||||||
"reconciliation_decision": decision.action.value,
|
"reconciliation_decision": decision.action.value,
|
||||||
"force_push": False,
|
"force_push": False,
|
||||||
}
|
}
|
||||||
self.controller.advance(job_id, JobState.REMOTE_VERIFIED, side_effect_state="REMOTE_VERIFIED", receipt=receipt)
|
self.controller.advance(
|
||||||
return self.controller.advance(job_id, JobState.COMPLETED, side_effect_state="REMOTE_VERIFIED", receipt=receipt).as_dict()
|
job_id,
|
||||||
|
JobState.REMOTE_VERIFIED,
|
||||||
|
side_effect_state="REMOTE_VERIFIED",
|
||||||
|
receipt=receipt,
|
||||||
|
)
|
||||||
|
if before_complete is not None:
|
||||||
|
before_complete(new_oid)
|
||||||
|
return self.controller.advance(
|
||||||
|
job_id,
|
||||||
|
JobState.COMPLETED,
|
||||||
|
side_effect_state="REMOTE_VERIFIED",
|
||||||
|
receipt=receipt,
|
||||||
|
).as_dict()
|
||||||
|
|
||||||
def reconcile(self, job_id: str) -> dict[str, object]:
|
def reconcile(self, job_id: str) -> dict[str, object]:
|
||||||
row = self.controller.store.read(job_id)
|
row = self.controller.store.read(job_id)
|
||||||
@@ -601,14 +681,11 @@ class ContinuationExecutor:
|
|||||||
and self.authority.is_ancestor(git_oid, new_oid)
|
and self.authority.is_ancestor(git_oid, new_oid)
|
||||||
):
|
):
|
||||||
self.controller.advance(job_id, JobState.RECONCILING, side_effect_state="OBSERVATION_FIRST_CHAIN")
|
self.controller.advance(job_id, JobState.RECONCILING, side_effect_state="OBSERVATION_FIRST_CHAIN")
|
||||||
self.remote.push_exact(row["branch"], git_oid, new_oid, allow_absent=bool(request.get("remote_branch_create_allowed")) or request.get("branch_mode") == "CREATE_NEW")
|
self.remote.push_exact(row["branch"], git_oid, new_oid)
|
||||||
return self._finish_remote_verification(job_id, new_oid, new_oid)
|
return self._finish_remote_verification(job_id, new_oid, new_oid)
|
||||||
allow_remote_create = bool(request.get("remote_branch_create_allowed")) or request.get("branch_mode") == "CREATE_NEW" or (git_oid is None and api_oid is None)
|
if authority_oid == new_oid and git_oid == api_oid == expected_oid:
|
||||||
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.controller.advance(job_id, JobState.RECONCILING, side_effect_state="OBSERVATION_FIRST_RETRY")
|
||||||
self.remote.push_exact(row["branch"], expected_oid, new_oid, allow_absent=allow_remote_create)
|
self.remote.push_exact(row["branch"], expected_oid, new_oid)
|
||||||
return self._finish_remote_verification(job_id, new_oid, new_oid)
|
return self._finish_remote_verification(job_id, new_oid, new_oid)
|
||||||
return self.controller.store._receipt(row, duplicate=True).as_dict()
|
return self.controller.store._receipt(row, duplicate=True).as_dict()
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class WriterLeaseRequestV2:
|
|||||||
target_branch: str
|
target_branch: str
|
||||||
generation: int
|
generation: int
|
||||||
lease_seconds: int = 900
|
lease_seconds: int = 900
|
||||||
|
job_id: str = ""
|
||||||
|
|
||||||
schema = "aios.task-writer-lease.v2"
|
schema = "aios.task-writer-lease.v2"
|
||||||
accepted_schemas = frozenset({"aios.task-writer-lease.v2", "aios.task-writer-lease-request.v2"})
|
accepted_schemas = frozenset({"aios.task-writer-lease.v2", "aios.task-writer-lease-request.v2"})
|
||||||
@@ -56,6 +57,8 @@ class WriterLeaseRequestV2:
|
|||||||
def validate(self, probe: BranchProbe) -> None:
|
def validate(self, probe: BranchProbe) -> None:
|
||||||
if not self.task_id or self.repository != "aios/aios":
|
if not self.task_id or self.repository != "aios/aios":
|
||||||
raise ContinuationError("request_identity_invalid")
|
raise ContinuationError("request_identity_invalid")
|
||||||
|
if not self.job_id or not self.job_id.startswith("job-"):
|
||||||
|
raise ContinuationError("job_id_invalid")
|
||||||
if self.branch_mode not in {"CREATE_NEW", "CONTINUE_EXISTING"}:
|
if self.branch_mode not in {"CREATE_NEW", "CONTINUE_EXISTING"}:
|
||||||
raise ContinuationError("branch_mode_invalid")
|
raise ContinuationError("branch_mode_invalid")
|
||||||
_oid(self.base_oid, "base_oid")
|
_oid(self.base_oid, "base_oid")
|
||||||
|
|||||||
@@ -10,16 +10,12 @@ def _copy(value: Mapping[str, Any] | None, keys: tuple[str, ...]) -> dict[str, A
|
|||||||
|
|
||||||
|
|
||||||
def build_control_plane_snapshot(*, target_version: str, runtime: Mapping[str, Any] | None = None, repository: Mapping[str, Any] | None = None, production: Mapping[str, Any] | None = None, task: Mapping[str, Any] | None = None, capabilities: Mapping[str, Any] | None = None, credential_broker: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
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 {
|
return {
|
||||||
"version": {"target_release": target_version},
|
"version": {"target_release": target_version},
|
||||||
"runtime": _copy(runtime, ("bridge_schema_version", "control_plane_release_version", "runtime_release_commit", "runtime_path", "artifact_sha256", "drift_status")),
|
"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")),
|
"repository": _copy(repository, ("identity", "authority_git_dir", "authority_main_oid")),
|
||||||
"production": _copy(production, ("baseline_version", "target_oid", "deployment_state")),
|
"production": _copy(production, ("baseline_version", "target_oid", "deployment_state")),
|
||||||
"task": task_snapshot,
|
"task": _copy(task, ("task_id", "branch", "authority_branch_oid", "gitea_git_oid", "gitea_api_oid", "commit_visible", "generation", "reconciliation_state")),
|
||||||
"capabilities": _copy(capabilities, ("legacy_writes_enabled", "governed_writes_enabled", "force_push", "main_write", "tag_write", "baseline_write", "production_write")),
|
"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")),
|
"credential_broker": _copy(credential_broker, ("status", "provider")),
|
||||||
"secret_material_exposed": False,
|
"secret_material_exposed": False,
|
||||||
|
|||||||
@@ -1,478 +0,0 @@
|
|||||||
"""Minimal convergence contracts for AIOS V2R2C2.
|
|
||||||
|
|
||||||
This module deliberately contains no external side effects. It provides the shared
|
|
||||||
data contracts and pure evaluation rules used by domain controllers. Domain
|
|
||||||
controllers remain authoritative for their own final states.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import asdict, dataclass, field
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from enum import Enum
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
from typing import Any, Iterable, Mapping, Sequence
|
|
||||||
|
|
||||||
|
|
||||||
class ConvergenceContractError(ValueError):
|
|
||||||
"""Raised when an invalid convergence contract is constructed."""
|
|
||||||
|
|
||||||
|
|
||||||
class EvidenceState(str, Enum):
|
|
||||||
PASS = "PASS"
|
|
||||||
FAIL = "FAIL"
|
|
||||||
UNKNOWN = "UNKNOWN"
|
|
||||||
BLOCKED = "BLOCKED"
|
|
||||||
NOT_APPLICABLE = "NOT_APPLICABLE"
|
|
||||||
|
|
||||||
|
|
||||||
class AcceptanceDecision(str, Enum):
|
|
||||||
ACCEPT = "ACCEPT"
|
|
||||||
REJECT = "REJECT"
|
|
||||||
BLOCKED = "BLOCKED"
|
|
||||||
UNKNOWN = "UNKNOWN"
|
|
||||||
|
|
||||||
|
|
||||||
class ObservedHealth(str, Enum):
|
|
||||||
HEALTHY = "HEALTHY"
|
|
||||||
DEGRADED = "DEGRADED"
|
|
||||||
FAILED = "FAILED"
|
|
||||||
UNKNOWN = "UNKNOWN"
|
|
||||||
|
|
||||||
|
|
||||||
class ReconciliationState(str, Enum):
|
|
||||||
PENDING = "PENDING"
|
|
||||||
OBSERVING = "OBSERVING"
|
|
||||||
DIFF_FOUND = "DIFF_FOUND"
|
|
||||||
APPLYING = "APPLYING"
|
|
||||||
VERIFYING = "VERIFYING"
|
|
||||||
CONVERGED = "CONVERGED"
|
|
||||||
DEGRADED = "DEGRADED"
|
|
||||||
BLOCKED = "BLOCKED"
|
|
||||||
|
|
||||||
|
|
||||||
class ContinuationStopReason(str, Enum):
|
|
||||||
CONTEXT_BOUNDARY = "context_boundary"
|
|
||||||
WORKER_RESTART = "worker_restart"
|
|
||||||
HANDOFF = "handoff"
|
|
||||||
AUTHORITY_GATE = "authority_gate"
|
|
||||||
WAIT_EXTERNAL = "wait_external"
|
|
||||||
|
|
||||||
|
|
||||||
def _utc_now() -> str:
|
|
||||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical_json(value: Mapping[str, Any]) -> bytes:
|
|
||||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _sha256(value: Mapping[str, Any]) -> str:
|
|
||||||
return "sha256:" + hashlib.sha256(_canonical_json(value)).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _nonempty(value: object, field_name: str) -> str:
|
|
||||||
text = str(value or "").strip()
|
|
||||||
if not text:
|
|
||||||
raise ConvergenceContractError(f"{field_name}_required")
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _generation(value: object, field_name: str = "generation") -> int:
|
|
||||||
try:
|
|
||||||
parsed = int(value)
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise ConvergenceContractError(f"{field_name}_invalid") from exc
|
|
||||||
if parsed < 1:
|
|
||||||
raise ConvergenceContractError(f"{field_name}_invalid")
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class StateOwnership:
|
|
||||||
object_type: str
|
|
||||||
authoritative_service: str
|
|
||||||
authoritative_store: str
|
|
||||||
final_writer: str | None = None
|
|
||||||
projections: tuple[str, ...] = ()
|
|
||||||
schema_version: str = "aios.state-ownership.v1"
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_nonempty(self.object_type, "object_type")
|
|
||||||
_nonempty(self.authoritative_service, "authoritative_service")
|
|
||||||
_nonempty(self.authoritative_store, "authoritative_store")
|
|
||||||
if self.final_writer is not None:
|
|
||||||
_nonempty(self.final_writer, "final_writer")
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
|
||||||
return asdict(self)
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_STATE_OWNERSHIP: tuple[StateOwnership, ...] = (
|
|
||||||
StateOwnership(
|
|
||||||
object_type="goal",
|
|
||||||
authoritative_service="goal-kernel",
|
|
||||||
authoritative_store="goal-store",
|
|
||||||
final_writer="goal-kernel",
|
|
||||||
projections=("admin-web", "context-pack"),
|
|
||||||
),
|
|
||||||
StateOwnership(
|
|
||||||
object_type="task_run",
|
|
||||||
authoritative_service="task-plane-18940",
|
|
||||||
authoritative_store="durable-task-store",
|
|
||||||
final_writer="task-plane-18940",
|
|
||||||
projections=("admin-web", "context-pack"),
|
|
||||||
),
|
|
||||||
StateOwnership(
|
|
||||||
object_type="release",
|
|
||||||
authoritative_service="release-controller",
|
|
||||||
authoritative_store="release-registry",
|
|
||||||
final_writer="release-controller",
|
|
||||||
projections=("baseline-authority", "admin-web"),
|
|
||||||
),
|
|
||||||
StateOwnership(
|
|
||||||
object_type="git_branch",
|
|
||||||
authoritative_service="authority-git-executor",
|
|
||||||
authoritative_store="authoritative-git",
|
|
||||||
final_writer="authority-git-executor",
|
|
||||||
projections=("gitea",),
|
|
||||||
),
|
|
||||||
StateOwnership(
|
|
||||||
object_type="external_projection",
|
|
||||||
authoritative_service="projection-controller",
|
|
||||||
authoritative_store="projection-store",
|
|
||||||
final_writer="projection-controller",
|
|
||||||
projections=("openapi", "gpt-action", "nginx"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def ownership_index(
|
|
||||||
ownership: Iterable[StateOwnership] = DEFAULT_STATE_OWNERSHIP,
|
|
||||||
) -> dict[str, StateOwnership]:
|
|
||||||
result: dict[str, StateOwnership] = {}
|
|
||||||
for row in ownership:
|
|
||||||
if row.object_type in result:
|
|
||||||
raise ConvergenceContractError(f"duplicate_state_owner:{row.object_type}")
|
|
||||||
result[row.object_type] = row
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class AcceptanceCriterion:
|
|
||||||
criterion_id: str
|
|
||||||
required: bool
|
|
||||||
state: EvidenceState
|
|
||||||
verifier_id: str
|
|
||||||
evidence_refs: tuple[str, ...] = ()
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_nonempty(self.criterion_id, "criterion_id")
|
|
||||||
_nonempty(self.verifier_id, "verifier_id")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class AcceptanceInvariant:
|
|
||||||
invariant_id: str
|
|
||||||
state: EvidenceState
|
|
||||||
evidence_refs: tuple[str, ...] = ()
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_nonempty(self.invariant_id, "invariant_id")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class AcceptanceVector:
|
|
||||||
subject_type: str
|
|
||||||
subject_id: str
|
|
||||||
subject_revision: int
|
|
||||||
criteria: tuple[AcceptanceCriterion, ...]
|
|
||||||
invariants: tuple[AcceptanceInvariant, ...] = ()
|
|
||||||
unresolved_side_effects: tuple[str, ...] = ()
|
|
||||||
unresolved_blockers: tuple[str, ...] = ()
|
|
||||||
runtime_proofs: tuple[str, ...] = ()
|
|
||||||
schema: str = "aios.acceptance-vector.v1"
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_nonempty(self.subject_type, "subject_type")
|
|
||||||
_nonempty(self.subject_id, "subject_id")
|
|
||||||
_generation(self.subject_revision, "subject_revision")
|
|
||||||
ids = [row.criterion_id for row in self.criteria]
|
|
||||||
if len(ids) != len(set(ids)):
|
|
||||||
raise ConvergenceContractError("duplicate_acceptance_criterion")
|
|
||||||
invariant_ids = [row.invariant_id for row in self.invariants]
|
|
||||||
if len(invariant_ids) != len(set(invariant_ids)):
|
|
||||||
raise ConvergenceContractError("duplicate_acceptance_invariant")
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
|
||||||
value = asdict(self)
|
|
||||||
for row in value["criteria"]:
|
|
||||||
row["state"] = row["state"].value
|
|
||||||
for row in value["invariants"]:
|
|
||||||
row["state"] = row["state"].value
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class AcceptanceResult:
|
|
||||||
decision: AcceptanceDecision
|
|
||||||
reasons: tuple[str, ...]
|
|
||||||
vector_hash: str
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"decision": self.decision.value,
|
|
||||||
"reasons": list(self.reasons),
|
|
||||||
"vector_hash": self.vector_hash,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def evaluate_acceptance(vector: AcceptanceVector) -> AcceptanceResult:
|
|
||||||
"""Pure fail-closed acceptance evaluator.
|
|
||||||
|
|
||||||
The evaluator never writes a domain final state. The domain owner consumes this
|
|
||||||
result and remains the only final writer for Goal/Release/Product state.
|
|
||||||
"""
|
|
||||||
reasons: list[str] = []
|
|
||||||
required = [row for row in vector.criteria if row.required]
|
|
||||||
|
|
||||||
for row in required:
|
|
||||||
if row.state == EvidenceState.FAIL:
|
|
||||||
reasons.append(f"criterion_failed:{row.criterion_id}")
|
|
||||||
elif row.state == EvidenceState.BLOCKED:
|
|
||||||
reasons.append(f"criterion_blocked:{row.criterion_id}")
|
|
||||||
elif row.state == EvidenceState.UNKNOWN:
|
|
||||||
reasons.append(f"criterion_unknown:{row.criterion_id}")
|
|
||||||
|
|
||||||
for row in vector.invariants:
|
|
||||||
if row.state == EvidenceState.FAIL:
|
|
||||||
reasons.append(f"invariant_failed:{row.invariant_id}")
|
|
||||||
elif row.state in {EvidenceState.UNKNOWN, EvidenceState.BLOCKED}:
|
|
||||||
reasons.append(f"invariant_unknown:{row.invariant_id}")
|
|
||||||
|
|
||||||
if vector.unresolved_side_effects:
|
|
||||||
reasons.append("unresolved_side_effects")
|
|
||||||
if vector.unresolved_blockers:
|
|
||||||
reasons.append("unresolved_blockers")
|
|
||||||
|
|
||||||
digest = _sha256(vector.as_dict())
|
|
||||||
|
|
||||||
if any(reason.startswith(("criterion_failed:", "invariant_failed:")) for reason in reasons):
|
|
||||||
decision = AcceptanceDecision.REJECT
|
|
||||||
elif any(reason.startswith("criterion_blocked:") for reason in reasons) or vector.unresolved_blockers:
|
|
||||||
decision = AcceptanceDecision.BLOCKED
|
|
||||||
elif reasons:
|
|
||||||
decision = AcceptanceDecision.UNKNOWN
|
|
||||||
else:
|
|
||||||
decision = AcceptanceDecision.ACCEPT
|
|
||||||
|
|
||||||
return AcceptanceResult(decision=decision, reasons=tuple(reasons), vector_hash=digest)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class DesiredState:
|
|
||||||
resource_id: str
|
|
||||||
resource_type: str
|
|
||||||
desired_generation: int
|
|
||||||
desired_spec: Mapping[str, Any]
|
|
||||||
owner_goal_id: str | None = None
|
|
||||||
lifecycle: str = "active"
|
|
||||||
schema: str = "aios.desired-state.v1"
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_nonempty(self.resource_id, "resource_id")
|
|
||||||
_nonempty(self.resource_type, "resource_type")
|
|
||||||
_generation(self.desired_generation, "desired_generation")
|
|
||||||
if self.lifecycle not in {"active", "suspended", "deleted"}:
|
|
||||||
raise ConvergenceContractError("desired_lifecycle_invalid")
|
|
||||||
|
|
||||||
def identity(self) -> tuple[str, int]:
|
|
||||||
return self.resource_id, self.desired_generation
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ObservedState:
|
|
||||||
resource_id: str
|
|
||||||
observed_generation: int | None
|
|
||||||
observed_spec: Mapping[str, Any]
|
|
||||||
health: ObservedHealth
|
|
||||||
source_identity: str
|
|
||||||
observed_at: str
|
|
||||||
evidence_refs: tuple[str, ...] = ()
|
|
||||||
schema: str = "aios.observed-state.v1"
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_nonempty(self.resource_id, "resource_id")
|
|
||||||
_nonempty(self.source_identity, "source_identity")
|
|
||||||
_nonempty(self.observed_at, "observed_at")
|
|
||||||
if self.observed_generation is not None:
|
|
||||||
_generation(self.observed_generation, "observed_generation")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ReconciliationPlan:
|
|
||||||
resource_id: str
|
|
||||||
desired_generation: int
|
|
||||||
state: ReconciliationState
|
|
||||||
diff: Mapping[str, Any]
|
|
||||||
observation_required: bool
|
|
||||||
idempotency_key: str
|
|
||||||
reason: str
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
|
||||||
value = asdict(self)
|
|
||||||
value["state"] = self.state.value
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def plan_reconciliation(
|
|
||||||
desired: DesiredState,
|
|
||||||
observed: ObservedState | None,
|
|
||||||
) -> ReconciliationPlan:
|
|
||||||
"""Return a side-effect-free reconciliation decision.
|
|
||||||
|
|
||||||
UNKNOWN/missing observed state always requires observation and never converges.
|
|
||||||
"""
|
|
||||||
identity = {
|
|
||||||
"resource_id": desired.resource_id,
|
|
||||||
"desired_generation": desired.desired_generation,
|
|
||||||
"desired_spec": desired.desired_spec,
|
|
||||||
}
|
|
||||||
key = _sha256(identity)
|
|
||||||
|
|
||||||
if observed is None:
|
|
||||||
return ReconciliationPlan(
|
|
||||||
desired.resource_id,
|
|
||||||
desired.desired_generation,
|
|
||||||
ReconciliationState.OBSERVING,
|
|
||||||
{"observed": None},
|
|
||||||
True,
|
|
||||||
key,
|
|
||||||
"observed_state_missing",
|
|
||||||
)
|
|
||||||
if observed.resource_id != desired.resource_id:
|
|
||||||
raise ConvergenceContractError("resource_identity_mismatch")
|
|
||||||
if observed.health == ObservedHealth.UNKNOWN:
|
|
||||||
return ReconciliationPlan(
|
|
||||||
desired.resource_id,
|
|
||||||
desired.desired_generation,
|
|
||||||
ReconciliationState.OBSERVING,
|
|
||||||
{"health": observed.health.value},
|
|
||||||
True,
|
|
||||||
key,
|
|
||||||
"observed_state_unknown",
|
|
||||||
)
|
|
||||||
|
|
||||||
same_generation = observed.observed_generation == desired.desired_generation
|
|
||||||
same_spec = dict(observed.observed_spec) == dict(desired.desired_spec)
|
|
||||||
if same_generation and same_spec and observed.health == ObservedHealth.HEALTHY:
|
|
||||||
return ReconciliationPlan(
|
|
||||||
desired.resource_id,
|
|
||||||
desired.desired_generation,
|
|
||||||
ReconciliationState.CONVERGED,
|
|
||||||
{},
|
|
||||||
False,
|
|
||||||
key,
|
|
||||||
"desired_equals_observed",
|
|
||||||
)
|
|
||||||
|
|
||||||
return ReconciliationPlan(
|
|
||||||
desired.resource_id,
|
|
||||||
desired.desired_generation,
|
|
||||||
ReconciliationState.DIFF_FOUND,
|
|
||||||
{
|
|
||||||
"desired_generation": desired.desired_generation,
|
|
||||||
"observed_generation": observed.observed_generation,
|
|
||||||
"desired_spec": dict(desired.desired_spec),
|
|
||||||
"observed_spec": dict(observed.observed_spec),
|
|
||||||
"observed_health": observed.health.value,
|
|
||||||
},
|
|
||||||
False,
|
|
||||||
key,
|
|
||||||
"desired_observed_drift",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class SideEffectReceipt:
|
|
||||||
mutation_id: str
|
|
||||||
external_system: str
|
|
||||||
state: str
|
|
||||||
observed_at: str
|
|
||||||
external_request_id: str | None = None
|
|
||||||
external_object_id: str | None = None
|
|
||||||
observed_result_hash: str | None = None
|
|
||||||
evidence_refs: tuple[str, ...] = ()
|
|
||||||
schema: str = "aios.side-effect-receipt.v1"
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_nonempty(self.mutation_id, "mutation_id")
|
|
||||||
_nonempty(self.external_system, "external_system")
|
|
||||||
_nonempty(self.observed_at, "observed_at")
|
|
||||||
if self.state not in {
|
|
||||||
"confirmed_applied",
|
|
||||||
"confirmed_not_applied",
|
|
||||||
"unknown",
|
|
||||||
}:
|
|
||||||
raise ConvergenceContractError("side_effect_receipt_state_invalid")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ContinuationManifest:
|
|
||||||
trace_id: str
|
|
||||||
goal_id: str
|
|
||||||
goal_revision: int
|
|
||||||
task_id: str
|
|
||||||
run_id: str
|
|
||||||
current_node_id: str
|
|
||||||
acceptance_vector_ref: str
|
|
||||||
next_action: str
|
|
||||||
stop_reason: ContinuationStopReason
|
|
||||||
checkpoint_ref: str | None = None
|
|
||||||
unresolved_blocker_refs: tuple[str, ...] = ()
|
|
||||||
authoritative_decision_refs: tuple[str, ...] = ()
|
|
||||||
resource_refs: tuple[str, ...] = ()
|
|
||||||
artifact_refs: tuple[str, ...] = ()
|
|
||||||
evidence_refs: tuple[str, ...] = ()
|
|
||||||
created_at: str = field(default_factory=_utc_now)
|
|
||||||
schema: str = "aios.continuation-manifest.v1"
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
for name in (
|
|
||||||
"trace_id",
|
|
||||||
"goal_id",
|
|
||||||
"task_id",
|
|
||||||
"run_id",
|
|
||||||
"current_node_id",
|
|
||||||
"acceptance_vector_ref",
|
|
||||||
"next_action",
|
|
||||||
"created_at",
|
|
||||||
):
|
|
||||||
_nonempty(getattr(self, name), name)
|
|
||||||
_generation(self.goal_revision, "goal_revision")
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
|
||||||
value = asdict(self)
|
|
||||||
value["stop_reason"] = self.stop_reason.value
|
|
||||||
return value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def content_hash(self) -> str:
|
|
||||||
return _sha256(self.as_dict())
|
|
||||||
|
|
||||||
|
|
||||||
def validate_state_ownership(
|
|
||||||
object_type: str,
|
|
||||||
writer: str,
|
|
||||||
ownership: Iterable[StateOwnership] = DEFAULT_STATE_OWNERSHIP,
|
|
||||||
*,
|
|
||||||
final_state: bool = False,
|
|
||||||
) -> None:
|
|
||||||
row = ownership_index(ownership).get(object_type)
|
|
||||||
if row is None:
|
|
||||||
raise ConvergenceContractError(f"state_owner_missing:{object_type}")
|
|
||||||
expected = row.final_writer if final_state and row.final_writer else row.authoritative_service
|
|
||||||
if writer != expected:
|
|
||||||
raise ConvergenceContractError(
|
|
||||||
f"STATE_OWNERSHIP_VIOLATION:{object_type}:{writer}:{expected}"
|
|
||||||
)
|
|
||||||
@@ -6,6 +6,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import secrets
|
import secrets
|
||||||
|
import re
|
||||||
from typing import Any, Callable, Mapping
|
from typing import Any, Callable, Mapping
|
||||||
|
|
||||||
|
|
||||||
@@ -21,9 +22,14 @@ class CredentialLease:
|
|||||||
scopes: tuple[str, ...]
|
scopes: tuple[str, ...]
|
||||||
provider: str
|
provider: str
|
||||||
state: str = "ACTIVE"
|
state: str = "ACTIVE"
|
||||||
|
scope_type: str = "system"
|
||||||
|
tenant_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
project_id: str | None = None
|
||||||
|
run_id: str | None = None
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
return {"lease_id": self.lease_id, "task_id": self.task_id, "repository": self.repository, "target_branch": self.target_branch, "generation": self.generation, "issued_at": self.issued_at, "expires_at": self.expires_at, "scopes": list(self.scopes), "provider": self.provider, "state": self.state}
|
return {"lease_id": self.lease_id, "task_id": self.task_id, "repository": self.repository, "target_branch": self.target_branch, "generation": self.generation, "issued_at": self.issued_at, "expires_at": self.expires_at, "scopes": list(self.scopes), "provider": self.provider, "state": self.state, "scope_type": self.scope_type, "tenant_id": self.tenant_id, "user_id": self.user_id, "project_id": self.project_id, "run_id": self.run_id}
|
||||||
|
|
||||||
|
|
||||||
class FileCredentialProvider:
|
class FileCredentialProvider:
|
||||||
@@ -43,11 +49,19 @@ class CredentialBroker:
|
|||||||
self.now = now or (lambda: datetime.now(timezone.utc))
|
self.now = now or (lambda: datetime.now(timezone.utc))
|
||||||
self._leases: dict[str, CredentialLease] = {}
|
self._leases: dict[str, CredentialLease] = {}
|
||||||
|
|
||||||
def issue(self, *, task_id: str, repository: str, target_branch: str, generation: int, scopes: tuple[str, ...] = ("git:push", "gitea:read"), lease_seconds: int = 900) -> dict[str, Any]:
|
def issue(self, *, task_id: str, repository: str, target_branch: str, generation: int, scopes: tuple[str, ...] = ("git:push", "gitea:read"), lease_seconds: int = 900, scope_type: str = "system", tenant_id: str | None = None, user_id: str | None = None, project_id: str | None = None, run_id: str | None = None) -> dict[str, Any]:
|
||||||
if repository != "aios/aios" or not target_branch.startswith("agent/") or generation < 1 or not 1 <= lease_seconds <= 86400:
|
if repository != "aios/aios" or not target_branch.startswith("agent/") or generation < 1 or not 1 <= lease_seconds <= 86400:
|
||||||
raise ValueError("CREDENTIAL_LEASE_BINDING_INVALID")
|
raise ValueError("CREDENTIAL_LEASE_BINDING_INVALID")
|
||||||
|
if scope_type == "tenant":
|
||||||
|
if not tenant_id:
|
||||||
|
raise ValueError("CREDENTIAL_SCOPE_DENY")
|
||||||
|
elif scope_type in {"system", "global"}:
|
||||||
|
if any((tenant_id, user_id, project_id, run_id)):
|
||||||
|
raise ValueError("CREDENTIAL_SCOPE_DENY")
|
||||||
|
else:
|
||||||
|
raise ValueError("CREDENTIAL_SCOPE_DENY")
|
||||||
now = self.now().astimezone(timezone.utc)
|
now = self.now().astimezone(timezone.utc)
|
||||||
lease = CredentialLease("lease-" + secrets.token_hex(16), task_id, repository, target_branch, generation, now.isoformat(), (now + timedelta(seconds=lease_seconds)).isoformat(), tuple(scopes), type(self.provider).__name__)
|
lease = CredentialLease("lease-" + secrets.token_hex(16), task_id, repository, target_branch, generation, now.isoformat(), (now + timedelta(seconds=lease_seconds)).isoformat(), tuple(scopes), type(self.provider).__name__, "ACTIVE", scope_type, tenant_id, user_id, project_id, run_id)
|
||||||
self._leases[lease.lease_id] = lease
|
self._leases[lease.lease_id] = lease
|
||||||
return lease.as_dict()
|
return lease.as_dict()
|
||||||
|
|
||||||
@@ -64,7 +78,39 @@ class CredentialBroker:
|
|||||||
lease = self._get(lease_id)
|
lease = self._get(lease_id)
|
||||||
if lease.state != "ACTIVE":
|
if lease.state != "ACTIVE":
|
||||||
raise RuntimeError("CREDENTIAL_LEASE_NOT_ACTIVE")
|
raise RuntimeError("CREDENTIAL_LEASE_NOT_ACTIVE")
|
||||||
return self.provider.environment(lease)
|
environment = self.provider.environment(lease)
|
||||||
|
for key, value in environment.items():
|
||||||
|
upper = str(key).upper()
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise RuntimeError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||||
|
sensitive = any(marker in upper for marker in ("CREDENTIAL", "TOKEN", "SECRET", "PASSWORD", "PRIVATE_KEY", "API_KEY"))
|
||||||
|
if re.search(r"(?i)bearer\s+\S+|(?:token|secret|password|api[_-]?key)\s*[:=]", value):
|
||||||
|
raise RuntimeError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||||
|
if sensitive and upper.endswith("_FILE"):
|
||||||
|
candidate = Path(value)
|
||||||
|
credential_root = Path("/run/credentials")
|
||||||
|
try:
|
||||||
|
resolved = candidate.resolve(strict=False)
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError("CREDENTIAL_SECRET_EXPOSURE_DENY") from exc
|
||||||
|
if not candidate.is_absolute() or not (resolved == credential_root or credential_root in resolved.parents):
|
||||||
|
raise RuntimeError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||||
|
elif sensitive and upper.endswith("_REFERENCE"):
|
||||||
|
if re.fullmatch(r"(?:systemd-credential|vault|secret-manager):[A-Za-z0-9][A-Za-z0-9._:/#-]{0,255}", value) is None:
|
||||||
|
raise RuntimeError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||||
|
elif sensitive:
|
||||||
|
raise RuntimeError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||||
|
return environment
|
||||||
|
|
||||||
|
def authorize(self, lease_id: str, *, task_id: str, operation: str, scope_type: str = "system", tenant_id: str | None = None, user_id: str | None = None, project_id: str | None = None, run_id: str | None = None) -> Mapping[str, str]:
|
||||||
|
lease = self._get(lease_id)
|
||||||
|
if lease.state != "ACTIVE" or lease.task_id != task_id or operation not in lease.scopes:
|
||||||
|
raise RuntimeError("CREDENTIAL_SCOPE_DENY")
|
||||||
|
requested = (scope_type, tenant_id, user_id, project_id, run_id)
|
||||||
|
actual = (lease.scope_type, lease.tenant_id, lease.user_id, lease.project_id, lease.run_id)
|
||||||
|
if requested != actual:
|
||||||
|
raise RuntimeError("CREDENTIAL_SCOPE_DENY")
|
||||||
|
return self.environment(lease_id)
|
||||||
|
|
||||||
def status(self, lease_id: str) -> dict[str, Any]:
|
def status(self, lease_id: str) -> dict[str, Any]:
|
||||||
return self._get(lease_id).as_dict()
|
return self._get(lease_id).as_dict()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,19 @@ class ServiceUnavailable(JobError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LeaseRenewalResult:
|
||||||
|
"""Durable outcome of one writer-lease recovery attempt."""
|
||||||
|
|
||||||
|
renewed: bool
|
||||||
|
idempotent: bool
|
||||||
|
durable: bool
|
||||||
|
lease_expires_at: str | None
|
||||||
|
|
||||||
|
def __bool__(self) -> bool:
|
||||||
|
return self.renewed or self.idempotent
|
||||||
|
|
||||||
|
|
||||||
class BindingConflict(JobError):
|
class BindingConflict(JobError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -41,8 +54,6 @@ class JobState(str, Enum):
|
|||||||
REMOTE_SYNCING = "REMOTE_SYNCING"
|
REMOTE_SYNCING = "REMOTE_SYNCING"
|
||||||
REMOTE_VERIFIED = "REMOTE_VERIFIED"
|
REMOTE_VERIFIED = "REMOTE_VERIFIED"
|
||||||
COMPLETED = "COMPLETED"
|
COMPLETED = "COMPLETED"
|
||||||
FAILED = "FAILED"
|
|
||||||
CANCELLED = "CANCELLED"
|
|
||||||
RECOVERABLE_FAILURE = "RECOVERABLE_FAILURE"
|
RECOVERABLE_FAILURE = "RECOVERABLE_FAILURE"
|
||||||
RECONCILING = "RECONCILING"
|
RECONCILING = "RECONCILING"
|
||||||
RECONCILIATION_REQUIRED = "RECONCILIATION_REQUIRED"
|
RECONCILIATION_REQUIRED = "RECONCILIATION_REQUIRED"
|
||||||
@@ -61,18 +72,21 @@ _ORDER = {
|
|||||||
JobState.COMPLETED: 8,
|
JobState.COMPLETED: 8,
|
||||||
}
|
}
|
||||||
_RECOVERABLE = {JobState.RECOVERABLE_FAILURE, JobState.RECONCILIATION_REQUIRED}
|
_RECOVERABLE = {JobState.RECOVERABLE_FAILURE, JobState.RECONCILIATION_REQUIRED}
|
||||||
_TERMINAL = {
|
_TERMINAL = {JobState.COMPLETED, JobState.MANUAL_OWNER_REVIEW_REQUIRED}
|
||||||
JobState.COMPLETED,
|
|
||||||
JobState.FAILED,
|
|
||||||
JobState.CANCELLED,
|
|
||||||
JobState.MANUAL_OWNER_REVIEW_REQUIRED,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> str:
|
def utc_now() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_utc(value: str) -> datetime:
|
||||||
|
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise ValueError("LEASE_EXPIRY_INVALID")
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def canonical_json(value: Any) -> bytes:
|
def canonical_json(value: Any) -> bytes:
|
||||||
return (
|
return (
|
||||||
json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n"
|
json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n"
|
||||||
@@ -178,63 +192,6 @@ class JobStore:
|
|||||||
connection.execute("PRAGMA busy_timeout=30000")
|
connection.execute("PRAGMA busy_timeout=30000")
|
||||||
return connection
|
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:
|
def _initialize(self) -> None:
|
||||||
try:
|
try:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
@@ -273,43 +230,9 @@ class JobStore:
|
|||||||
kind TEXT NOT NULL, digest TEXT NOT NULL, payload_json TEXT NOT NULL,
|
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)
|
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');
|
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:
|
except sqlite3.Error as exc:
|
||||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||||
|
|
||||||
@@ -342,227 +265,6 @@ class JobStore:
|
|||||||
raise JobError("JOB_NOT_FOUND")
|
raise JobError("JOB_NOT_FOUND")
|
||||||
return dict(row)
|
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:
|
def _receipt(self, row: Mapping[str, Any], *, duplicate: bool) -> JobReceipt:
|
||||||
state = JobState(row["state"])
|
state = JobState(row["state"])
|
||||||
if state == JobState.COMPLETED:
|
if state == JobState.COMPLETED:
|
||||||
@@ -614,11 +316,6 @@ class GitOpsJobController:
|
|||||||
"check_context",
|
"check_context",
|
||||||
"check_state",
|
"check_state",
|
||||||
"check_description",
|
"check_description",
|
||||||
"external_job_id",
|
|
||||||
"files_digest",
|
|
||||||
"message_digest",
|
|
||||||
"worker_principal",
|
|
||||||
"worker_lineage",
|
|
||||||
)
|
)
|
||||||
for field in optional:
|
for field in optional:
|
||||||
value = request.get(field)
|
value = request.get(field)
|
||||||
@@ -728,13 +425,16 @@ class GitOpsJobController:
|
|||||||
self.store._mirror(result)
|
self.store._mirror(result)
|
||||||
return self.store._receipt(result, duplicate=True)
|
return self.store._receipt(result, duplicate=True)
|
||||||
|
|
||||||
def update_lease(self, lease_id: str, *, expected_revision: int, current_head_oid: str, generation: int, state: str = "ACTIVE") -> None:
|
def update_lease(self, lease_id: str, *, expected_revision: int, current_head_oid: str, generation: int, state: str = "ACTIVE", lease_expires_at: str | None = None) -> None:
|
||||||
if len(current_head_oid) != 40 or generation < 1:
|
if len(current_head_oid) != 40 or generation < 1:
|
||||||
raise JobError("LEASE_BINDING_INVALID")
|
raise JobError("LEASE_BINDING_INVALID")
|
||||||
try:
|
try:
|
||||||
with self.store._connect() as connection:
|
with self.store._connect() as connection:
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
if lease_expires_at is None:
|
||||||
updated = connection.execute("UPDATE branch_leases SET current_head_oid=?,generation=?,state=?,revision=revision+1 WHERE lease_id=? AND revision=?", (current_head_oid, generation, state, lease_id, expected_revision))
|
updated = connection.execute("UPDATE branch_leases SET current_head_oid=?,generation=?,state=?,revision=revision+1 WHERE lease_id=? AND revision=?", (current_head_oid, generation, state, lease_id, expected_revision))
|
||||||
|
else:
|
||||||
|
updated = connection.execute("UPDATE branch_leases SET current_head_oid=?,generation=?,lease_expires_at=?,state=?,revision=revision+1 WHERE lease_id=? AND revision=?", (current_head_oid, generation, lease_expires_at, state, lease_id, expected_revision))
|
||||||
if updated.rowcount != 1:
|
if updated.rowcount != 1:
|
||||||
raise JobError("LEASE_REVISION_CAS_CONFLICT")
|
raise JobError("LEASE_REVISION_CAS_CONFLICT")
|
||||||
connection.commit()
|
connection.commit()
|
||||||
@@ -743,6 +443,94 @@ class GitOpsJobController:
|
|||||||
except sqlite3.Error as exc:
|
except sqlite3.Error as exc:
|
||||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||||
|
|
||||||
|
def renew_lease(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
branch: str,
|
||||||
|
lineage_anchor_oid: str,
|
||||||
|
current_head_oid: str,
|
||||||
|
generation: int,
|
||||||
|
lease_expires_at: str,
|
||||||
|
) -> LeaseRenewalResult:
|
||||||
|
if (
|
||||||
|
not branch
|
||||||
|
or len(lineage_anchor_oid) != 40
|
||||||
|
or len(current_head_oid) != 40
|
||||||
|
or generation < 1
|
||||||
|
or not lease_expires_at.strip()
|
||||||
|
):
|
||||||
|
raise JobError("LEASE_BINDING_INVALID")
|
||||||
|
try:
|
||||||
|
with self.store._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT lease_id, revision, generation, state, lease_expires_at "
|
||||||
|
"FROM branch_leases "
|
||||||
|
"WHERE branch=? AND lineage_anchor_oid=? AND current_head_oid=? "
|
||||||
|
"AND generation=? AND state='ACTIVE' ORDER BY revision DESC",
|
||||||
|
(branch, lineage_anchor_oid, current_head_oid, generation),
|
||||||
|
).fetchall()
|
||||||
|
if not rows:
|
||||||
|
connection.commit()
|
||||||
|
return LeaseRenewalResult(False, False, False, None)
|
||||||
|
if len(rows) != 1:
|
||||||
|
raise JobError("LEASE_BINDING_CONFLICT")
|
||||||
|
row = rows[0]
|
||||||
|
try:
|
||||||
|
current_expires_at = parse_utc(str(row["lease_expires_at"]))
|
||||||
|
requested_expires_at = parse_utc(lease_expires_at)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise JobError("LEASE_EXPIRY_INVALID") from exc
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if requested_expires_at <= now:
|
||||||
|
raise JobError("LEASE_EXPIRY_INVALID")
|
||||||
|
if current_expires_at > now:
|
||||||
|
connection.commit()
|
||||||
|
return LeaseRenewalResult(
|
||||||
|
False,
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
str(row["lease_expires_at"]),
|
||||||
|
)
|
||||||
|
updated = connection.execute(
|
||||||
|
"UPDATE branch_leases SET lease_expires_at=?,revision=revision+1 "
|
||||||
|
"WHERE lease_id=? AND revision=? AND generation=? "
|
||||||
|
"AND state='ACTIVE' AND lease_expires_at=?",
|
||||||
|
(
|
||||||
|
lease_expires_at,
|
||||||
|
row["lease_id"],
|
||||||
|
int(row["revision"]),
|
||||||
|
generation,
|
||||||
|
row["lease_expires_at"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if updated.rowcount != 1:
|
||||||
|
current = connection.execute(
|
||||||
|
"SELECT lease_expires_at, generation, state FROM branch_leases "
|
||||||
|
"WHERE lease_id=?",
|
||||||
|
(row["lease_id"],),
|
||||||
|
).fetchone()
|
||||||
|
if (
|
||||||
|
current is not None
|
||||||
|
and int(current["generation"]) == generation
|
||||||
|
and current["state"] == "ACTIVE"
|
||||||
|
and parse_utc(str(current["lease_expires_at"])) > now
|
||||||
|
):
|
||||||
|
connection.commit()
|
||||||
|
return LeaseRenewalResult(
|
||||||
|
False,
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
str(current["lease_expires_at"]),
|
||||||
|
)
|
||||||
|
raise JobError("LEASE_REVISION_CAS_CONFLICT")
|
||||||
|
connection.commit()
|
||||||
|
return LeaseRenewalResult(True, False, True, lease_expires_at)
|
||||||
|
except JobError:
|
||||||
|
raise
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||||
|
|
||||||
def advance(self, job_id: str, state: JobState, *, side_effect_state: str, receipt: Mapping[str, Any] | None = None, expected_revision: int | None = None) -> JobReceipt:
|
def advance(self, job_id: str, state: JobState, *, side_effect_state: str, receipt: Mapping[str, Any] | None = None, expected_revision: int | None = None) -> JobReceipt:
|
||||||
return self._advance(
|
return self._advance(
|
||||||
job_id,
|
job_id,
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from release_version import validate_release_version
|
|
||||||
|
|
||||||
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
|
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
|
||||||
WORKSPACE = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
|
WORKSPACE = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
|
||||||
DB_PATH = Path(os.getenv('AIOS_GITOPS_DB', str(ROOT / 'runtime' / 'gitops_model.sqlite3')))
|
DB_PATH = Path(os.getenv('AIOS_GITOPS_DB', str(ROOT / 'runtime' / 'gitops_model.sqlite3')))
|
||||||
@@ -220,11 +218,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
a = self.require_write(['owner']);
|
a = self.require_write(['owner']);
|
||||||
if not a: return
|
if not a: return
|
||||||
ver=data.get('version') or data.get('tag')
|
ver=data.get('version') or data.get('tag')
|
||||||
try:
|
if not re.match(r'^V\d+R\d+C\d+(-[a-zA-Z0-9_.-]+)?$', ver or ''): self.send_json({'error':'version must match VxRxCx'},400); return
|
||||||
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]);
|
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])
|
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
|
level='V' if re.match(r'^V[1-9]\d*R0C0', ver) else ('R' if re.match(r'^V\d+R[1-9]\d*C0', ver) else 'C'); exec_sql('INSERT OR REPLACE INTO gitops_versions(version,tag,level,summary,status,created_by,created_at) VALUES(?,?,?,?,?,?,?)',(ver,ver,level,msg,'ready',a['username'],now())); exec_sql('UPDATE gitops_baseline SET active_version=?,active_tag=?,updated_at=? WHERE id=?',(ver,ver,now(),'current')); audit(a['username'],'git_tag',ver,{'level':level}); self.send_json({'status':'tagged','version':ver,'git':out}); return
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import re
|
|||||||
from typing import Any, Mapping
|
from typing import Any, Mapping
|
||||||
|
|
||||||
from aios_gitops_job_controller import GitOpsJobController, JobStore
|
from aios_gitops_job_controller import GitOpsJobController, JobStore
|
||||||
from aios_online_agent_execution import GovernedExecutionBinding, project_execution_envelope
|
|
||||||
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
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}$")
|
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
||||||
@@ -54,46 +53,7 @@ class GovernedTaskBootstrap:
|
|||||||
def _read_existing(self, task_id: str) -> GovernedTaskBinding | None:
|
def _read_existing(self, task_id: str) -> GovernedTaskBinding | None:
|
||||||
path = self._metadata(task_id)
|
path = self._metadata(task_id)
|
||||||
if not path.is_file() or path.is_symlink(): return None
|
if not path.is_file() or path.is_symlink(): return None
|
||||||
|
|
||||||
return GovernedTaskBinding(**json.loads(path.read_text(encoding="utf-8")))
|
return GovernedTaskBinding(**json.loads(path.read_text(encoding="utf-8")))
|
||||||
def get_binding(self, task_id: str) -> GovernedTaskBinding:
|
|
||||||
"""Read the exact durable binding; never synthesize missing lineage."""
|
|
||||||
binding = self._read_existing(str(task_id))
|
|
||||||
if binding is None or not binding.job_id:
|
|
||||||
raise BootstrapError("TASK_EXECUTION_PROJECTION_MISSING")
|
|
||||||
return binding
|
|
||||||
|
|
||||||
def execution_binding(self, binding: GovernedTaskBinding) -> GovernedExecutionBinding:
|
|
||||||
return GovernedExecutionBinding.from_mapping(
|
|
||||||
{
|
|
||||||
"task_id": binding.task_id,
|
|
||||||
"job_id": binding.job_id,
|
|
||||||
"repository": binding.repository,
|
|
||||||
"base_oid": binding.base_oid,
|
|
||||||
"expected_branch_oid": binding.expected_branch_oid,
|
|
||||||
"target_branch": binding.target_branch,
|
|
||||||
"branch_mode": binding.branch_mode,
|
|
||||||
"generation": binding.generation,
|
|
||||||
"idempotency_key": binding.idempotency_key,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def execution_envelope(
|
|
||||||
self,
|
|
||||||
task_id: str,
|
|
||||||
execution_request: Mapping[str, Any] | None = None,
|
|
||||||
*,
|
|
||||||
required_capabilities: tuple[str, ...] = (),
|
|
||||||
expected_job_id: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
binding = self.get_binding(task_id)
|
|
||||||
if expected_job_id and binding.job_id != expected_job_id:
|
|
||||||
raise BootstrapError("JOB_BINDING_CONFLICT")
|
|
||||||
return project_execution_envelope(
|
|
||||||
self.execution_binding(binding),
|
|
||||||
execution_request,
|
|
||||||
required_capabilities=required_capabilities,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _write(self, binding: GovernedTaskBinding) -> None:
|
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); temporary = path.with_suffix(".tmp"); temporary.write_text(json.dumps(asdict(binding), sort_keys=True), encoding="utf-8"); temporary.replace(path)
|
||||||
|
|||||||
@@ -0,0 +1,456 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Disabled-by-default TLS edge for the read-only GPT Online Task ingress.
|
||||||
|
|
||||||
|
The internal AIOS ingress remains loopback-only. This service is a separate edge
|
||||||
|
that may bind non-loopback only when the owner explicitly approves it. It
|
||||||
|
forwards only the tiny read-only ingress API and never proxies the canonical
|
||||||
|
Bridge or Task API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any, Mapping
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
UPSTREAM = "http://127.0.0.1:18945"
|
||||||
|
MAX_BODY = 64 * 1024
|
||||||
|
MAX_RESPONSE = 256 * 1024
|
||||||
|
MAX_CONCURRENT_REQUESTS = 32
|
||||||
|
REQUEST_TIMEOUT_SECONDS = 20
|
||||||
|
RATE_WINDOW_SECONDS = 60
|
||||||
|
RATE_LIMIT_REQUESTS = 60
|
||||||
|
RATE_BURST_REQUESTS = 15
|
||||||
|
RATE_MAX_CLIENTS = 4096
|
||||||
|
_AUDIT_SALT = secrets.token_bytes(32)
|
||||||
|
PUBLIC_APPROVAL_ENV = "AIOS_GPT_EDGE_PUBLIC_BIND_APPROVED"
|
||||||
|
PUBLIC_DOMAIN_ENV = "AIOS_GPT_EDGE_PUBLIC_DOMAIN"
|
||||||
|
TASK_PATH_RE = re.compile(r"^/v1/read-only-tasks(?:/[A-Za-z0-9][A-Za-z0-9._:-]{7,127})?$")
|
||||||
|
|
||||||
|
|
||||||
|
def _client_pseudonym(value: str) -> str:
|
||||||
|
return hashlib.sha256(_AUDIT_SALT + value.encode("utf-8", "replace")).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def audit(event: str, **fields: object) -> None:
|
||||||
|
safe = {
|
||||||
|
"event": event,
|
||||||
|
"ts_unix_ms": int(time.time() * 1000),
|
||||||
|
}
|
||||||
|
for key, value in fields.items():
|
||||||
|
if key in {"authorization", "body", "token", "cookie", "client_ip"}:
|
||||||
|
continue
|
||||||
|
safe[key] = value
|
||||||
|
print(
|
||||||
|
json.dumps(safe, sort_keys=True, separators=(",", ":")),
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EdgeError(RuntimeError):
|
||||||
|
def __init__(self, code: str, status: int = 400) -> None:
|
||||||
|
super().__init__(code)
|
||||||
|
self.code = code
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
|
def canonical(value: object) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def public_bind_allowed(host: str, approval: str) -> str:
|
||||||
|
value = host.strip()
|
||||||
|
if value in {"127.0.0.1", "::1", "localhost"}:
|
||||||
|
return value
|
||||||
|
if approval != "1":
|
||||||
|
raise RuntimeError("GPT_EDGE_PUBLIC_BIND_OWNER_APPROVAL_REQUIRED")
|
||||||
|
if value not in {"0.0.0.0", "::"}:
|
||||||
|
raise RuntimeError("GPT_EDGE_BIND_ADDRESS_INVALID")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def validated_public_domain(value: str) -> str:
|
||||||
|
domain = value.strip().lower()
|
||||||
|
if not domain:
|
||||||
|
return ""
|
||||||
|
if len(domain) > 253 or re.fullmatch(
|
||||||
|
r"(?=.{1,253}\Z)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?",
|
||||||
|
domain,
|
||||||
|
) is None:
|
||||||
|
raise RuntimeError("GPT_EDGE_PUBLIC_DOMAIN_INVALID")
|
||||||
|
return domain
|
||||||
|
|
||||||
|
|
||||||
|
def validated_edge_identity(host: str, approval: str, domain: str) -> tuple[str, str]:
|
||||||
|
bind_host = public_bind_allowed(host, approval)
|
||||||
|
public_domain = validated_public_domain(domain)
|
||||||
|
if bind_host not in {"127.0.0.1", "::1", "localhost"} and not public_domain:
|
||||||
|
raise RuntimeError("GPT_EDGE_PUBLIC_DOMAIN_REQUIRED")
|
||||||
|
return bind_host, public_domain
|
||||||
|
|
||||||
|
|
||||||
|
def validate_tls_file(path: Path, *, key: bool) -> Path:
|
||||||
|
if path.is_symlink() or not path.is_file():
|
||||||
|
raise RuntimeError("GPT_EDGE_TLS_FILE_INVALID")
|
||||||
|
size = path.stat().st_size
|
||||||
|
if size < 16 or size > 256 * 1024:
|
||||||
|
raise RuntimeError("GPT_EDGE_TLS_FILE_INVALID")
|
||||||
|
if key and (path.stat().st_mode & 0o077):
|
||||||
|
raise RuntimeError("GPT_EDGE_TLS_KEY_MODE_INVALID")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def build_tls_context(cert_path: Path, key_path: Path) -> ssl.SSLContext:
|
||||||
|
cert = validate_tls_file(cert_path, key=False)
|
||||||
|
key = validate_tls_file(key_path, key=True)
|
||||||
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
|
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||||
|
context.options |= ssl.OP_NO_COMPRESSION
|
||||||
|
context.load_cert_chain(certfile=str(cert), keyfile=str(key))
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_route(method: str, raw_path: str) -> str:
|
||||||
|
parsed = urlsplit(raw_path)
|
||||||
|
if parsed.query or parsed.fragment:
|
||||||
|
raise EdgeError("QUERY_OR_FRAGMENT_DENY", 400)
|
||||||
|
path = parsed.path
|
||||||
|
if method == "GET" and path in {"/health", "/openapi.json"}:
|
||||||
|
return path
|
||||||
|
if method == "POST" and path == "/v1/read-only-tasks":
|
||||||
|
return path
|
||||||
|
if method == "GET" and TASK_PATH_RE.fullmatch(path):
|
||||||
|
return path
|
||||||
|
raise EdgeError("ROUTE_DENY", 404)
|
||||||
|
|
||||||
|
|
||||||
|
def route_label(method: str, path: str) -> str:
|
||||||
|
if method == "GET" and path == "/health":
|
||||||
|
return "health"
|
||||||
|
if method == "GET" and path == "/openapi.json":
|
||||||
|
return "openapi"
|
||||||
|
if method == "POST" and path == "/v1/read-only-tasks":
|
||||||
|
return "task_create"
|
||||||
|
if method == "GET" and path.startswith("/v1/read-only-tasks/"):
|
||||||
|
return "task_status"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def forwarded_headers(headers: Mapping[str, str], *, body: bool) -> dict[str, str]:
|
||||||
|
result = {"Accept": "application/json"}
|
||||||
|
authorization = headers.get("Authorization")
|
||||||
|
if not isinstance(authorization, str) or not authorization.startswith("Bearer "):
|
||||||
|
raise EdgeError("AUTHORIZATION_REQUIRED", 401)
|
||||||
|
token = authorization[7:]
|
||||||
|
if len(token) < 32 or len(token) > 4096 or any(ch.isspace() for ch in token):
|
||||||
|
raise EdgeError("AUTHORIZATION_INVALID", 401)
|
||||||
|
result["Authorization"] = authorization
|
||||||
|
if body:
|
||||||
|
content_type = headers.get("Content-Type", "")
|
||||||
|
if content_type.split(";", 1)[0].strip().lower() != "application/json":
|
||||||
|
raise EdgeError("CONTENT_TYPE_INVALID", 415)
|
||||||
|
result["Content-Type"] = "application/json"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimiter:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
window_seconds: int = RATE_WINDOW_SECONDS,
|
||||||
|
limit: int = RATE_LIMIT_REQUESTS,
|
||||||
|
burst: int = RATE_BURST_REQUESTS,
|
||||||
|
max_clients: int = RATE_MAX_CLIENTS,
|
||||||
|
now_fn=time.monotonic,
|
||||||
|
) -> None:
|
||||||
|
self.window_seconds = window_seconds
|
||||||
|
self.limit = limit
|
||||||
|
self.burst = burst
|
||||||
|
self.max_clients = max_clients
|
||||||
|
self.now_fn = now_fn
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._events: dict[str, deque[float]] = defaultdict(deque)
|
||||||
|
|
||||||
|
def _prune_stale(self, cutoff: float) -> None:
|
||||||
|
stale = [
|
||||||
|
key
|
||||||
|
for key, events in self._events.items()
|
||||||
|
if not events or events[-1] <= cutoff
|
||||||
|
]
|
||||||
|
for key in stale:
|
||||||
|
self._events.pop(key, None)
|
||||||
|
|
||||||
|
def allowed(self, key: str) -> bool:
|
||||||
|
now = self.now_fn()
|
||||||
|
cutoff = now - self.window_seconds
|
||||||
|
with self._lock:
|
||||||
|
if key not in self._events and len(self._events) >= self.max_clients:
|
||||||
|
self._prune_stale(cutoff)
|
||||||
|
if len(self._events) >= self.max_clients:
|
||||||
|
return False
|
||||||
|
events = self._events[key]
|
||||||
|
while events and events[0] <= cutoff:
|
||||||
|
events.popleft()
|
||||||
|
if len(events) >= self.limit + self.burst:
|
||||||
|
return False
|
||||||
|
events.append(now)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Proxy:
|
||||||
|
upstream: str = UPSTREAM
|
||||||
|
timeout: int = REQUEST_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
def request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
headers: Mapping[str, str],
|
||||||
|
body: bytes | None,
|
||||||
|
) -> tuple[int, bytes, str]:
|
||||||
|
if self.upstream != UPSTREAM:
|
||||||
|
raise EdgeError("UPSTREAM_DRIFT_DENY", 503)
|
||||||
|
req = urllib.request.Request(
|
||||||
|
self.upstream + path,
|
||||||
|
method=method,
|
||||||
|
data=body,
|
||||||
|
headers=dict(headers),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
||||||
|
raw = response.read(MAX_RESPONSE + 1)
|
||||||
|
status = response.status
|
||||||
|
content_type = response.headers.get("Content-Type", "application/json")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raw = exc.read(MAX_RESPONSE + 1)
|
||||||
|
status = exc.code
|
||||||
|
content_type = exc.headers.get("Content-Type", "application/json")
|
||||||
|
except Exception as exc:
|
||||||
|
raise EdgeError("UPSTREAM_UNAVAILABLE", 502) from exc
|
||||||
|
if len(raw) > MAX_RESPONSE:
|
||||||
|
raise EdgeError("UPSTREAM_RESPONSE_TOO_LARGE", 502)
|
||||||
|
if content_type.split(";", 1)[0].strip().lower() != "application/json":
|
||||||
|
raise EdgeError("UPSTREAM_CONTENT_TYPE_INVALID", 502)
|
||||||
|
return status, raw, "application/json"
|
||||||
|
|
||||||
|
|
||||||
|
class EdgeServer(ThreadingHTTPServer):
|
||||||
|
daemon_threads = True
|
||||||
|
request_queue_size = 64
|
||||||
|
allow_reuse_address = True
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
proxy = Proxy()
|
||||||
|
limiter = RateLimiter()
|
||||||
|
concurrency = threading.BoundedSemaphore(MAX_CONCURRENT_REQUESTS)
|
||||||
|
expected_domain = ""
|
||||||
|
|
||||||
|
server_version = "AIOSGPTIngressEdge/1.0"
|
||||||
|
sys_version = ""
|
||||||
|
|
||||||
|
def log_message(self, *_args: Any) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def respond_json(self, value: Mapping[str, Any], status: int) -> None:
|
||||||
|
raw = canonical(value)
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
|
self.send_header("Referrer-Policy", "no-referrer")
|
||||||
|
self.send_header("Content-Security-Policy", "default-src 'none'")
|
||||||
|
self.send_header("Strict-Transport-Security", "max-age=31536000")
|
||||||
|
self.send_header("Content-Length", str(len(raw)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(raw)
|
||||||
|
|
||||||
|
def send_proxy_response(self, status: int, raw: bytes, content_type: str) -> None:
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
|
self.send_header("Referrer-Policy", "no-referrer")
|
||||||
|
self.send_header("Content-Security-Policy", "default-src 'none'")
|
||||||
|
self.send_header("Strict-Transport-Security", "max-age=31536000")
|
||||||
|
self.send_header("Content-Length", str(len(raw)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(raw)
|
||||||
|
|
||||||
|
def client_key(self) -> str:
|
||||||
|
return str(self.client_address[0])
|
||||||
|
|
||||||
|
def validate_host(self) -> None:
|
||||||
|
if not self.expected_domain:
|
||||||
|
return
|
||||||
|
host = self.headers.get("Host", "").split(":", 1)[0].lower()
|
||||||
|
if not hmac.compare_digest(host, self.expected_domain):
|
||||||
|
raise EdgeError("HOST_HEADER_DENY", 421)
|
||||||
|
|
||||||
|
def body(self) -> bytes:
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", "-1"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise EdgeError("CONTENT_LENGTH_INVALID") from exc
|
||||||
|
if length < 1 or length > MAX_BODY:
|
||||||
|
raise EdgeError("REQUEST_SIZE_INVALID", 413)
|
||||||
|
raw = self.rfile.read(length)
|
||||||
|
if len(raw) != length:
|
||||||
|
raise EdgeError("REQUEST_BODY_TRUNCATED", 400)
|
||||||
|
# Validate JSON at the edge so malformed bodies do not consume upstream work.
|
||||||
|
try:
|
||||||
|
value = json.loads(raw.decode("utf-8"))
|
||||||
|
except Exception as exc:
|
||||||
|
raise EdgeError("REQUEST_JSON_INVALID", 400) from exc
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise EdgeError("REQUEST_JSON_OBJECT_REQUIRED", 400)
|
||||||
|
return canonical(value)
|
||||||
|
|
||||||
|
def handle_proxy(self, method: str) -> None:
|
||||||
|
client_id = _client_pseudonym(self.client_key())
|
||||||
|
started = time.monotonic()
|
||||||
|
acquired = self.concurrency.acquire(blocking=False)
|
||||||
|
if not acquired:
|
||||||
|
audit("EDGE_BUSY", client_id=client_id, method=method)
|
||||||
|
self.respond_json({"error": "EDGE_BUSY"}, 503)
|
||||||
|
return
|
||||||
|
label = "unknown"
|
||||||
|
try:
|
||||||
|
self.validate_host()
|
||||||
|
path = allowed_route(method, self.path)
|
||||||
|
label = route_label(method, path)
|
||||||
|
if path == "/health" and method == "GET":
|
||||||
|
self.respond_json(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"service": "aios-gpt-https-edge",
|
||||||
|
"upstream": "loopback-readonly-ingress",
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
audit(
|
||||||
|
"EDGE_REQUEST",
|
||||||
|
client_id=client_id,
|
||||||
|
method=method,
|
||||||
|
route=label,
|
||||||
|
status=200,
|
||||||
|
duration_ms=int((time.monotonic() - started) * 1000),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not self.limiter.allowed(self.client_key()):
|
||||||
|
audit(
|
||||||
|
"EDGE_RATE_LIMIT",
|
||||||
|
client_id=client_id,
|
||||||
|
method=method,
|
||||||
|
route=label,
|
||||||
|
)
|
||||||
|
self.respond_json({"error": "RATE_LIMITED"}, 429)
|
||||||
|
return
|
||||||
|
has_body = method == "POST"
|
||||||
|
headers = forwarded_headers(self.headers, body=has_body)
|
||||||
|
body = self.body() if has_body else None
|
||||||
|
status, raw, content_type = self.proxy.request(
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
self.send_proxy_response(status, raw, content_type)
|
||||||
|
audit(
|
||||||
|
"EDGE_REQUEST",
|
||||||
|
client_id=client_id,
|
||||||
|
method=method,
|
||||||
|
route=label,
|
||||||
|
status=status,
|
||||||
|
duration_ms=int((time.monotonic() - started) * 1000),
|
||||||
|
)
|
||||||
|
except EdgeError as exc:
|
||||||
|
audit(
|
||||||
|
"EDGE_REJECT",
|
||||||
|
client_id=client_id,
|
||||||
|
method=method,
|
||||||
|
route=label,
|
||||||
|
status=exc.status,
|
||||||
|
error=exc.code,
|
||||||
|
)
|
||||||
|
self.respond_json({"error": exc.code}, exc.status)
|
||||||
|
finally:
|
||||||
|
self.concurrency.release()
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
self.handle_proxy("GET")
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
self.handle_proxy("POST")
|
||||||
|
|
||||||
|
def do_PUT(self) -> None:
|
||||||
|
self.respond_json({"error": "METHOD_DENY"}, 405)
|
||||||
|
|
||||||
|
def do_PATCH(self) -> None:
|
||||||
|
self.respond_json({"error": "METHOD_DENY"}, 405)
|
||||||
|
|
||||||
|
def do_DELETE(self) -> None:
|
||||||
|
self.respond_json({"error": "METHOD_DENY"}, 405)
|
||||||
|
|
||||||
|
def do_CONNECT(self) -> None:
|
||||||
|
self.respond_json({"error": "METHOD_DENY"}, 405)
|
||||||
|
|
||||||
|
def do_OPTIONS(self) -> None:
|
||||||
|
self.respond_json({"error": "METHOD_DENY"}, 405)
|
||||||
|
|
||||||
|
|
||||||
|
def serve() -> None:
|
||||||
|
host, public_domain = validated_edge_identity(
|
||||||
|
os.environ.get("AIOS_GPT_EDGE_HOST", "127.0.0.1"),
|
||||||
|
os.environ.get(PUBLIC_APPROVAL_ENV, "0"),
|
||||||
|
os.environ.get(PUBLIC_DOMAIN_ENV, ""),
|
||||||
|
)
|
||||||
|
port = int(os.environ.get("AIOS_GPT_EDGE_PORT", "18443"))
|
||||||
|
if not 1024 <= port <= 65535:
|
||||||
|
raise RuntimeError("GPT_EDGE_PORT_INVALID")
|
||||||
|
Handler.expected_domain = public_domain
|
||||||
|
|
||||||
|
credentials = Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials"))
|
||||||
|
cert_path = Path(
|
||||||
|
os.environ.get(
|
||||||
|
"AIOS_GPT_EDGE_TLS_CERT_FILE",
|
||||||
|
str(credentials / "tls_cert"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
key_path = Path(
|
||||||
|
os.environ.get(
|
||||||
|
"AIOS_GPT_EDGE_TLS_KEY_FILE",
|
||||||
|
str(credentials / "tls_key"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
context = build_tls_context(cert_path, key_path)
|
||||||
|
|
||||||
|
server = EdgeServer((host, port), Handler)
|
||||||
|
server.socket = context.wrap_socket(server.socket, server_side=True)
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
serve()
|
||||||
@@ -0,0 +1,816 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Credential-isolated host-side ingress for externally scheduled read-only Online Tasks.
|
||||||
|
|
||||||
|
This service is intentionally not a public AIOS control plane. It accepts a tiny,
|
||||||
|
authenticated external intent and translates it to the canonical loopback Bridge.
|
||||||
|
Callers cannot select repository coordinates, capabilities, execution action,
|
||||||
|
worker, branch policy, credentials, verification decisions, or write permissions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
SCHEMA = "aios.gpt-online-task-ingress.request.v1"
|
||||||
|
RESPONSE_SCHEMA = "aios.gpt-online-task-ingress.response.v1"
|
||||||
|
OPENAPI_VERSION = "3.1.0"
|
||||||
|
|
||||||
|
REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$")
|
||||||
|
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
||||||
|
JOB_ID_RE = re.compile(r"^job-[0-9a-f]{32}$")
|
||||||
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
|
MAX_BODY = 64 * 1024
|
||||||
|
MAX_GOAL = 12_000
|
||||||
|
MAX_INSTRUCTIONS = 24_000
|
||||||
|
MAX_CRITERIA = 16
|
||||||
|
MAX_CRITERION_TEXT = 1_000
|
||||||
|
|
||||||
|
READONLY_CAPABILITIES = ("git.read", "git.test")
|
||||||
|
REPOSITORY = "aios/aios"
|
||||||
|
|
||||||
|
|
||||||
|
class IngressError(RuntimeError):
|
||||||
|
def __init__(self, code: str, status: int = 400) -> None:
|
||||||
|
super().__init__(code)
|
||||||
|
self.code = code
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
|
def canonical(value: object) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def digest(value: object) -> str:
|
||||||
|
return hashlib.sha256(canonical(value)).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def load_secret(path: Path, *, code: str) -> str:
|
||||||
|
if path.is_symlink() or not path.is_file():
|
||||||
|
raise RuntimeError(code + "_UNAVAILABLE")
|
||||||
|
value = path.read_text(encoding="utf-8").strip()
|
||||||
|
if len(value) < 32 or "\n" in value or "\r" in value:
|
||||||
|
raise RuntimeError(code + "_INVALID")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def normalized_request(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
allowed = {
|
||||||
|
"schema",
|
||||||
|
"request_id",
|
||||||
|
"goal",
|
||||||
|
"instructions",
|
||||||
|
"success_criteria",
|
||||||
|
"constraints",
|
||||||
|
}
|
||||||
|
if set(value) - allowed:
|
||||||
|
raise IngressError("UNKNOWN_FIELDS")
|
||||||
|
if value.get("schema") != SCHEMA:
|
||||||
|
raise IngressError("SCHEMA_INVALID")
|
||||||
|
|
||||||
|
request_id = value.get("request_id")
|
||||||
|
if not isinstance(request_id, str) or REQUEST_ID_RE.fullmatch(request_id) is None:
|
||||||
|
raise IngressError("REQUEST_ID_INVALID")
|
||||||
|
|
||||||
|
goal = value.get("goal")
|
||||||
|
if not isinstance(goal, str) or not goal.strip() or len(goal) > MAX_GOAL:
|
||||||
|
raise IngressError("GOAL_INVALID")
|
||||||
|
instructions = value.get("instructions", "")
|
||||||
|
if not isinstance(instructions, str) or len(instructions) > MAX_INSTRUCTIONS:
|
||||||
|
raise IngressError("INSTRUCTIONS_INVALID")
|
||||||
|
|
||||||
|
criteria = value.get("success_criteria") or []
|
||||||
|
if not isinstance(criteria, list) or len(criteria) > MAX_CRITERIA:
|
||||||
|
raise IngressError("SUCCESS_CRITERIA_INVALID")
|
||||||
|
normalized_criteria = []
|
||||||
|
for item in criteria:
|
||||||
|
if not isinstance(item, Mapping) or set(item) != {"id", "description"}:
|
||||||
|
raise IngressError("SUCCESS_CRITERIA_INVALID")
|
||||||
|
criterion_id = item.get("id")
|
||||||
|
description = item.get("description")
|
||||||
|
if (
|
||||||
|
not isinstance(criterion_id, str)
|
||||||
|
or not criterion_id.strip()
|
||||||
|
or len(criterion_id) > 120
|
||||||
|
or not isinstance(description, str)
|
||||||
|
or not description.strip()
|
||||||
|
or len(description) > MAX_CRITERION_TEXT
|
||||||
|
):
|
||||||
|
raise IngressError("SUCCESS_CRITERIA_INVALID")
|
||||||
|
normalized_criteria.append(
|
||||||
|
{"id": criterion_id.strip(), "description": description.strip()}
|
||||||
|
)
|
||||||
|
|
||||||
|
constraints = value.get("constraints") or {}
|
||||||
|
if not isinstance(constraints, Mapping):
|
||||||
|
raise IngressError("CONSTRAINTS_INVALID")
|
||||||
|
allowed_constraints = {
|
||||||
|
"max_files",
|
||||||
|
"focus_paths",
|
||||||
|
"notes",
|
||||||
|
}
|
||||||
|
if set(constraints) - allowed_constraints:
|
||||||
|
raise IngressError("CONSTRAINTS_FIELDS_INVALID")
|
||||||
|
normalized_constraints: dict[str, Any] = {}
|
||||||
|
if "max_files" in constraints:
|
||||||
|
max_files = constraints["max_files"]
|
||||||
|
if (
|
||||||
|
not isinstance(max_files, int)
|
||||||
|
or isinstance(max_files, bool)
|
||||||
|
or not 1 <= max_files <= 500
|
||||||
|
):
|
||||||
|
raise IngressError("MAX_FILES_INVALID")
|
||||||
|
normalized_constraints["max_files"] = max_files
|
||||||
|
if "focus_paths" in constraints:
|
||||||
|
paths = constraints["focus_paths"]
|
||||||
|
if (
|
||||||
|
not isinstance(paths, list)
|
||||||
|
or len(paths) > 32
|
||||||
|
or not all(
|
||||||
|
isinstance(path, str)
|
||||||
|
and path
|
||||||
|
and len(path) <= 512
|
||||||
|
and not path.startswith("/")
|
||||||
|
and ".." not in Path(path).parts
|
||||||
|
for path in paths
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise IngressError("FOCUS_PATHS_INVALID")
|
||||||
|
normalized_constraints["focus_paths"] = paths
|
||||||
|
if "notes" in constraints:
|
||||||
|
notes = constraints["notes"]
|
||||||
|
if not isinstance(notes, str) or len(notes) > 4_000:
|
||||||
|
raise IngressError("CONSTRAINT_NOTES_INVALID")
|
||||||
|
normalized_constraints["notes"] = notes
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schema": SCHEMA,
|
||||||
|
"request_id": request_id,
|
||||||
|
"goal": goal.strip(),
|
||||||
|
"instructions": instructions,
|
||||||
|
"success_criteria": normalized_criteria,
|
||||||
|
"constraints": normalized_constraints,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validated_bind_host(value: str) -> str:
|
||||||
|
host = value.strip()
|
||||||
|
if host not in {"127.0.0.1", "::1", "localhost"}:
|
||||||
|
raise RuntimeError("GPT_INGRESS_NON_LOOPBACK_BIND_DENY")
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
def task_id_for(request_id: str) -> str:
|
||||||
|
suffix = hashlib.sha256(request_id.encode("utf-8")).hexdigest()[:24]
|
||||||
|
return "gpt-readonly-" + suffix
|
||||||
|
|
||||||
|
|
||||||
|
def branch_for(task_id: str) -> str:
|
||||||
|
return "agent/external-gpt-readonly/" + task_id[-24:]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Upstream:
|
||||||
|
bridge_base: str
|
||||||
|
task_base: str
|
||||||
|
control_token: str
|
||||||
|
timeout: int = 20
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
self,
|
||||||
|
base: str,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
body: Mapping[str, Any] | None = None,
|
||||||
|
) -> tuple[int, dict[str, Any]]:
|
||||||
|
raw_body = None if body is None else canonical(body)
|
||||||
|
headers = {
|
||||||
|
"Authorization": "Bearer " + self.control_token,
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
if raw_body is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(
|
||||||
|
base.rstrip("/") + path,
|
||||||
|
method=method,
|
||||||
|
data=raw_body,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
||||||
|
raw = response.read()
|
||||||
|
status = response.status
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raw = exc.read()
|
||||||
|
status = exc.code
|
||||||
|
except Exception as exc:
|
||||||
|
raise IngressError("UPSTREAM_TRANSPORT_FAILED", 503) from exc
|
||||||
|
try:
|
||||||
|
value = json.loads(raw.decode("utf-8"))
|
||||||
|
except Exception as exc:
|
||||||
|
raise IngressError("UPSTREAM_RESPONSE_INVALID", 502) from exc
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise IngressError("UPSTREAM_RESPONSE_INVALID", 502)
|
||||||
|
return status, dict(value)
|
||||||
|
|
||||||
|
def control_snapshot(self) -> dict[str, Any]:
|
||||||
|
status, value = self._request(
|
||||||
|
self.bridge_base,
|
||||||
|
"GET",
|
||||||
|
"/v2/control-plane-snapshot",
|
||||||
|
)
|
||||||
|
if status != 200:
|
||||||
|
raise IngressError("CONTROL_SNAPSHOT_UNAVAILABLE", 503)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def create_governed_task(
|
||||||
|
self,
|
||||||
|
body: Mapping[str, Any],
|
||||||
|
) -> tuple[int, dict[str, Any]]:
|
||||||
|
return self._request(
|
||||||
|
self.bridge_base,
|
||||||
|
"POST",
|
||||||
|
"/v2/governed-task",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
|
||||||
|
def execution_envelope(
|
||||||
|
self,
|
||||||
|
body: Mapping[str, Any],
|
||||||
|
) -> tuple[int, dict[str, Any]]:
|
||||||
|
return self._request(
|
||||||
|
self.bridge_base,
|
||||||
|
"POST",
|
||||||
|
"/v2/execution-envelope",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_task(self, task_id: str) -> tuple[int, dict[str, Any]]:
|
||||||
|
return self._request(
|
||||||
|
self.task_base,
|
||||||
|
"GET",
|
||||||
|
"/v1/tasks/" + task_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Store:
|
||||||
|
def __init__(self, path: Path) -> None:
|
||||||
|
self.path = Path(path)
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self._connect() as con:
|
||||||
|
con.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS external_task_requests(
|
||||||
|
request_id TEXT PRIMARY KEY,
|
||||||
|
request_sha256 TEXT NOT NULL,
|
||||||
|
task_id TEXT NOT NULL UNIQUE,
|
||||||
|
job_id TEXT,
|
||||||
|
base_oid TEXT,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
response_json TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
con.commit()
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
con = sqlite3.connect(self.path, timeout=30)
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
con.execute("PRAGMA busy_timeout=30000")
|
||||||
|
return con
|
||||||
|
|
||||||
|
def get(self, request_id: str) -> sqlite3.Row | None:
|
||||||
|
with self._connect() as con:
|
||||||
|
return con.execute(
|
||||||
|
"SELECT * FROM external_task_requests WHERE request_id=?",
|
||||||
|
(request_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
def reserve(self, request: Mapping[str, Any], task_id: str) -> sqlite3.Row:
|
||||||
|
request_sha = digest(request)
|
||||||
|
timestamp = now()
|
||||||
|
with self._lock, self._connect() as con:
|
||||||
|
con.execute("BEGIN IMMEDIATE")
|
||||||
|
row = con.execute(
|
||||||
|
"SELECT * FROM external_task_requests WHERE request_id=?",
|
||||||
|
(request["request_id"],),
|
||||||
|
).fetchone()
|
||||||
|
if row is not None:
|
||||||
|
if row["request_sha256"] != request_sha:
|
||||||
|
raise IngressError("IDEMPOTENCY_CONFLICT", 409)
|
||||||
|
con.commit()
|
||||||
|
return row
|
||||||
|
con.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO external_task_requests(
|
||||||
|
request_id,request_sha256,task_id,state,created_at,updated_at
|
||||||
|
) VALUES(?,?,?,?,?,?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
request["request_id"],
|
||||||
|
request_sha,
|
||||||
|
task_id,
|
||||||
|
"PENDING",
|
||||||
|
timestamp,
|
||||||
|
timestamp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
con.commit()
|
||||||
|
row = self.get(request["request_id"])
|
||||||
|
assert row is not None
|
||||||
|
return row
|
||||||
|
|
||||||
|
def bound(
|
||||||
|
self,
|
||||||
|
request_id: str,
|
||||||
|
*,
|
||||||
|
job_id: str,
|
||||||
|
base_oid: str,
|
||||||
|
response: Mapping[str, Any],
|
||||||
|
) -> None:
|
||||||
|
with self._lock, self._connect() as con:
|
||||||
|
con.execute(
|
||||||
|
"""
|
||||||
|
UPDATE external_task_requests
|
||||||
|
SET job_id=?,base_oid=?,state='BOUND',response_json=?,updated_at=?
|
||||||
|
WHERE request_id=?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
job_id,
|
||||||
|
base_oid,
|
||||||
|
json.dumps(response, sort_keys=True, separators=(",", ":")),
|
||||||
|
now(),
|
||||||
|
request_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
con.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class IngressService:
|
||||||
|
def __init__(self, upstream: Upstream, store: Store) -> None:
|
||||||
|
self.upstream = upstream
|
||||||
|
self.store = store
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _main_oid(snapshot: Mapping[str, Any]) -> str:
|
||||||
|
candidates = [
|
||||||
|
snapshot.get("authoritative_head_oid"),
|
||||||
|
snapshot.get("head"),
|
||||||
|
(snapshot.get("aggregate") or {}).get("authoritative_head_oid")
|
||||||
|
if isinstance(snapshot.get("aggregate"), Mapping)
|
||||||
|
else None,
|
||||||
|
]
|
||||||
|
for candidate in candidates:
|
||||||
|
if isinstance(candidate, str) and OID_RE.fullmatch(candidate):
|
||||||
|
return candidate
|
||||||
|
raise IngressError("AUTHORITATIVE_HEAD_UNAVAILABLE", 503)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _public_response(
|
||||||
|
request_id: str,
|
||||||
|
task_id: str,
|
||||||
|
job_id: str,
|
||||||
|
state: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema": RESPONSE_SCHEMA,
|
||||||
|
"request_id": request_id,
|
||||||
|
"task_id": task_id,
|
||||||
|
"job_id": job_id,
|
||||||
|
"state": state,
|
||||||
|
"execution_mode": "source_capture",
|
||||||
|
"capabilities": list(READONLY_CAPABILITIES),
|
||||||
|
"write_access": False,
|
||||||
|
"official_verification_required": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _recover_existing(
|
||||||
|
self,
|
||||||
|
request: Mapping[str, Any],
|
||||||
|
row: sqlite3.Row,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
task_id = str(row["task_id"])
|
||||||
|
status, task = self.upstream.get_task(task_id)
|
||||||
|
if status == 404:
|
||||||
|
return None
|
||||||
|
if status != 200:
|
||||||
|
raise IngressError("TASK_RECOVERY_FAILED", 503)
|
||||||
|
metadata = task.get("metadata")
|
||||||
|
execution = (
|
||||||
|
metadata.get("execution")
|
||||||
|
if isinstance(metadata, Mapping)
|
||||||
|
and isinstance(metadata.get("execution"), Mapping)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
job_id = str(execution.get("job_id") or row["job_id"] or "")
|
||||||
|
base_oid = str(execution.get("base_oid") or row["base_oid"] or "")
|
||||||
|
if (
|
||||||
|
task.get("task_id") != task_id
|
||||||
|
or not JOB_ID_RE.fullmatch(job_id)
|
||||||
|
or not OID_RE.fullmatch(base_oid)
|
||||||
|
or execution.get("action") != "source_capture"
|
||||||
|
or sorted(execution.get("capability_scope") or [])
|
||||||
|
!= sorted(READONLY_CAPABILITIES)
|
||||||
|
or (execution.get("governance") or {}).get("writer_lease") is not False
|
||||||
|
):
|
||||||
|
raise IngressError("TASK_RECOVERY_BINDING_INVALID", 409)
|
||||||
|
response = self._public_response(
|
||||||
|
request["request_id"],
|
||||||
|
task_id,
|
||||||
|
job_id,
|
||||||
|
str(task.get("status") or "BOUND"),
|
||||||
|
)
|
||||||
|
self.store.bound(
|
||||||
|
request["request_id"],
|
||||||
|
job_id=job_id,
|
||||||
|
base_oid=base_oid,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def create(self, raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
request = normalized_request(raw)
|
||||||
|
task_id = task_id_for(request["request_id"])
|
||||||
|
row = self.store.reserve(request, task_id)
|
||||||
|
if row["state"] == "BOUND" and row["response_json"]:
|
||||||
|
return json.loads(row["response_json"])
|
||||||
|
|
||||||
|
recovered = self._recover_existing(request, row)
|
||||||
|
if recovered is not None:
|
||||||
|
return recovered
|
||||||
|
|
||||||
|
snapshot = self.upstream.control_snapshot()
|
||||||
|
base_oid = self._main_oid(snapshot)
|
||||||
|
target_branch = branch_for(task_id)
|
||||||
|
binding_key = "gpt-readonly-binding-" + digest(
|
||||||
|
{"request_id": request["request_id"], "base_oid": base_oid}
|
||||||
|
)[:24]
|
||||||
|
constraints = {
|
||||||
|
**request["constraints"],
|
||||||
|
"repository_mutation": "forbidden",
|
||||||
|
"control_plane_side_effects": "forbidden",
|
||||||
|
"credential_access": "forbidden",
|
||||||
|
}
|
||||||
|
body = {
|
||||||
|
"task_id": task_id,
|
||||||
|
"repository": REPOSITORY,
|
||||||
|
"base_oid": base_oid,
|
||||||
|
"expected_branch_oid": base_oid,
|
||||||
|
"target_branch": target_branch,
|
||||||
|
"branch_mode": "CREATE_NEW",
|
||||||
|
"generation": 1,
|
||||||
|
"risk": "low",
|
||||||
|
"description": "Externally requested read-only governed Online Task.",
|
||||||
|
"idempotency_key": binding_key,
|
||||||
|
"priority": 0,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"require_online_task_projection": True,
|
||||||
|
"online_task": {
|
||||||
|
"goal": request["goal"],
|
||||||
|
"instructions": request["instructions"],
|
||||||
|
"success_criteria": request["success_criteria"],
|
||||||
|
"constraints": constraints,
|
||||||
|
"required_capabilities": list(READONLY_CAPABILITIES),
|
||||||
|
"priority": 0,
|
||||||
|
"max_attempts": 3,
|
||||||
|
},
|
||||||
|
"execution": {
|
||||||
|
"action": "source_capture",
|
||||||
|
"adapter": "source_capture",
|
||||||
|
"capability_scope": list(READONLY_CAPABILITIES),
|
||||||
|
"source_signals": [
|
||||||
|
"external read-only source analysis request",
|
||||||
|
"no repository mutation no credential access no control-plane side effects",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
status, value = self.upstream.create_governed_task(body)
|
||||||
|
if status not in {200, 201, 202}:
|
||||||
|
if status == 409:
|
||||||
|
recovered = self._recover_existing(request, row)
|
||||||
|
if recovered is not None:
|
||||||
|
return recovered
|
||||||
|
raise IngressError(
|
||||||
|
"GOVERNED_TASK_CREATE_FAILED",
|
||||||
|
409 if status == 409 else 503,
|
||||||
|
)
|
||||||
|
job_id = str(value.get("job_id") or "")
|
||||||
|
if value.get("task_id") != task_id or JOB_ID_RE.fullmatch(job_id) is None:
|
||||||
|
raise IngressError("GOVERNED_TASK_RESPONSE_INVALID", 502)
|
||||||
|
|
||||||
|
envelope_status, envelope = self.upstream.execution_envelope(
|
||||||
|
{
|
||||||
|
"task_id": task_id,
|
||||||
|
"job_id": job_id,
|
||||||
|
"required_capabilities": list(READONLY_CAPABILITIES),
|
||||||
|
"execution": body["execution"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if envelope_status != 200:
|
||||||
|
raise IngressError("EXECUTION_PROJECTION_FAILED", 503)
|
||||||
|
if (
|
||||||
|
envelope.get("task_id") != task_id
|
||||||
|
or envelope.get("job_id") != job_id
|
||||||
|
or envelope.get("base_oid") != base_oid
|
||||||
|
or envelope.get("action") != "source_capture"
|
||||||
|
or sorted(envelope.get("capability_scope") or [])
|
||||||
|
!= sorted(READONLY_CAPABILITIES)
|
||||||
|
or (envelope.get("governance") or {}).get("writer_lease") is not False
|
||||||
|
):
|
||||||
|
raise IngressError("EXECUTION_PROJECTION_INVALID", 502)
|
||||||
|
|
||||||
|
response = self._public_response(
|
||||||
|
request["request_id"],
|
||||||
|
task_id,
|
||||||
|
job_id,
|
||||||
|
"BOUND",
|
||||||
|
)
|
||||||
|
self.store.bound(
|
||||||
|
request["request_id"],
|
||||||
|
job_id=job_id,
|
||||||
|
base_oid=base_oid,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def status(self, request_id: str) -> dict[str, Any]:
|
||||||
|
if REQUEST_ID_RE.fullmatch(request_id) is None:
|
||||||
|
raise IngressError("REQUEST_ID_INVALID")
|
||||||
|
row = self.store.get(request_id)
|
||||||
|
if row is None:
|
||||||
|
raise IngressError("REQUEST_NOT_FOUND", 404)
|
||||||
|
task_id = str(row["task_id"])
|
||||||
|
status, task = self.upstream.get_task(task_id)
|
||||||
|
if status == 200 and not row["job_id"]:
|
||||||
|
metadata = task.get("metadata")
|
||||||
|
execution = (
|
||||||
|
metadata.get("execution")
|
||||||
|
if isinstance(metadata, Mapping)
|
||||||
|
and isinstance(metadata.get("execution"), Mapping)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
job_id = str(execution.get("job_id") or "")
|
||||||
|
base_oid = str(execution.get("base_oid") or "")
|
||||||
|
if (
|
||||||
|
JOB_ID_RE.fullmatch(job_id)
|
||||||
|
and OID_RE.fullmatch(base_oid)
|
||||||
|
and execution.get("action") == "source_capture"
|
||||||
|
and sorted(execution.get("capability_scope") or [])
|
||||||
|
== sorted(READONLY_CAPABILITIES)
|
||||||
|
and (execution.get("governance") or {}).get("writer_lease") is False
|
||||||
|
):
|
||||||
|
recovered = self._public_response(
|
||||||
|
request_id,
|
||||||
|
task_id,
|
||||||
|
job_id,
|
||||||
|
str(task.get("status") or "BOUND"),
|
||||||
|
)
|
||||||
|
self.store.bound(
|
||||||
|
request_id,
|
||||||
|
job_id=job_id,
|
||||||
|
base_oid=base_oid,
|
||||||
|
response=recovered,
|
||||||
|
)
|
||||||
|
row = self.store.get(request_id) or row
|
||||||
|
if status == 404:
|
||||||
|
return {
|
||||||
|
"schema": RESPONSE_SCHEMA,
|
||||||
|
"request_id": request_id,
|
||||||
|
"task_id": task_id,
|
||||||
|
"state": "PENDING",
|
||||||
|
"official_verification_required": True,
|
||||||
|
}
|
||||||
|
if status != 200:
|
||||||
|
raise IngressError("TASK_STATUS_UNAVAILABLE", 503)
|
||||||
|
|
||||||
|
result = task.get("result") if isinstance(task.get("result"), Mapping) else {}
|
||||||
|
verification = (
|
||||||
|
task.get("verification")
|
||||||
|
if isinstance(task.get("verification"), Mapping)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
safe = {
|
||||||
|
"schema": RESPONSE_SCHEMA,
|
||||||
|
"request_id": request_id,
|
||||||
|
"task_id": task_id,
|
||||||
|
"job_id": str(row["job_id"] or ""),
|
||||||
|
"state": str(task.get("status") or "UNKNOWN"),
|
||||||
|
"attempt": int(task.get("attempt") or 0),
|
||||||
|
"claimed": bool(task.get("claimed_by")),
|
||||||
|
"result_status": result.get("status"),
|
||||||
|
"result_classification": result.get("classification"),
|
||||||
|
"official_verification": verification.get("decision"),
|
||||||
|
"official_classification": verification.get("classification"),
|
||||||
|
"write_access": False,
|
||||||
|
}
|
||||||
|
if result.get("execution_mode") == "source_capture":
|
||||||
|
snapshot = result.get("source_snapshot")
|
||||||
|
if isinstance(snapshot, Mapping):
|
||||||
|
safe["source_snapshot_sha256"] = snapshot.get("payload_sha256")
|
||||||
|
safe["source_snapshot_file_count"] = snapshot.get("file_count")
|
||||||
|
return safe
|
||||||
|
|
||||||
|
|
||||||
|
def openapi_document() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"openapi": OPENAPI_VERSION,
|
||||||
|
"info": {
|
||||||
|
"title": "AIOS Read-Only Online Task Ingress",
|
||||||
|
"version": "1.0.0",
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"/v1/read-only-tasks": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "createReadOnlyOnlineTask",
|
||||||
|
"security": [{"bearerAuth": []}],
|
||||||
|
"requestBody": {"required": True},
|
||||||
|
"responses": {"200": {"description": "Task accepted"}},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v1/read-only-tasks/{request_id}": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getReadOnlyOnlineTaskStatus",
|
||||||
|
"security": [{"bearerAuth": []}],
|
||||||
|
"responses": {"200": {"description": "Sanitized task status"}},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"securitySchemes": {
|
||||||
|
"bearerAuth": {
|
||||||
|
"type": "http",
|
||||||
|
"scheme": "bearer",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
service: IngressService | None = None
|
||||||
|
ingress_token: str = ""
|
||||||
|
|
||||||
|
def log_message(self, *_args: Any) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def respond(self, value: Mapping[str, Any], status: int) -> None:
|
||||||
|
raw = canonical(value)
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("Content-Length", str(len(raw)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(raw)
|
||||||
|
|
||||||
|
def authorized(self) -> bool:
|
||||||
|
header = self.headers.get("Authorization", "")
|
||||||
|
return (
|
||||||
|
header.startswith("Bearer ")
|
||||||
|
and bool(self.ingress_token)
|
||||||
|
and hmac.compare_digest(header[7:], self.ingress_token)
|
||||||
|
)
|
||||||
|
|
||||||
|
def body(self) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", "-1"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise IngressError("CONTENT_LENGTH_INVALID") from exc
|
||||||
|
if length < 1 or length > MAX_BODY:
|
||||||
|
raise IngressError("REQUEST_SIZE_INVALID", 413)
|
||||||
|
try:
|
||||||
|
value = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||||
|
except Exception as exc:
|
||||||
|
raise IngressError("REQUEST_JSON_INVALID") from exc
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise IngressError("REQUEST_OBJECT_REQUIRED")
|
||||||
|
return dict(value)
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
if self.path == "/health":
|
||||||
|
self.respond(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"service": "aios-gpt-online-task-ingress",
|
||||||
|
"mode": "read-only",
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if self.path == "/openapi.json":
|
||||||
|
self.respond(openapi_document(), 200)
|
||||||
|
return
|
||||||
|
if not self.authorized():
|
||||||
|
self.respond({"error": "UNAUTHORIZED"}, 401)
|
||||||
|
return
|
||||||
|
match = re.fullmatch(r"/v1/read-only-tasks/([^/?#]+)", self.path)
|
||||||
|
if match is None:
|
||||||
|
self.respond({"error": "NOT_FOUND"}, 404)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if self.service is None:
|
||||||
|
raise IngressError("RUNTIME_UNAVAILABLE", 503)
|
||||||
|
self.respond(self.service.status(match.group(1)), 200)
|
||||||
|
except IngressError as exc:
|
||||||
|
self.respond({"error": exc.code}, exc.status)
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
if self.path != "/v1/read-only-tasks":
|
||||||
|
self.respond({"error": "NOT_FOUND"}, 404)
|
||||||
|
return
|
||||||
|
if not self.authorized():
|
||||||
|
self.respond({"error": "UNAUTHORIZED"}, 401)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if self.service is None:
|
||||||
|
raise IngressError("RUNTIME_UNAVAILABLE", 503)
|
||||||
|
value = self.service.create(self.body())
|
||||||
|
self.respond(value, 200)
|
||||||
|
except IngressError as exc:
|
||||||
|
self.respond({"error": exc.code}, exc.status)
|
||||||
|
|
||||||
|
|
||||||
|
def serve() -> None:
|
||||||
|
ingress_token_path = Path(
|
||||||
|
os.environ.get(
|
||||||
|
"AIOS_GPT_INGRESS_TOKEN_FILE",
|
||||||
|
str(
|
||||||
|
Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials"))
|
||||||
|
/ "gpt_ingress_token"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
control_token_path = Path(
|
||||||
|
os.environ.get(
|
||||||
|
"AIOS_AGENT_TASK_CONTROL_TOKEN_FILE",
|
||||||
|
str(
|
||||||
|
Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials"))
|
||||||
|
/ "agent_task_control_token"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ingress_token = load_secret(
|
||||||
|
ingress_token_path,
|
||||||
|
code="GPT_INGRESS_CREDENTIAL",
|
||||||
|
)
|
||||||
|
control_token = load_secret(
|
||||||
|
control_token_path,
|
||||||
|
code="CONTROL_PLANE_CREDENTIAL",
|
||||||
|
)
|
||||||
|
|
||||||
|
state_root = Path(
|
||||||
|
os.environ.get(
|
||||||
|
"AIOS_GPT_INGRESS_STATE_ROOT",
|
||||||
|
"/var/lib/aios-gpt-online-task-ingress",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
host = validated_bind_host(
|
||||||
|
os.environ.get("AIOS_GPT_INGRESS_HOST", "127.0.0.1")
|
||||||
|
)
|
||||||
|
port = int(os.environ.get("AIOS_GPT_INGRESS_PORT", "18945"))
|
||||||
|
if not 1024 <= port <= 65535:
|
||||||
|
raise RuntimeError("GPT_INGRESS_PORT_INVALID")
|
||||||
|
|
||||||
|
Handler.service = IngressService(
|
||||||
|
Upstream(
|
||||||
|
bridge_base=os.environ.get(
|
||||||
|
"AIOS_GOVERNED_WRITE_API_URL",
|
||||||
|
"http://127.0.0.1:18892",
|
||||||
|
),
|
||||||
|
task_base=os.environ.get(
|
||||||
|
"AIOS_AGENT_TASK_API_URL",
|
||||||
|
"http://127.0.0.1:18940",
|
||||||
|
),
|
||||||
|
control_token=control_token,
|
||||||
|
),
|
||||||
|
Store(state_root / "state.sqlite3"),
|
||||||
|
)
|
||||||
|
Handler.ingress_token = ingress_token
|
||||||
|
ThreadingHTTPServer((host, port), Handler).serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
serve()
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"""Uncredentialed deterministic executor for governed Online Agent changes.
|
||||||
|
|
||||||
|
This process never receives Agent/Owner/Bridge credentials and never writes Git refs.
|
||||||
|
It operates only on an exchange bundle and returns hash-bound changed files + test evidence.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import errno
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
|
ALLOWED_PROGRAMS = {"/usr/bin/python3", "/usr/bin/git", "/bin/true", "/usr/bin/true"}
|
||||||
|
MAX_ARCHIVE_BYTES = 64 * 1024 * 1024
|
||||||
|
MAX_FILE_BYTES = 4 * 1024 * 1024
|
||||||
|
SECRET_ENV_TOKENS = ("TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "AUTHORIZATION", "PRIVATE_KEY")
|
||||||
|
REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,254}$")
|
||||||
|
SENSITIVE_VALUE_RE = re.compile(
|
||||||
|
r"(?i)(authorization\s*[:=]\s*bearer\s+\S+|authorization\s+bearer\s+\S+|"
|
||||||
|
r"(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S+)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IsolatedExecutorError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(data: bytes) -> str:
|
||||||
|
return hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def canonical(value: object) -> bytes:
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_relative(value: object) -> str:
|
||||||
|
if not isinstance(value, str) or not value or value.startswith("/"):
|
||||||
|
raise IsolatedExecutorError("PATH_INVALID")
|
||||||
|
path = Path(value)
|
||||||
|
if ".." in path.parts or "." in path.parts or any(part in {"", ".git"} for part in path.parts):
|
||||||
|
raise IsolatedExecutorError("PATH_INVALID")
|
||||||
|
return path.as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_under(root: Path, relative: str) -> Path:
|
||||||
|
root = root.resolve()
|
||||||
|
path = (root / safe_relative(relative)).resolve(strict=False)
|
||||||
|
if path == root or root not in path.parents:
|
||||||
|
raise IsolatedExecutorError("PATH_ESCAPE")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _credential_exposure(directory: Path) -> str | None:
|
||||||
|
"""Return an exposure code; EACCES is the intended credential isolation proof."""
|
||||||
|
try:
|
||||||
|
if not directory.exists():
|
||||||
|
return None
|
||||||
|
if directory.is_symlink():
|
||||||
|
return "CREDENTIAL_MOUNT_SYMLINK"
|
||||||
|
if os.access(directory, os.R_OK):
|
||||||
|
return "CREDENTIAL_MOUNT_ACCESSIBLE"
|
||||||
|
if directory.is_dir():
|
||||||
|
for child in directory.iterdir():
|
||||||
|
if child.is_file() and os.access(child, os.R_OK):
|
||||||
|
return "CREDENTIAL_FILE_ACCESSIBLE"
|
||||||
|
except PermissionError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def assert_uncredentialed() -> None:
|
||||||
|
credentials = Path("/run/credentials")
|
||||||
|
candidates = {credentials}
|
||||||
|
credentials_directory = Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials"))
|
||||||
|
candidates.add(credentials_directory)
|
||||||
|
leaked = sorted(
|
||||||
|
key for key, value in os.environ.items()
|
||||||
|
if value
|
||||||
|
and (any(token in key.upper() for token in SECRET_ENV_TOKENS) or SENSITIVE_VALUE_RE.search(str(value)))
|
||||||
|
)
|
||||||
|
if leaked:
|
||||||
|
raise IsolatedExecutorError("CREDENTIAL_ENV_EXPOSED:" + ",".join(leaked))
|
||||||
|
for directory in candidates:
|
||||||
|
exposure = _credential_exposure(directory)
|
||||||
|
if exposure is not None:
|
||||||
|
raise IsolatedExecutorError(exposure)
|
||||||
|
# In the intended PrivateNetwork namespace host loopback services are unreachable.
|
||||||
|
try:
|
||||||
|
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno in {errno.EACCES, errno.EPERM, errno.EAFNOSUPPORT}:
|
||||||
|
return
|
||||||
|
raise IsolatedExecutorError("NETWORK_ISOLATION_PROBE_FAILED") from exc
|
||||||
|
probe.settimeout(0.15)
|
||||||
|
try:
|
||||||
|
if probe.connect_ex(("127.0.0.1", 18892)) == 0:
|
||||||
|
raise IsolatedExecutorError("GOVERNED_BRIDGE_REACHABLE")
|
||||||
|
finally:
|
||||||
|
probe.close()
|
||||||
|
|
||||||
|
|
||||||
|
def extract_source(archive: Path, root: Path) -> None:
|
||||||
|
if archive.is_symlink() or not archive.is_file() or archive.stat().st_size > MAX_ARCHIVE_BYTES:
|
||||||
|
raise IsolatedExecutorError("SOURCE_ARCHIVE_INVALID")
|
||||||
|
with tarfile.open(archive, "r:gz") as tf:
|
||||||
|
for member in tf.getmembers():
|
||||||
|
path = Path(member.name)
|
||||||
|
if path.is_absolute() or ".." in path.parts or ".git" in path.parts:
|
||||||
|
raise IsolatedExecutorError("SOURCE_ARCHIVE_PATH_INVALID")
|
||||||
|
if member.issym() or member.islnk():
|
||||||
|
raise IsolatedExecutorError("SOURCE_ARCHIVE_LINK_DENY")
|
||||||
|
tf.extractall(root, filter="data")
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(item: Mapping[str, Any], root: Path) -> dict[str, Any]:
|
||||||
|
argv = item.get("argv")
|
||||||
|
if not isinstance(argv, Sequence) or isinstance(argv, (str, bytes)) or not argv:
|
||||||
|
raise IsolatedExecutorError("COMMAND_INVALID")
|
||||||
|
argv = [str(part) for part in argv]
|
||||||
|
if argv[0] not in ALLOWED_PROGRAMS:
|
||||||
|
raise IsolatedExecutorError("COMMAND_PROGRAM_DENY")
|
||||||
|
if argv[0] == "/usr/bin/python3" and "-m" in argv:
|
||||||
|
index = argv.index("-m")
|
||||||
|
if index + 1 >= len(argv) or argv[index + 1] not in {"pytest", "py_compile"}:
|
||||||
|
raise IsolatedExecutorError("PYTHON_MODULE_DENY")
|
||||||
|
if argv[0] == "/usr/bin/git" and any(
|
||||||
|
token in {"push", "update-ref", "commit", "merge", "rebase", "reset", "clean", "branch"}
|
||||||
|
for token in argv[1:]
|
||||||
|
):
|
||||||
|
raise IsolatedExecutorError("GIT_MUTATION_DENY")
|
||||||
|
timeout = int(item.get("timeout_seconds") or 300)
|
||||||
|
if not 1 <= timeout <= 1800:
|
||||||
|
raise IsolatedExecutorError("COMMAND_TIMEOUT_INVALID")
|
||||||
|
cwd = root if item.get("cwd", ".") == "." else resolve_under(root, str(item.get("cwd")))
|
||||||
|
started = time.monotonic()
|
||||||
|
completed = subprocess.run(
|
||||||
|
argv,
|
||||||
|
cwd=cwd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
timeout=timeout,
|
||||||
|
env={"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8"},
|
||||||
|
)
|
||||||
|
result = {
|
||||||
|
"label": str(item.get("label") or "command"),
|
||||||
|
"exit_code": completed.returncode,
|
||||||
|
"stdout_sha256": sha256(completed.stdout.encode("utf-8", "replace")),
|
||||||
|
"stderr_sha256": sha256(completed.stderr.encode("utf-8", "replace")),
|
||||||
|
"duration_ms": int((time.monotonic() - started) * 1000),
|
||||||
|
}
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise IsolatedExecutorError(
|
||||||
|
"COMMAND_FAILED:" + result["label"] + ":" + str(completed.returncode)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def apply_changes(request: Mapping[str, Any], root: Path) -> list[str]:
|
||||||
|
changes = request.get("change_set")
|
||||||
|
if not isinstance(changes, list) or not changes:
|
||||||
|
raise IsolatedExecutorError("CHANGE_SET_INVALID")
|
||||||
|
changed: list[str] = []
|
||||||
|
for change in changes:
|
||||||
|
if not isinstance(change, Mapping):
|
||||||
|
raise IsolatedExecutorError("CHANGE_INVALID")
|
||||||
|
relative = safe_relative(change.get("path"))
|
||||||
|
path = resolve_under(root, relative)
|
||||||
|
current = path.read_bytes() if path.is_file() else None
|
||||||
|
expected = change.get("expected_sha256")
|
||||||
|
if expected is not None and (current is None or sha256(current) != expected):
|
||||||
|
raise IsolatedExecutorError("SOURCE_DRIFT:" + relative)
|
||||||
|
operation = change.get("operation")
|
||||||
|
if operation == "write_file":
|
||||||
|
content = change.get("content")
|
||||||
|
if not isinstance(content, str):
|
||||||
|
raise IsolatedExecutorError("WRITE_CONTENT_INVALID")
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
elif operation == "replace_text":
|
||||||
|
if current is None:
|
||||||
|
raise IsolatedExecutorError("REPLACE_SOURCE_MISSING")
|
||||||
|
old = str(change.get("old"))
|
||||||
|
new = str(change.get("new"))
|
||||||
|
count = int(change.get("count", 1))
|
||||||
|
text = current.decode("utf-8")
|
||||||
|
if text.count(old) != count:
|
||||||
|
raise IsolatedExecutorError("REPLACE_COUNT_INVALID:" + relative)
|
||||||
|
path.write_text(text.replace(old, new), encoding="utf-8")
|
||||||
|
elif operation == "delete_file":
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
else:
|
||||||
|
raise IsolatedExecutorError("CHANGE_OPERATION_INVALID")
|
||||||
|
changed.append(relative)
|
||||||
|
return sorted(set(changed))
|
||||||
|
|
||||||
|
|
||||||
|
def changed_bundle(root: Path, changed: list[str]) -> list[dict[str, Any]]:
|
||||||
|
files = []
|
||||||
|
for relative in changed:
|
||||||
|
path = resolve_under(root, relative)
|
||||||
|
if path.exists():
|
||||||
|
data = path.read_bytes()
|
||||||
|
if len(data) > MAX_FILE_BYTES:
|
||||||
|
raise IsolatedExecutorError("CHANGED_FILE_TOO_LARGE:" + relative)
|
||||||
|
files.append({
|
||||||
|
"path": relative,
|
||||||
|
"deleted": False,
|
||||||
|
"sha256": sha256(data),
|
||||||
|
"content_b64": base64.b64encode(data).decode("ascii"),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
files.append({"path": relative, "deleted": True, "sha256": None, "content_b64": None})
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def execute_request(request_path: Path, result_path: Path) -> None:
|
||||||
|
assert_uncredentialed()
|
||||||
|
request = json.loads(request_path.read_text(encoding="utf-8"))
|
||||||
|
if request.get("schema") != "aios.isolated-execution.request.v1":
|
||||||
|
raise IsolatedExecutorError("REQUEST_SCHEMA_INVALID")
|
||||||
|
request_id = request.get("request_id")
|
||||||
|
if not isinstance(request_id, str) or not REQUEST_ID_RE.fullmatch(request_id):
|
||||||
|
raise IsolatedExecutorError("REQUEST_ID_INVALID")
|
||||||
|
source_archive = Path(str(request.get("source_archive") or ""))
|
||||||
|
if not source_archive.is_absolute():
|
||||||
|
raise IsolatedExecutorError("SOURCE_ARCHIVE_PATH_INVALID")
|
||||||
|
expected_archive_sha = str(request.get("source_archive_sha256") or "")
|
||||||
|
if sha256(source_archive.read_bytes()) != expected_archive_sha:
|
||||||
|
raise IsolatedExecutorError("SOURCE_ARCHIVE_SHA_MISMATCH")
|
||||||
|
with tempfile.TemporaryDirectory(prefix="aios-isolated-executor-") as temporary:
|
||||||
|
root = Path(temporary) / "workspace"
|
||||||
|
root.mkdir()
|
||||||
|
extract_source(source_archive, root)
|
||||||
|
changed = apply_changes(request, root)
|
||||||
|
commands = [
|
||||||
|
run_command(item, root)
|
||||||
|
for item in request.get("verification", [])
|
||||||
|
if isinstance(item, Mapping)
|
||||||
|
]
|
||||||
|
result = {
|
||||||
|
"schema": "aios.isolated-execution.result.v1",
|
||||||
|
"request_id": request_id,
|
||||||
|
"request_sha256": sha256(canonical(request)),
|
||||||
|
"changed_files": changed_bundle(root, changed),
|
||||||
|
"verification": commands,
|
||||||
|
"credential_isolation": {
|
||||||
|
"credential_env_absent": True,
|
||||||
|
"credential_mount_inaccessible": True,
|
||||||
|
"governed_bridge_unreachable": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result["result_sha256"] = sha256(canonical(result))
|
||||||
|
temporary_result = result_path.with_suffix(".tmp")
|
||||||
|
temporary_result.write_text(json.dumps(result, sort_keys=True, separators=(",", ":")), encoding="utf-8")
|
||||||
|
os.replace(temporary_result, result_path)
|
||||||
|
|
||||||
|
|
||||||
|
def serve(root: Path) -> None:
|
||||||
|
assert_uncredentialed()
|
||||||
|
requests = root / "requests"
|
||||||
|
results = root / "results"
|
||||||
|
failed = root / "failed"
|
||||||
|
for path in (requests, results, failed):
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
while True:
|
||||||
|
for request in sorted(requests.glob("*.json")):
|
||||||
|
if request.is_symlink():
|
||||||
|
continue
|
||||||
|
result = results / request.name
|
||||||
|
try:
|
||||||
|
execute_request(request, result)
|
||||||
|
request.unlink()
|
||||||
|
except Exception as exc:
|
||||||
|
(failed / request.name).write_text(
|
||||||
|
json.dumps({"error": str(exc) or type(exc).__name__}, sort_keys=True),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
request.unlink(missing_ok=True)
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--root", default="/var/lib/aios-online-agent-executor")
|
||||||
|
parser.add_argument("--request")
|
||||||
|
parser.add_argument("--result")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.request and args.result:
|
||||||
|
execute_request(Path(args.request), Path(args.result))
|
||||||
|
else:
|
||||||
|
serve(Path(args.root))
|
||||||
@@ -4,7 +4,6 @@ import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, urllib.
|
|||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from baseline_authority import BaselineAuthority, BaselineAuthorityError
|
from baseline_authority import BaselineAuthority, BaselineAuthorityError
|
||||||
from silent_release_controller import ReleaseCandidate
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -29,18 +28,11 @@ from orchestrator.action_monitor import ActionMonitor
|
|||||||
from silent_release_hardening import (
|
from silent_release_hardening import (
|
||||||
ApprovalKeyRing,
|
ApprovalKeyRing,
|
||||||
ApprovalRevocationStore,
|
ApprovalRevocationStore,
|
||||||
BuilderAttestation,
|
|
||||||
BuilderAttestationKeyRing,
|
|
||||||
BuilderAttestationVerifier,
|
|
||||||
CapabilityAuthorizationError,
|
CapabilityAuthorizationError,
|
||||||
ReleasePlan,
|
|
||||||
SagaConflict,
|
SagaConflict,
|
||||||
SignedApprovalVerifier,
|
SignedApprovalVerifier,
|
||||||
SignedReleasePlanApproval,
|
SignedReleasePlanApproval,
|
||||||
SilentReleaseActionService,
|
SilentReleaseActionService,
|
||||||
TrustedCandidateBuilderUnavailable,
|
|
||||||
TrustedCandidateBundle,
|
|
||||||
TrustedReleasePolicy,
|
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
from aios_gitops_job_controller import GitOpsJobController, JobError, ServiceUnavailable, JobStore
|
from aios_gitops_job_controller import GitOpsJobController, JobError, ServiceUnavailable, JobStore
|
||||||
@@ -90,34 +82,13 @@ SILENT_RELEASE_STATE_ROOT = Path(os.getenv(
|
|||||||
'AIOS_SILENT_RELEASE_STATE_ROOT',
|
'AIOS_SILENT_RELEASE_STATE_ROOT',
|
||||||
'/var/lib/aios-gitops/silent-release',
|
'/var/lib/aios-gitops/silent-release',
|
||||||
))
|
))
|
||||||
# This immutable staging API can prepare only a shadow candidate. An
|
SILENT_RELEASE_EXECUTION_ENABLED = str(os.getenv(
|
||||||
# environment value must not turn its HTTP process into a release executor.
|
'AIOS_SILENT_RELEASE_EXECUTION_ENABLED', 'false'
|
||||||
SILENT_RELEASE_EXECUTION_ENABLED = False
|
)).lower() in {'1', 'true', 'yes', 'on'}
|
||||||
SILENT_RELEASE_PUBLIC_KEYS = Path(os.getenv(
|
SILENT_RELEASE_PUBLIC_KEYS = Path(os.getenv(
|
||||||
'AIOS_SILENT_RELEASE_PUBLIC_KEYS',
|
'AIOS_SILENT_RELEASE_PUBLIC_KEYS',
|
||||||
'/etc/aios-gitops/silent-release-owner-public-keys.json',
|
'/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()
|
monitor = ActionMonitor()
|
||||||
|
|
||||||
BASE_MODELS = [
|
BASE_MODELS = [
|
||||||
@@ -195,86 +166,6 @@ def _file_sha256(path: Path) -> str:
|
|||||||
digest.update(chunk)
|
digest.update(chunk)
|
||||||
return digest.hexdigest()
|
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:
|
def _build_silent_release_service() -> SilentReleaseActionService:
|
||||||
verifier=None
|
verifier=None
|
||||||
if SILENT_RELEASE_PUBLIC_KEYS.is_file():
|
if SILENT_RELEASE_PUBLIC_KEYS.is_file():
|
||||||
@@ -284,88 +175,12 @@ def _build_silent_release_service() -> SilentReleaseActionService:
|
|||||||
SILENT_RELEASE_STATE_ROOT / 'revocations'
|
SILENT_RELEASE_STATE_ROOT / 'revocations'
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
policy_path=TRUSTED_RELEASE_POLICY
|
policy_path=WS / 'config' / 'gitops' / 'silent-release-policy.json'
|
||||||
policy_sha=_file_sha256(policy_path) if policy_path.is_file() else ''
|
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(
|
return SilentReleaseActionService(
|
||||||
SILENT_RELEASE_STATE_ROOT,
|
SILENT_RELEASE_STATE_ROOT,
|
||||||
execution_enabled=SILENT_RELEASE_EXECUTION_ENABLED,
|
execution_enabled=SILENT_RELEASE_EXECUTION_ENABLED,
|
||||||
approval_verifier=verifier,
|
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,
|
expected_repository=REPOSITORY_IDENTITY,
|
||||||
policy_sha256=policy_sha,
|
policy_sha256=policy_sha,
|
||||||
)
|
)
|
||||||
@@ -528,15 +343,6 @@ def publication_request_from_query(q: dict[str, list[str]]) -> PublicationReques
|
|||||||
(q['approval_plan_id'])[0],
|
(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:
|
def required_authoritative_git_dir() -> Path:
|
||||||
snapshot = repository_snapshot(
|
snapshot = repository_snapshot(
|
||||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||||
@@ -660,11 +466,8 @@ class H(BaseHTTPRequestHandler):
|
|||||||
q=urllib.parse.parse_qs(u.query)
|
q=urllib.parse.parse_qs(u.query)
|
||||||
try:
|
try:
|
||||||
if path in ('/health','/api/health'):
|
if path in ('/health','/api/health'):
|
||||||
builder_readiness = silent_release_service().get_builder_readiness()
|
|
||||||
return self.sendj({
|
return self.sendj({
|
||||||
'status': 'ok',
|
'status': 'ok',
|
||||||
'process_liveness': 'PASS',
|
|
||||||
'builder_readiness': builder_readiness,
|
|
||||||
'service': os.getenv('AIOS_GITOPS_MODEL_API_SERVICE', 'aios-gitops-model-api-v2r2c1-staging.service'),
|
'service': os.getenv('AIOS_GITOPS_MODEL_API_SERVICE', 'aios-gitops-model-api-v2r2c1-staging.service'),
|
||||||
'workspace': str(WS),
|
'workspace': str(WS),
|
||||||
'version': 'V2R2C1',
|
'version': 'V2R2C1',
|
||||||
@@ -903,15 +706,8 @@ class H(BaseHTTPRequestHandler):
|
|||||||
if path == '/createReleaseCandidate':
|
if path == '/createReleaseCandidate':
|
||||||
a=self.need('release:candidate')
|
a=self.need('release:candidate')
|
||||||
if not a: return
|
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(
|
return self.sendj(
|
||||||
service.create_release_candidate(data)
|
silent_release_service().create_release_candidate(data)
|
||||||
)
|
)
|
||||||
if path == '/createReleaseIntent':
|
if path == '/createReleaseIntent':
|
||||||
a=self.need('release:candidate')
|
a=self.need('release:candidate')
|
||||||
@@ -951,13 +747,6 @@ class H(BaseHTTPRequestHandler):
|
|||||||
error=redact(e)
|
error=redact(e)
|
||||||
monitor.record_failure('do_POST', error, {'path':path})
|
monitor.record_failure('do_POST', error, {'path':path})
|
||||||
return self.sendj({'error':error},403)
|
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:
|
except SagaConflict as e:
|
||||||
error=redact(e)
|
error=redact(e)
|
||||||
monitor.record_failure('do_POST', error, {'path':path})
|
monitor.record_failure('do_POST', error, {'path':path})
|
||||||
@@ -1230,3 +1019,4 @@ def main():
|
|||||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||||
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
||||||
if __name__ == '__main__': main()
|
if __name__ == '__main__': main()
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,232 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Root-owned Model Governance configuration reconciler.
|
||||||
|
|
||||||
|
The config contains provider/model/policy metadata only. Raw credentials are rejected by
|
||||||
|
aios_model_governance and never stored in the governance database.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from aios_model_governance import (
|
||||||
|
ModelGovernanceError,
|
||||||
|
ModelGovernanceStore,
|
||||||
|
ModelRecord,
|
||||||
|
ProviderRecord,
|
||||||
|
ScopePolicy,
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA = "aios.model-governance.bootstrap.v1"
|
||||||
|
MAX_CONFIG_BYTES = 512 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def canonical(value: object) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def fail(code: str) -> None:
|
||||||
|
raise SystemExit(code)
|
||||||
|
|
||||||
|
|
||||||
|
def _load(path: Path) -> dict[str, Any]:
|
||||||
|
if path.is_symlink() or not path.is_file() or path.stat().st_size > MAX_CONFIG_BYTES:
|
||||||
|
fail("MODEL_GOVERNANCE_CONFIG_INVALID")
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
fail("MODEL_GOVERNANCE_CONFIG_INVALID")
|
||||||
|
if not isinstance(value, Mapping) or value.get("schema") != SCHEMA:
|
||||||
|
fail("MODEL_GOVERNANCE_CONFIG_SCHEMA_INVALID")
|
||||||
|
return dict(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_desired(value: Mapping[str, Any]) -> ProviderRecord:
|
||||||
|
return ProviderRecord(
|
||||||
|
provider_id=str(value.get("provider_id") or ""),
|
||||||
|
credential_ref=value.get("credential_ref"),
|
||||||
|
enabled=bool(value.get("enabled", True)),
|
||||||
|
failure_domain=str(value.get("failure_domain") or "default"),
|
||||||
|
max_failures=int(value.get("max_failures") or 3),
|
||||||
|
cooldown_seconds=int(value.get("cooldown_seconds") or 60),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _model_desired(value: Mapping[str, Any]) -> ModelRecord:
|
||||||
|
return ModelRecord(
|
||||||
|
model_id=str(value.get("model_id") or ""),
|
||||||
|
provider_id=str(value.get("provider_id") or ""),
|
||||||
|
version=str(value.get("version") or ""),
|
||||||
|
capabilities=tuple(value.get("capabilities") or ()),
|
||||||
|
input_cost_micros_per_1k=int(value.get("input_cost_micros_per_1k") or 0),
|
||||||
|
output_cost_micros_per_1k=int(value.get("output_cost_micros_per_1k") or 0),
|
||||||
|
max_input_tokens=int(value.get("max_input_tokens") or 0),
|
||||||
|
max_output_tokens=int(value.get("max_output_tokens") or 0),
|
||||||
|
evaluation_state=str(value.get("evaluation_state") or "APPROVED"),
|
||||||
|
evaluation_sha256=str(value.get("evaluation_sha256") or ""),
|
||||||
|
enabled=bool(value.get("enabled", True)),
|
||||||
|
priority=int(value.get("priority") or 100),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_desired(value: Mapping[str, Any]) -> ScopePolicy:
|
||||||
|
return ScopePolicy(
|
||||||
|
scope_type=str(value.get("scope_type") or ""),
|
||||||
|
tenant_id=value.get("tenant_id"),
|
||||||
|
project_id=value.get("project_id"),
|
||||||
|
allowed_models=tuple(value.get("allowed_models") or ()),
|
||||||
|
denied_providers=tuple(value.get("denied_providers") or ()),
|
||||||
|
required_capabilities=tuple(value.get("required_capabilities") or ()),
|
||||||
|
pinned_model_id=value.get("pinned_model_id"),
|
||||||
|
pinned_model_version=value.get("pinned_model_version"),
|
||||||
|
max_input_tokens_per_request=int(value.get("max_input_tokens_per_request") or 0),
|
||||||
|
max_output_tokens_per_request=int(value.get("max_output_tokens_per_request") or 0),
|
||||||
|
daily_token_budget=int(value.get("daily_token_budget") or 0),
|
||||||
|
daily_cost_budget_micros=int(value.get("daily_cost_budget_micros") or 0),
|
||||||
|
fallback_limit=int(value.get("fallback_limit") or 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_dict(row: object) -> dict[str, Any] | None:
|
||||||
|
return dict(row) if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_matches(row: Mapping[str, Any], desired: ProviderRecord) -> bool:
|
||||||
|
return (
|
||||||
|
row["credential_ref"] == desired.credential_ref
|
||||||
|
and bool(row["enabled"]) == desired.enabled
|
||||||
|
and row["failure_domain"] == desired.failure_domain
|
||||||
|
and int(row["max_failures"]) == desired.max_failures
|
||||||
|
and int(row["cooldown_seconds"]) == desired.cooldown_seconds
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _model_matches(row: Mapping[str, Any], desired: ModelRecord) -> bool:
|
||||||
|
return (
|
||||||
|
row["provider_id"] == desired.provider_id
|
||||||
|
and row["version"] == desired.version
|
||||||
|
and tuple(json.loads(row["capabilities_json"])) == tuple(sorted(set(desired.capabilities)))
|
||||||
|
and int(row["input_cost_micros_per_1k"]) == desired.input_cost_micros_per_1k
|
||||||
|
and int(row["output_cost_micros_per_1k"]) == desired.output_cost_micros_per_1k
|
||||||
|
and int(row["max_input_tokens"]) == desired.max_input_tokens
|
||||||
|
and int(row["max_output_tokens"]) == desired.max_output_tokens
|
||||||
|
and row["evaluation_state"] == desired.evaluation_state
|
||||||
|
and row["evaluation_sha256"] == desired.evaluation_sha256
|
||||||
|
and bool(row["enabled"]) == desired.enabled
|
||||||
|
and int(row["priority"]) == desired.priority
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_matches(row: Mapping[str, Any], desired: ScopePolicy) -> bool:
|
||||||
|
return (
|
||||||
|
row["scope_type"] == desired.scope_type
|
||||||
|
and row["tenant_id"] == desired.tenant_id
|
||||||
|
and row["project_id"] == desired.project_id
|
||||||
|
and tuple(json.loads(row["allowed_models_json"])) == tuple(sorted(set(desired.allowed_models)))
|
||||||
|
and tuple(json.loads(row["denied_providers_json"])) == tuple(sorted(set(desired.denied_providers)))
|
||||||
|
and tuple(json.loads(row["required_capabilities_json"])) == tuple(sorted(set(desired.required_capabilities)))
|
||||||
|
and row["pinned_model_id"] == desired.pinned_model_id
|
||||||
|
and row["pinned_model_version"] == desired.pinned_model_version
|
||||||
|
and int(row["max_input_tokens_per_request"]) == desired.max_input_tokens_per_request
|
||||||
|
and int(row["max_output_tokens_per_request"]) == desired.max_output_tokens_per_request
|
||||||
|
and int(row["daily_token_budget"]) == desired.daily_token_budget
|
||||||
|
and int(row["daily_cost_budget_micros"]) == desired.daily_cost_budget_micros
|
||||||
|
and int(row["fallback_limit"]) == desired.fallback_limit
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_config(db_path: Path, config_path: Path) -> dict[str, Any]:
|
||||||
|
config = _load(config_path)
|
||||||
|
config_sha = hashlib.sha256(canonical(config)).hexdigest()
|
||||||
|
providers = config.get("providers") or []
|
||||||
|
models = config.get("models") or []
|
||||||
|
policies = config.get("policies") or []
|
||||||
|
if not all(isinstance(value, list) for value in (providers, models, policies)):
|
||||||
|
fail("MODEL_GOVERNANCE_CONFIG_COLLECTION_INVALID")
|
||||||
|
|
||||||
|
store = ModelGovernanceStore(db_path)
|
||||||
|
changes = {"providers": 0, "models": 0, "policies": 0, "unchanged": 0}
|
||||||
|
try:
|
||||||
|
for item in providers:
|
||||||
|
if not isinstance(item, Mapping):
|
||||||
|
fail("MODEL_GOVERNANCE_PROVIDER_CONFIG_INVALID")
|
||||||
|
desired = _provider_desired(item)
|
||||||
|
row = store.conn.execute(
|
||||||
|
"SELECT * FROM model_governance_providers WHERE provider_id=?",
|
||||||
|
(desired.provider_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is not None and _provider_matches(row, desired):
|
||||||
|
changes["unchanged"] += 1
|
||||||
|
continue
|
||||||
|
expected = int(row["revision"]) if row is not None else 0
|
||||||
|
store.register_provider(desired, expected_revision=expected)
|
||||||
|
changes["providers"] += 1
|
||||||
|
|
||||||
|
for item in models:
|
||||||
|
if not isinstance(item, Mapping):
|
||||||
|
fail("MODEL_GOVERNANCE_MODEL_CONFIG_INVALID")
|
||||||
|
desired = _model_desired(item)
|
||||||
|
row = store.conn.execute(
|
||||||
|
"SELECT * FROM model_governance_models WHERE model_id=?",
|
||||||
|
(desired.model_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is not None and _model_matches(row, desired):
|
||||||
|
changes["unchanged"] += 1
|
||||||
|
continue
|
||||||
|
expected = int(row["revision"]) if row is not None else 0
|
||||||
|
store.register_model(desired, expected_revision=expected)
|
||||||
|
changes["models"] += 1
|
||||||
|
|
||||||
|
for item in policies:
|
||||||
|
if not isinstance(item, Mapping):
|
||||||
|
fail("MODEL_GOVERNANCE_POLICY_CONFIG_INVALID")
|
||||||
|
desired = _policy_desired(item)
|
||||||
|
row = store.conn.execute(
|
||||||
|
"SELECT * FROM model_governance_scope_policies WHERE scope_key=?",
|
||||||
|
(desired.scope_key,),
|
||||||
|
).fetchone()
|
||||||
|
if row is not None and _policy_matches(row, desired):
|
||||||
|
changes["unchanged"] += 1
|
||||||
|
continue
|
||||||
|
expected = int(row["revision"]) if row is not None else 0
|
||||||
|
store.set_scope_policy(desired, expected_revision=expected)
|
||||||
|
changes["policies"] += 1
|
||||||
|
|
||||||
|
if not store.verify_audit_chain():
|
||||||
|
fail("MODEL_GOVERNANCE_AUDIT_CHAIN_INVALID")
|
||||||
|
return {
|
||||||
|
"schema": "aios.model-governance.bootstrap-result.v1",
|
||||||
|
"database": str(db_path),
|
||||||
|
"config_sha256": config_sha,
|
||||||
|
"changes": changes,
|
||||||
|
"audit_chain_valid": True,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--db", required=True)
|
||||||
|
parser.add_argument("--config", required=True)
|
||||||
|
parser.add_argument("--output")
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
result = apply_config(Path(args.db), Path(args.config))
|
||||||
|
except ModelGovernanceError as exc:
|
||||||
|
print(json.dumps({"status": "FAILED", "error": exc.code}, sort_keys=True))
|
||||||
|
return 20
|
||||||
|
data = json.dumps(result, indent=2, sort_keys=True) + "\n"
|
||||||
|
if args.output:
|
||||||
|
Path(args.output).write_text(data, encoding="utf-8")
|
||||||
|
print(data, end="")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
"""Durable checkpoint journal for the generic Online Agent worker.
|
||||||
|
|
||||||
|
The JSON task state remains a fast cache. This SQLite journal is the durable,
|
||||||
|
append-only evidence used for restart/resume/replay decisions.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
PHASE_ORDER = {
|
||||||
|
"claimed": 10,
|
||||||
|
"workspace_prepared": 20,
|
||||||
|
"verification_passed": 30,
|
||||||
|
"agent_runtime_done": 40,
|
||||||
|
"source_capture_done": 40,
|
||||||
|
"commit_done": 40,
|
||||||
|
"reconciliation_required": 45,
|
||||||
|
"result_pending": 50,
|
||||||
|
"result_submitted": 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CheckpointError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def canonical(value: object) -> bytes:
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_json(value: object) -> str:
|
||||||
|
return hashlib.sha256(canonical(value)).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CheckpointBinding:
|
||||||
|
task_id: str
|
||||||
|
attempt: int
|
||||||
|
execution_digest: str
|
||||||
|
job_id: str
|
||||||
|
repository: str
|
||||||
|
base_oid: str
|
||||||
|
expected_branch_oid: str
|
||||||
|
target_branch: str
|
||||||
|
generation: int
|
||||||
|
|
||||||
|
def material(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"task_id": self.task_id,
|
||||||
|
"attempt": self.attempt,
|
||||||
|
"execution_digest": self.execution_digest,
|
||||||
|
"job_id": self.job_id,
|
||||||
|
"repository": self.repository,
|
||||||
|
"base_oid": self.base_oid,
|
||||||
|
"expected_branch_oid": self.expected_branch_oid,
|
||||||
|
"target_branch": self.target_branch,
|
||||||
|
"generation": self.generation,
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def binding_sha256(self) -> str:
|
||||||
|
return sha256_json(self.material())
|
||||||
|
|
||||||
|
|
||||||
|
class CheckpointStore:
|
||||||
|
def __init__(self, path: Path) -> None:
|
||||||
|
self.path = Path(path)
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS checkpoints(
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
attempt INTEGER NOT NULL,
|
||||||
|
sequence INTEGER NOT NULL,
|
||||||
|
phase TEXT NOT NULL,
|
||||||
|
phase_order INTEGER NOT NULL,
|
||||||
|
execution_digest TEXT NOT NULL,
|
||||||
|
job_id TEXT NOT NULL,
|
||||||
|
repository TEXT NOT NULL,
|
||||||
|
base_oid TEXT NOT NULL,
|
||||||
|
expected_branch_oid TEXT NOT NULL,
|
||||||
|
target_branch TEXT NOT NULL,
|
||||||
|
generation INTEGER NOT NULL,
|
||||||
|
binding_sha256 TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
payload_sha256 TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(task_id, attempt, sequence)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_checkpoints_task_latest
|
||||||
|
ON checkpoints(task_id, attempt DESC, sequence DESC);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_checkpoints_exact_replay
|
||||||
|
ON checkpoints(task_id, attempt, phase, payload_sha256);
|
||||||
|
CREATE TABLE IF NOT EXISTS checkpoint_heads(
|
||||||
|
task_id TEXT PRIMARY KEY,
|
||||||
|
attempt INTEGER NOT NULL,
|
||||||
|
sequence INTEGER NOT NULL,
|
||||||
|
phase TEXT NOT NULL,
|
||||||
|
phase_order INTEGER NOT NULL,
|
||||||
|
execution_digest TEXT NOT NULL,
|
||||||
|
binding_sha256 TEXT NOT NULL,
|
||||||
|
payload_sha256 TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
connection = sqlite3.connect(self.path, timeout=30.0)
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
connection.execute("PRAGMA foreign_keys=ON")
|
||||||
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
connection.execute("PRAGMA synchronous=FULL")
|
||||||
|
connection.execute("PRAGMA busy_timeout=30000")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _phase_order(phase: str) -> int:
|
||||||
|
try:
|
||||||
|
return PHASE_ORDER[phase]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise CheckpointError("CHECKPOINT_PHASE_INVALID") from exc
|
||||||
|
|
||||||
|
def append(
|
||||||
|
self,
|
||||||
|
binding: CheckpointBinding,
|
||||||
|
phase: str,
|
||||||
|
payload: Mapping[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
order = self._phase_order(phase)
|
||||||
|
payload_value = dict(payload)
|
||||||
|
payload_sha = sha256_json(payload_value)
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
replay = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM checkpoints
|
||||||
|
WHERE task_id=? AND attempt=? AND phase=? AND payload_sha256=?
|
||||||
|
""",
|
||||||
|
(binding.task_id, binding.attempt, phase, payload_sha),
|
||||||
|
).fetchone()
|
||||||
|
if replay is not None:
|
||||||
|
connection.commit()
|
||||||
|
return dict(replay)
|
||||||
|
|
||||||
|
head = connection.execute(
|
||||||
|
"SELECT * FROM checkpoint_heads WHERE task_id=?",
|
||||||
|
(binding.task_id,),
|
||||||
|
).fetchone()
|
||||||
|
if head is not None:
|
||||||
|
head_attempt = int(head["attempt"])
|
||||||
|
head_order = int(head["phase_order"])
|
||||||
|
if binding.attempt < head_attempt:
|
||||||
|
raise CheckpointError("CHECKPOINT_ATTEMPT_REGRESSION")
|
||||||
|
if binding.attempt == head_attempt:
|
||||||
|
if head["execution_digest"] != binding.execution_digest:
|
||||||
|
raise CheckpointError("CHECKPOINT_EXECUTION_DIGEST_CONFLICT")
|
||||||
|
if head["binding_sha256"] != binding.binding_sha256:
|
||||||
|
raise CheckpointError("CHECKPOINT_SCOPE_MISMATCH_DENY")
|
||||||
|
if order < head_order and phase != "reconciliation_required":
|
||||||
|
raise CheckpointError("CHECKPOINT_PHASE_REGRESSION")
|
||||||
|
sequence = int(head["sequence"]) + 1
|
||||||
|
else:
|
||||||
|
sequence = 1
|
||||||
|
else:
|
||||||
|
sequence = 1
|
||||||
|
|
||||||
|
created_at = now()
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO checkpoints(
|
||||||
|
task_id,attempt,sequence,phase,phase_order,execution_digest,job_id,
|
||||||
|
repository,base_oid,expected_branch_oid,target_branch,generation,
|
||||||
|
binding_sha256,payload_json,payload_sha256,created_at
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
binding.task_id,
|
||||||
|
binding.attempt,
|
||||||
|
sequence,
|
||||||
|
phase,
|
||||||
|
order,
|
||||||
|
binding.execution_digest,
|
||||||
|
binding.job_id,
|
||||||
|
binding.repository,
|
||||||
|
binding.base_oid,
|
||||||
|
binding.expected_branch_oid,
|
||||||
|
binding.target_branch,
|
||||||
|
binding.generation,
|
||||||
|
binding.binding_sha256,
|
||||||
|
canonical(payload_value).decode("utf-8"),
|
||||||
|
payload_sha,
|
||||||
|
created_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO checkpoint_heads(
|
||||||
|
task_id,attempt,sequence,phase,phase_order,execution_digest,
|
||||||
|
binding_sha256,payload_sha256,updated_at
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(task_id) DO UPDATE SET
|
||||||
|
attempt=excluded.attempt,
|
||||||
|
sequence=excluded.sequence,
|
||||||
|
phase=excluded.phase,
|
||||||
|
phase_order=excluded.phase_order,
|
||||||
|
execution_digest=excluded.execution_digest,
|
||||||
|
binding_sha256=excluded.binding_sha256,
|
||||||
|
payload_sha256=excluded.payload_sha256,
|
||||||
|
updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
binding.task_id,
|
||||||
|
binding.attempt,
|
||||||
|
sequence,
|
||||||
|
phase,
|
||||||
|
order,
|
||||||
|
binding.execution_digest,
|
||||||
|
binding.binding_sha256,
|
||||||
|
payload_sha,
|
||||||
|
created_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT * FROM checkpoints WHERE task_id=? AND attempt=? AND sequence=?",
|
||||||
|
(binding.task_id, binding.attempt, sequence),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
def latest(self, task_id: str) -> dict[str, Any] | None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM checkpoints
|
||||||
|
WHERE task_id=?
|
||||||
|
ORDER BY attempt DESC, sequence DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(task_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
value = dict(row)
|
||||||
|
value["payload"] = json.loads(value.pop("payload_json"))
|
||||||
|
return value
|
||||||
|
|
||||||
|
def latest_for_attempt(self, task_id: str, attempt: int) -> dict[str, Any] | None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM checkpoints
|
||||||
|
WHERE task_id=? AND attempt=?
|
||||||
|
ORDER BY sequence DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(task_id, attempt),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
value = dict(row)
|
||||||
|
value["payload"] = json.loads(value.pop("payload_json"))
|
||||||
|
return value
|
||||||
|
|
||||||
|
def verify_latest_binding(self, binding: CheckpointBinding) -> dict[str, Any] | None:
|
||||||
|
value = self.latest(binding.task_id)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if int(value["attempt"]) > binding.attempt:
|
||||||
|
raise CheckpointError("CHECKPOINT_FUTURE_ATTEMPT")
|
||||||
|
# A previous attempt may be reused only when the immutable execution/scope binding matches.
|
||||||
|
if value["execution_digest"] != binding.execution_digest:
|
||||||
|
return None
|
||||||
|
expected = CheckpointBinding(
|
||||||
|
task_id=value["task_id"],
|
||||||
|
attempt=int(value["attempt"]),
|
||||||
|
execution_digest=value["execution_digest"],
|
||||||
|
job_id=value["job_id"],
|
||||||
|
repository=value["repository"],
|
||||||
|
base_oid=value["base_oid"],
|
||||||
|
expected_branch_oid=value["expected_branch_oid"],
|
||||||
|
target_branch=value["target_branch"],
|
||||||
|
generation=int(value["generation"]),
|
||||||
|
)
|
||||||
|
current_scope = binding.material()
|
||||||
|
historical_scope = expected.material()
|
||||||
|
current_scope.pop("attempt", None)
|
||||||
|
historical_scope.pop("attempt", None)
|
||||||
|
if sha256_json(current_scope) != sha256_json(historical_scope):
|
||||||
|
raise CheckpointError("CHECKPOINT_SCOPE_MISMATCH_DENY")
|
||||||
|
return value
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
"""Bounded read-only repository interface map for governed Online Agent analysis.
|
||||||
|
|
||||||
|
The compact map intentionally returns symbols and short source excerpts rather than a
|
||||||
|
source archive. It is designed for control-plane consumers that cannot safely ingest a
|
||||||
|
multi-megabyte repository snapshot.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from typing import Any, Iterable, Mapping, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA = "aios.engineering.interface-map.v1"
|
||||||
|
MAX_RESULT_BYTES = 120 * 1024
|
||||||
|
TARGET_RESULT_BYTES = 108 * 1024
|
||||||
|
MAX_MODULES = 40
|
||||||
|
MAX_SYMBOLS = 80
|
||||||
|
MAX_TESTS = 40
|
||||||
|
MAX_EXCERPTS = 25
|
||||||
|
MAX_EXCERPT_CHARS = 1200
|
||||||
|
MAX_FILE_BYTES = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
ROOTS = (
|
||||||
|
"active-runtime/app",
|
||||||
|
"runtime/agent-os",
|
||||||
|
"tests",
|
||||||
|
"db/migrations",
|
||||||
|
"migrations",
|
||||||
|
"deploy/systemd",
|
||||||
|
"docs/task-plane",
|
||||||
|
"docs/superpowers/specs",
|
||||||
|
)
|
||||||
|
SUFFIXES = {".py", ".sql", ".service", ".md", ".json", ".yaml", ".yml"}
|
||||||
|
|
||||||
|
DEFAULT_TERMS = {
|
||||||
|
"agent", "worker", "fleet", "scheduler", "scheduling", "task", "claim", "lease",
|
||||||
|
"heartbeat", "capacity", "concurrency", "priority", "fairness", "queue", "retry",
|
||||||
|
"backoff", "circuit", "health", "liveness", "scope", "tenant", "project", "trace",
|
||||||
|
"message", "checkpoint", "recovery", "idempotency", "dispatch", "assignment",
|
||||||
|
}
|
||||||
|
FLEET_CONTRACT_TERMS: dict[str, tuple[str, ...]] = {
|
||||||
|
"FLEET_AGENT_REGISTRATION": ("agent", "register", "worker", "capability", "version"),
|
||||||
|
"FLEET_CAPACITY_SCHEDULING": ("capacity", "slot", "concurrency", "scheduler", "queue"),
|
||||||
|
"FLEET_FAIRNESS": ("fair", "priority", "queue", "tenant", "project", "starvation"),
|
||||||
|
"FLEET_FAULT_ISOLATION": ("failure", "health", "liveness", "timeout", "circuit", "poison"),
|
||||||
|
"FLEET_WORKER_REASSIGNMENT": ("reassign", "assignment", "claim", "lease", "retry", "worker"),
|
||||||
|
"FLEET_SCOPE_ISOLATION": ("tenant", "project", "scope", "conversation", "task", "run"),
|
||||||
|
"FLEET_IDEMPOTENT_DISPATCH": ("idempot", "dispatch", "claim", "lease", "result", "attempt"),
|
||||||
|
"FLEET_FAILURE_RECOVERY": ("checkpoint", "resume", "recover", "retry", "cleanup", "receipt"),
|
||||||
|
}
|
||||||
|
|
||||||
|
MODEL_GOVERNANCE_CONTRACT_TERMS: dict[str, tuple[str, ...]] = {
|
||||||
|
"MODEL_PROVIDER_REGISTRY": ("model", "provider", "registry", "endpoint", "capability"),
|
||||||
|
"MODEL_SELECTION_POLICY": ("model", "select", "route", "policy", "capability"),
|
||||||
|
"MODEL_VERSION_PINNING": ("model", "version", "pin", "revision", "snapshot"),
|
||||||
|
"MODEL_BUDGET_ACCOUNTING": ("budget", "token", "cost", "quota", "usage"),
|
||||||
|
"MODEL_FALLBACK_POLICY": ("fallback", "retry", "route", "provider", "model"),
|
||||||
|
"MODEL_SCOPE_ISOLATION": ("tenant", "project", "scope", "policy", "model"),
|
||||||
|
"MODEL_CREDENTIAL_BOUNDARY": ("credential", "secret", "provider", "tool", "token"),
|
||||||
|
"MODEL_TRACE_AUDIT": ("trace", "audit", "model", "provider", "request"),
|
||||||
|
"MODEL_EVALUATION_GATE": ("eval", "evaluation", "approval", "quality", "gate"),
|
||||||
|
"MODEL_ROUTING_RESILIENCE": ("route", "retry", "fallback", "timeout", "health"),
|
||||||
|
}
|
||||||
|
|
||||||
|
PROFILE_CLASSIFICATION = {
|
||||||
|
"fleet": "P2_INTERFACE_MAP_CAPTURED",
|
||||||
|
"model_governance": "MODEL_GOVERNANCE_INTERFACE_MAP_CAPTURED",
|
||||||
|
}
|
||||||
|
|
||||||
|
MODEL_PROFILE_MARKERS = {
|
||||||
|
"model", "provider", "llm", "inference", "routing", "router", "budget",
|
||||||
|
"token", "cost", "quota", "fallback", "evaluation", "eval", "approval",
|
||||||
|
"version", "pinning",
|
||||||
|
}
|
||||||
|
SENSITIVE_RE = re.compile(
|
||||||
|
r"(?i)(authorization\s*:\s*bearer\s+\S+|"
|
||||||
|
r"(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*['\"]?[^'\"\s,;]+)"
|
||||||
|
)
|
||||||
|
SQL_TABLE_RE = re.compile(
|
||||||
|
r"(?is)\bcreate\s+table\s+(?:if\s+not\s+exists\s+)?[\"`[]?([A-Za-z_][A-Za-z0-9_.-]*)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CompactInterfaceMapError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _sha(data: bytes) -> str:
|
||||||
|
return hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_excerpt(text: str, start_line: int, end_line: int) -> str:
|
||||||
|
lines = text.splitlines()
|
||||||
|
start = max(0, start_line - 2)
|
||||||
|
end = min(len(lines), max(end_line + 1, start + 1))
|
||||||
|
excerpt = "\n".join(lines[start:end])
|
||||||
|
excerpt = SENSITIVE_RE.sub("<redacted-sensitive-source-pattern>", excerpt)
|
||||||
|
if len(excerpt) > MAX_EXCERPT_CHARS:
|
||||||
|
excerpt = excerpt[: MAX_EXCERPT_CHARS - 1] + "…"
|
||||||
|
return excerpt
|
||||||
|
|
||||||
|
|
||||||
|
def _signal_terms(signals: Sequence[str]) -> set[str]:
|
||||||
|
terms = set(DEFAULT_TERMS)
|
||||||
|
for signal in signals:
|
||||||
|
if not isinstance(signal, str):
|
||||||
|
continue
|
||||||
|
terms.update(
|
||||||
|
token.lower()
|
||||||
|
for token in re.findall(r"[A-Za-z][A-Za-z0-9_.-]{2,63}", signal)
|
||||||
|
)
|
||||||
|
return set(sorted(terms)[:96])
|
||||||
|
|
||||||
|
|
||||||
|
def _profile(source_signals: Sequence[str]) -> str:
|
||||||
|
tokens: set[str] = set()
|
||||||
|
for signal in source_signals:
|
||||||
|
if not isinstance(signal, str):
|
||||||
|
continue
|
||||||
|
tokens.update(
|
||||||
|
token.lower()
|
||||||
|
for token in re.findall(r"[A-Za-z][A-Za-z0-9_.-]{2,63}", signal)
|
||||||
|
)
|
||||||
|
model_score = len(tokens & MODEL_PROFILE_MARKERS)
|
||||||
|
return "model_governance" if model_score >= 2 else "fleet"
|
||||||
|
|
||||||
|
|
||||||
|
def _contract_terms(profile: str) -> Mapping[str, tuple[str, ...]]:
|
||||||
|
if profile == "model_governance":
|
||||||
|
return MODEL_GOVERNANCE_CONTRACT_TERMS
|
||||||
|
if profile == "fleet":
|
||||||
|
return FLEET_CONTRACT_TERMS
|
||||||
|
raise CompactInterfaceMapError("INTERFACE_MAP_PROFILE_INVALID")
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_files(root: Path, terms: set[str]) -> list[dict[str, Any]]:
|
||||||
|
root = root.resolve()
|
||||||
|
candidates: list[dict[str, Any]] = []
|
||||||
|
for relative_root in ROOTS:
|
||||||
|
directory = (root / relative_root).resolve(strict=False)
|
||||||
|
if directory == root or root not in directory.parents:
|
||||||
|
raise CompactInterfaceMapError("INTERFACE_MAP_SCOPE_INVALID")
|
||||||
|
if not directory.is_dir() or directory.is_symlink():
|
||||||
|
continue
|
||||||
|
for path in sorted(directory.rglob("*")):
|
||||||
|
if path.is_symlink() or not path.is_file() or path.suffix.lower() not in SUFFIXES:
|
||||||
|
continue
|
||||||
|
resolved = path.resolve(strict=True)
|
||||||
|
if root not in resolved.parents:
|
||||||
|
raise CompactInterfaceMapError("INTERFACE_MAP_PATH_ESCAPE")
|
||||||
|
data = path.read_bytes()
|
||||||
|
if len(data) > MAX_FILE_BYTES:
|
||||||
|
continue
|
||||||
|
relative = path.relative_to(root).as_posix()
|
||||||
|
text = data.decode("utf-8", "ignore")
|
||||||
|
searchable = (relative + "\n" + text[:384000]).lower()
|
||||||
|
matched = sorted(term for term in terms if term in searchable)
|
||||||
|
if not matched:
|
||||||
|
continue
|
||||||
|
path_bonus = sum(3 for term in terms if term in relative.lower())
|
||||||
|
test_bonus = 3 if relative.startswith("tests/") else 0
|
||||||
|
score = min(250, len(matched) + path_bonus + test_bonus)
|
||||||
|
candidates.append(
|
||||||
|
{
|
||||||
|
"path": relative,
|
||||||
|
"sha256": _sha(data),
|
||||||
|
"bytes": len(data),
|
||||||
|
"score": score,
|
||||||
|
"text": text,
|
||||||
|
"suffix": path.suffix.lower(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
candidates.sort(key=lambda item: (-item["score"], item["path"]))
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _python_symbols(item: Mapping[str, Any], terms: set[str]) -> list[dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
tree = ast.parse(str(item["text"]), filename=str(item["path"]))
|
||||||
|
except SyntaxError:
|
||||||
|
return []
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
class Visitor(ast.NodeVisitor):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.stack: list[str] = []
|
||||||
|
|
||||||
|
def _record(self, node: ast.AST, kind: str, name: str) -> None:
|
||||||
|
qualified = ".".join([*self.stack, name]) if self.stack else name
|
||||||
|
haystack = f"{item['path']} {qualified}".lower()
|
||||||
|
score = sum(3 if term in haystack else 0 for term in terms)
|
||||||
|
if name.startswith("test_"):
|
||||||
|
score += 12
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"path": item["path"],
|
||||||
|
"symbol": qualified,
|
||||||
|
"kind": kind,
|
||||||
|
"line": int(getattr(node, "lineno", 1)),
|
||||||
|
"end_line": int(getattr(node, "end_lineno", getattr(node, "lineno", 1))),
|
||||||
|
"score": score + int(item["score"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def visit_ClassDef(self, node: ast.ClassDef) -> Any:
|
||||||
|
self._record(node, "class", node.name)
|
||||||
|
self.stack.append(node.name)
|
||||||
|
self.generic_visit(node)
|
||||||
|
self.stack.pop()
|
||||||
|
|
||||||
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> Any:
|
||||||
|
self._record(node, "function", node.name)
|
||||||
|
self.stack.append(node.name)
|
||||||
|
self.generic_visit(node)
|
||||||
|
self.stack.pop()
|
||||||
|
|
||||||
|
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> Any:
|
||||||
|
self._record(node, "async_function", node.name)
|
||||||
|
self.stack.append(node.name)
|
||||||
|
self.generic_visit(node)
|
||||||
|
self.stack.pop()
|
||||||
|
|
||||||
|
Visitor().visit(tree)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _surface_ref(symbol: Mapping[str, Any]) -> str:
|
||||||
|
return f"{symbol['path']}::{symbol['symbol']}"
|
||||||
|
|
||||||
|
|
||||||
|
def _contract_map(
|
||||||
|
symbols: Sequence[Mapping[str, Any]],
|
||||||
|
modules: Sequence[Mapping[str, Any]],
|
||||||
|
contract_terms: Mapping[str, tuple[str, ...]],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
module_paths = [str(item["path"]) for item in modules]
|
||||||
|
for contract, terms in contract_terms.items():
|
||||||
|
scored: list[tuple[int, str]] = []
|
||||||
|
for symbol in symbols:
|
||||||
|
haystack = _surface_ref(symbol).lower()
|
||||||
|
score = sum(1 for term in terms if term in haystack)
|
||||||
|
if score:
|
||||||
|
scored.append((score, _surface_ref(symbol)))
|
||||||
|
scored.sort(key=lambda item: (-item[0], item[1]))
|
||||||
|
surfaces = [value for _, value in scored[:6]]
|
||||||
|
if not surfaces:
|
||||||
|
for path in module_paths:
|
||||||
|
score = sum(1 for term in terms if term in path.lower())
|
||||||
|
if score:
|
||||||
|
surfaces.append(path)
|
||||||
|
if len(surfaces) == 6:
|
||||||
|
break
|
||||||
|
result[contract] = {
|
||||||
|
"status": "PARTIAL" if surfaces else "GAP",
|
||||||
|
"surfaces": surfaces,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _gap_evidence(
|
||||||
|
symbols: Sequence[Mapping[str, Any]],
|
||||||
|
profile: str,
|
||||||
|
) -> tuple[list[str], list[str], list[str]]:
|
||||||
|
haystack = "\n".join(_surface_ref(item).lower() for item in symbols)
|
||||||
|
|
||||||
|
if profile == "model_governance":
|
||||||
|
invariants = [
|
||||||
|
"Online Task claim/heartbeat remains the authoritative worker ownership boundary.",
|
||||||
|
"Provider/model credentials remain outside uncredentialed planner/executor processes.",
|
||||||
|
"P0-A tenant/project scope and P0-C trace/audit lineage must constrain model routing.",
|
||||||
|
"P1 retry/checkpoint semantics remain authoritative for model-call execution recovery.",
|
||||||
|
]
|
||||||
|
failure_windows: list[str] = []
|
||||||
|
if "provider" not in haystack and "model" not in haystack:
|
||||||
|
failure_windows.append("No first-class model/provider registry surface detected.")
|
||||||
|
if "budget" not in haystack and "cost" not in haystack and "quota" not in haystack:
|
||||||
|
failure_windows.append("No explicit durable model budget/cost/quota accounting surface detected.")
|
||||||
|
if "fallback" not in haystack and "route" not in haystack:
|
||||||
|
failure_windows.append("No explicit model fallback/routing policy surface detected.")
|
||||||
|
if "eval" not in haystack and "approval" not in haystack:
|
||||||
|
failure_windows.append("No explicit evaluation/approval gate surface detected for model changes.")
|
||||||
|
if "version" not in haystack and "pin" not in haystack:
|
||||||
|
failure_windows.append("No explicit model-version pinning surface detected.")
|
||||||
|
desired = (
|
||||||
|
("trace", "P0-C trace/audit lineage"),
|
||||||
|
("scope", "P0-A tenant/project scope isolation"),
|
||||||
|
("credential", "P0-C credential governance"),
|
||||||
|
("checkpoint", "P1 durable execution recovery"),
|
||||||
|
("message", "P0-B MessageEnvelope/handoff"),
|
||||||
|
("retry", "P1 retry/idempotency"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
invariants = [
|
||||||
|
"Online Task claim/heartbeat remains the authoritative worker ownership boundary.",
|
||||||
|
"Governed mutation remains separated from read-only analysis and requires WriterLease.",
|
||||||
|
"P1 attempt-aware result idempotency and durable checkpoint recovery are reusable fleet primitives.",
|
||||||
|
"Scope/trace/message lineage must remain attached when work is scheduled or reassigned.",
|
||||||
|
]
|
||||||
|
failure_windows = []
|
||||||
|
if "scheduler" not in haystack:
|
||||||
|
failure_windows.append("No first-class durable fleet scheduler symbol detected; worker-local polling may still be the scheduling mechanism.")
|
||||||
|
if "capacity" not in haystack and "slot" not in haystack:
|
||||||
|
failure_windows.append("No explicit durable worker-capacity/slot primitive detected.")
|
||||||
|
if "fair" not in haystack and "starvation" not in haystack:
|
||||||
|
failure_windows.append("No explicit fairness/starvation policy symbol detected.")
|
||||||
|
if "circuit" not in haystack:
|
||||||
|
failure_windows.append("No explicit circuit-breaker primitive detected for repeated worker/task failures.")
|
||||||
|
if "reassign" not in haystack:
|
||||||
|
failure_windows.append("No explicit worker-reassignment primitive detected beyond lease expiry/retry semantics.")
|
||||||
|
desired = (
|
||||||
|
("checkpoint", "P1 durable checkpoint/resume"),
|
||||||
|
("message", "P0-B MessageEnvelope/handoff"),
|
||||||
|
("trace", "P0-C trace correlation"),
|
||||||
|
("scope", "P0-A scope isolation"),
|
||||||
|
("lease", "claim/WriterLease ownership"),
|
||||||
|
("heartbeat", "worker liveness"),
|
||||||
|
("idempot", "retry-safe dispatch/result"),
|
||||||
|
)
|
||||||
|
|
||||||
|
reuse_points: list[str] = []
|
||||||
|
for token, label in desired:
|
||||||
|
match = next((item for item in symbols if token in _surface_ref(item).lower()), None)
|
||||||
|
if match is not None:
|
||||||
|
reuse_points.append(f"{label}: {_surface_ref(match)}")
|
||||||
|
return invariants, failure_windows, reuse_points
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_to_budget(value: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
def size() -> int:
|
||||||
|
return len(json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8"))
|
||||||
|
|
||||||
|
while size() > TARGET_RESULT_BYTES and value["bounded_excerpts"]:
|
||||||
|
value["bounded_excerpts"].pop()
|
||||||
|
while size() > TARGET_RESULT_BYTES and len(value["implementation_map"]["functions"]) > 20:
|
||||||
|
value["implementation_map"]["functions"].pop()
|
||||||
|
while size() > TARGET_RESULT_BYTES and len(value["implementation_map"]["classes"]) > 15:
|
||||||
|
value["implementation_map"]["classes"].pop()
|
||||||
|
while size() > TARGET_RESULT_BYTES and len(value["implementation_map"]["tests"]) > 15:
|
||||||
|
value["implementation_map"]["tests"].pop()
|
||||||
|
while size() > TARGET_RESULT_BYTES and len(value["implementation_map"]["modules"]) > 20:
|
||||||
|
value["implementation_map"]["modules"].pop()
|
||||||
|
final_size = size()
|
||||||
|
if final_size > MAX_RESULT_BYTES:
|
||||||
|
raise CompactInterfaceMapError("INTERFACE_MAP_RESULT_TOO_LARGE")
|
||||||
|
value["result_bytes"] = final_size
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def build_compact_interface_map(
|
||||||
|
worktree: Path,
|
||||||
|
source_signals: Sequence[str],
|
||||||
|
*,
|
||||||
|
source_commit: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
root = Path(worktree)
|
||||||
|
if not root.is_dir() or root.is_symlink():
|
||||||
|
raise CompactInterfaceMapError("INTERFACE_MAP_WORKTREE_INVALID")
|
||||||
|
if re.fullmatch(r"[0-9a-f]{40}", source_commit) is None:
|
||||||
|
raise CompactInterfaceMapError("INTERFACE_MAP_SOURCE_COMMIT_INVALID")
|
||||||
|
|
||||||
|
profile = _profile(source_signals)
|
||||||
|
terms = _signal_terms(source_signals)
|
||||||
|
candidates = _candidate_files(root, terms)
|
||||||
|
if not candidates:
|
||||||
|
raise CompactInterfaceMapError("INTERFACE_MAP_EMPTY")
|
||||||
|
|
||||||
|
module_candidates = candidates[:MAX_MODULES]
|
||||||
|
modules = [
|
||||||
|
{
|
||||||
|
"path": item["path"],
|
||||||
|
"sha256": item["sha256"],
|
||||||
|
"bytes": item["bytes"],
|
||||||
|
"score": item["score"],
|
||||||
|
}
|
||||||
|
for item in module_candidates
|
||||||
|
]
|
||||||
|
|
||||||
|
symbols: list[dict[str, Any]] = []
|
||||||
|
tables: list[dict[str, Any]] = []
|
||||||
|
systemd_units: list[dict[str, Any]] = []
|
||||||
|
for item in candidates:
|
||||||
|
if item["suffix"] == ".py":
|
||||||
|
symbols.extend(_python_symbols(item, terms))
|
||||||
|
elif item["suffix"] == ".sql":
|
||||||
|
for table in SQL_TABLE_RE.findall(str(item["text"])):
|
||||||
|
tables.append({"path": item["path"], "table": table, "sha256": item["sha256"]})
|
||||||
|
elif item["suffix"] == ".service":
|
||||||
|
systemd_units.append({"path": item["path"], "unit": Path(str(item["path"])).name, "sha256": item["sha256"]})
|
||||||
|
|
||||||
|
symbols.sort(key=lambda item: (-int(item["score"]), str(item["path"]), str(item["symbol"])))
|
||||||
|
all_symbols = list(symbols)
|
||||||
|
test_symbols = [
|
||||||
|
item for item in all_symbols
|
||||||
|
if str(item["path"]).startswith("tests/") or str(item["symbol"]).split(".")[-1].startswith("test_")
|
||||||
|
][: min(MAX_TESTS, 24)]
|
||||||
|
non_test_symbols = [
|
||||||
|
item for item in all_symbols
|
||||||
|
if item not in test_symbols
|
||||||
|
][: max(0, MAX_SYMBOLS - len(test_symbols))]
|
||||||
|
symbols = [*test_symbols, *non_test_symbols]
|
||||||
|
symbols.sort(key=lambda item: (-int(item["score"]), str(item["path"]), str(item["symbol"])))
|
||||||
|
classes = [
|
||||||
|
{k: item[k] for k in ("path", "symbol", "line", "end_line")}
|
||||||
|
for item in symbols
|
||||||
|
if item["kind"] == "class"
|
||||||
|
]
|
||||||
|
functions = [
|
||||||
|
{k: item[k] for k in ("path", "symbol", "line", "end_line")}
|
||||||
|
for item in symbols
|
||||||
|
if item["kind"] in {"function", "async_function"} and not str(item["symbol"]).split(".")[-1].startswith("test_")
|
||||||
|
]
|
||||||
|
tests = [
|
||||||
|
{k: item[k] for k in ("path", "symbol", "line", "end_line")}
|
||||||
|
for item in symbols
|
||||||
|
if str(item["path"]).startswith("tests/") or str(item["symbol"]).split(".")[-1].startswith("test_")
|
||||||
|
][:MAX_TESTS]
|
||||||
|
|
||||||
|
excerpts: list[dict[str, Any]] = []
|
||||||
|
by_path = {str(item["path"]): item for item in candidates}
|
||||||
|
for symbol in symbols:
|
||||||
|
if len(excerpts) == MAX_EXCERPTS:
|
||||||
|
break
|
||||||
|
item = by_path.get(str(symbol["path"]))
|
||||||
|
if item is None:
|
||||||
|
continue
|
||||||
|
excerpt = _bounded_excerpt(str(item["text"]), int(symbol["line"]), int(symbol["end_line"]))
|
||||||
|
if not excerpt.strip():
|
||||||
|
continue
|
||||||
|
excerpts.append(
|
||||||
|
{
|
||||||
|
"path": symbol["path"],
|
||||||
|
"symbol": symbol["symbol"],
|
||||||
|
"sha256": item["sha256"],
|
||||||
|
"excerpt": excerpt,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
invariants, failure_windows, reuse_points = _gap_evidence(all_symbols, profile)
|
||||||
|
result = {
|
||||||
|
"schema": SCHEMA,
|
||||||
|
"profile": profile,
|
||||||
|
"classification": PROFILE_CLASSIFICATION[profile],
|
||||||
|
"source_commit": source_commit,
|
||||||
|
"implementation_map": {
|
||||||
|
"modules": modules,
|
||||||
|
"classes": classes[:MAX_SYMBOLS],
|
||||||
|
"functions": functions[:MAX_SYMBOLS],
|
||||||
|
"tables": tables[:40],
|
||||||
|
"systemd_units": systemd_units[:20],
|
||||||
|
"tests": tests,
|
||||||
|
"current_invariants": invariants,
|
||||||
|
"failure_windows": failure_windows,
|
||||||
|
"reuse_points": reuse_points,
|
||||||
|
},
|
||||||
|
"bounded_excerpts": excerpts,
|
||||||
|
"contracts": _contract_map(all_symbols, modules, _contract_terms(profile)),
|
||||||
|
}
|
||||||
|
return _trim_to_budget(result)
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""Durable, authority-bound deterministic execution plans for Online Agent Tasks."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import fcntl
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from aios_online_agent_execution import (
|
||||||
|
GovernedExecutionBinding,
|
||||||
|
project_execution_envelope,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionPlanError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical(value: object) -> bytes:
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _digest(value: object) -> str:
|
||||||
|
return hashlib.sha256(_canonical(value)).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _task_key(task_id: str) -> str:
|
||||||
|
readable = "".join(ch if ch.isalnum() or ch in "._-" else "-" for ch in task_id).strip("-")[:72] or "task"
|
||||||
|
return readable + "-" + hashlib.sha256(task_id.encode("utf-8")).hexdigest()[:24]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExecutionPlanRecord:
|
||||||
|
task_id: str
|
||||||
|
job_id: str
|
||||||
|
source_head_oid: str
|
||||||
|
generation: int
|
||||||
|
idempotency_key: str
|
||||||
|
plan_digest: str
|
||||||
|
execution: dict[str, Any]
|
||||||
|
|
||||||
|
def public(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema": "aios.online-agent.plan-record.v1",
|
||||||
|
"task_id": self.task_id,
|
||||||
|
"job_id": self.job_id,
|
||||||
|
"source_head_oid": self.source_head_oid,
|
||||||
|
"generation": self.generation,
|
||||||
|
"idempotency_key": self.idempotency_key,
|
||||||
|
"plan_digest": self.plan_digest,
|
||||||
|
"execution": dict(self.execution),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionPlanStore:
|
||||||
|
def __init__(self, root: Path) -> None:
|
||||||
|
self.root = Path(root)
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _path(self, task_id: str) -> Path:
|
||||||
|
return self.root / (_task_key(task_id) + ".json")
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _lock(self, task_id: str):
|
||||||
|
path = self.root / (_task_key(task_id) + ".lock")
|
||||||
|
with path.open("a+b") as handle:
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _source_head(binding: GovernedExecutionBinding) -> str:
|
||||||
|
return binding.expected_branch_oid if binding.branch_mode == "CONTINUE_EXISTING" else binding.base_oid
|
||||||
|
|
||||||
|
def _read(self, task_id: str) -> ExecutionPlanRecord | None:
|
||||||
|
path = self._path(task_id)
|
||||||
|
if not path.is_file() or path.is_symlink():
|
||||||
|
return None
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(value, Mapping) or value.get("schema") != "aios.online-agent.plan-record.v1":
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_RECORD_INVALID")
|
||||||
|
execution = value.get("execution")
|
||||||
|
if not isinstance(execution, Mapping):
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_RECORD_INVALID")
|
||||||
|
return ExecutionPlanRecord(
|
||||||
|
task_id=str(value.get("task_id") or ""),
|
||||||
|
job_id=str(value.get("job_id") or ""),
|
||||||
|
source_head_oid=str(value.get("source_head_oid") or ""),
|
||||||
|
generation=int(value.get("generation") or 0),
|
||||||
|
idempotency_key=str(value.get("idempotency_key") or ""),
|
||||||
|
plan_digest=str(value.get("plan_digest") or ""),
|
||||||
|
execution=dict(execution),
|
||||||
|
)
|
||||||
|
|
||||||
|
def register(
|
||||||
|
self,
|
||||||
|
binding: GovernedExecutionBinding,
|
||||||
|
request: Mapping[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
with self._lock(binding.task_id):
|
||||||
|
return self._register_locked(binding, request)
|
||||||
|
|
||||||
|
def _register_locked(
|
||||||
|
self,
|
||||||
|
binding: GovernedExecutionBinding,
|
||||||
|
request: Mapping[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if request.get("schema") != "aios.online-agent.plan.v1":
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_SCHEMA_INVALID")
|
||||||
|
if request.get("task_id") != binding.task_id or request.get("job_id") != binding.job_id:
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_BINDING_CONFLICT")
|
||||||
|
if int(request.get("generation") or 0) != binding.generation:
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_GENERATION_CONFLICT")
|
||||||
|
source_head = str(request.get("source_head_oid") or "")
|
||||||
|
if source_head != self._source_head(binding):
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_SOURCE_HEAD_CONFLICT")
|
||||||
|
key = str(request.get("idempotency_key") or "")
|
||||||
|
if len(key) < 8:
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_IDEMPOTENCY_INVALID")
|
||||||
|
execution_request = request.get("execution")
|
||||||
|
if not isinstance(execution_request, Mapping) or execution_request.get("action") != "deterministic":
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_DETERMINISTIC_REQUIRED")
|
||||||
|
envelope = project_execution_envelope(binding, execution_request)
|
||||||
|
material = {
|
||||||
|
"task_id": binding.task_id,
|
||||||
|
"job_id": binding.job_id,
|
||||||
|
"source_head_oid": source_head,
|
||||||
|
"generation": binding.generation,
|
||||||
|
"idempotency_key": key,
|
||||||
|
"execution": dict(execution_request),
|
||||||
|
}
|
||||||
|
plan_digest = _digest(material)
|
||||||
|
existing = self._read(binding.task_id)
|
||||||
|
expected_previous = request.get("expected_previous_plan_digest")
|
||||||
|
if existing is not None:
|
||||||
|
if existing.idempotency_key == key:
|
||||||
|
if existing.plan_digest != plan_digest:
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_IDEMPOTENCY_CONFLICT")
|
||||||
|
return {**existing.public(), "idempotent": True, "execution_digest": envelope["execution_digest"]}
|
||||||
|
if expected_previous != existing.plan_digest:
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_REPLACEMENT_CAS_MISMATCH")
|
||||||
|
if existing.source_head_oid != source_head:
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_STALE_REPLACEMENT_DENY")
|
||||||
|
elif expected_previous not in (None, ""):
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_REPLACEMENT_CAS_MISMATCH")
|
||||||
|
|
||||||
|
record = ExecutionPlanRecord(
|
||||||
|
task_id=binding.task_id,
|
||||||
|
job_id=binding.job_id,
|
||||||
|
source_head_oid=source_head,
|
||||||
|
generation=binding.generation,
|
||||||
|
idempotency_key=key,
|
||||||
|
plan_digest=plan_digest,
|
||||||
|
execution=dict(execution_request),
|
||||||
|
)
|
||||||
|
path = self._path(binding.task_id)
|
||||||
|
temporary = path.with_suffix(".tmp")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(record.public(), sort_keys=True, separators=(",", ":")),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
temporary.replace(path)
|
||||||
|
return {**record.public(), "idempotent": False, "execution_digest": envelope["execution_digest"]}
|
||||||
|
|
||||||
|
def resolve(self, binding: GovernedExecutionBinding) -> dict[str, Any] | None:
|
||||||
|
record = self._read(binding.task_id)
|
||||||
|
if record is None:
|
||||||
|
return None
|
||||||
|
if (
|
||||||
|
record.task_id != binding.task_id
|
||||||
|
or record.job_id != binding.job_id
|
||||||
|
or record.generation != binding.generation
|
||||||
|
):
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_BINDING_CONFLICT")
|
||||||
|
if record.source_head_oid != self._source_head(binding):
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_SOURCE_HEAD_DRIFT")
|
||||||
|
envelope = project_execution_envelope(binding, record.execution)
|
||||||
|
return {
|
||||||
|
"execution": dict(record.execution),
|
||||||
|
"plan_digest": record.plan_digest,
|
||||||
|
"execution_digest": envelope["execution_digest"],
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,148 +0,0 @@
|
|||||||
"""Fail-closed signed deterministic execution amendment for R7."""
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import hashlib, json, sqlite3
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Mapping
|
|
||||||
from aios_task_terminal_recovery import DeterministicPlan, RecoveryError, canonical_json
|
|
||||||
|
|
||||||
AMENDMENT_SCHEMA="aios.deterministic-execution-amendment.v1"
|
|
||||||
APPLY_SCHEMA="aios.deterministic-execution-amendment-apply.v1"
|
|
||||||
NAMESPACE="aios-owner-approval-v1"
|
|
||||||
EVENT="TASK_RECOVERY_EXECUTION_AMENDMENT_APPLIED"
|
|
||||||
APPLY_STATES=frozenset({"FAILED", "QUEUED"})
|
|
||||||
|
|
||||||
|
|
||||||
def _err(code): raise RecoveryError(code)
|
|
||||||
def _sha(v): return hashlib.sha256(canonical_json(v).encode()).hexdigest()
|
|
||||||
def amendment_body(x):
|
|
||||||
return {k:v for k,v in x.items() if k not in {"signature","amendment_id"}}
|
|
||||||
def amendment_id(x): return "sha256:"+_sha(amendment_body(x))
|
|
||||||
def signed_payload(x):
|
|
||||||
return canonical_json({"amendment_id":x["amendment_id"],**amendment_body(x)}).encode()
|
|
||||||
|
|
||||||
|
|
||||||
def parse_amendment(raw):
|
|
||||||
x=dict(raw)
|
|
||||||
required={"schema","amendment_id","nonce","task_id","job_id","generation",
|
|
||||||
"repository","branch","expected_branch_oid","recovery_epoch","decision_id",
|
|
||||||
"plan_id","plan_sha256","expected_apply_status","expected_apply_revision",
|
|
||||||
"expected_apply_attempt","expected_current_capabilities","envelope",
|
|
||||||
"owner_identity","key_id","issued_at","not_before","expires_at",
|
|
||||||
"one_time_use","signature"}
|
|
||||||
if set(x)<required or x.get("schema")!=AMENDMENT_SCHEMA: _err("AMENDMENT_SCHEMA_INVALID")
|
|
||||||
if x["expected_apply_status"] not in APPLY_STATES: _err("AMENDMENT_APPLY_STATUS_INVALID")
|
|
||||||
if x.get("one_time_use") is not True: _err("AMENDMENT_ONE_TIME_REQUIRED")
|
|
||||||
if not isinstance(x["envelope"],Mapping): _err("AMENDMENT_ENVELOPE_INVALID")
|
|
||||||
if not isinstance(x["expected_current_capabilities"],list): _err("AMENDMENT_CAPABILITIES_INVALID")
|
|
||||||
for k in ("generation","expected_apply_revision","expected_apply_attempt"):
|
|
||||||
if isinstance(x[k],bool) or not isinstance(x[k],int) or x[k]<0: _err("AMENDMENT_CAS_INVALID")
|
|
||||||
for k in ("issued_at","not_before","expires_at"):
|
|
||||||
try:d=datetime.fromisoformat(str(x[k]).replace("Z","+00:00"))
|
|
||||||
except ValueError as e: raise RecoveryError("AMENDMENT_TIME_INVALID") from e
|
|
||||||
if d.tzinfo is None:_err("AMENDMENT_TIME_INVALID")
|
|
||||||
x[k]=d.astimezone(timezone.utc).isoformat().replace("+00:00","Z")
|
|
||||||
x["expected_current_capabilities"]=sorted(set(x["expected_current_capabilities"]))
|
|
||||||
x["envelope"]=dict(x["envelope"])
|
|
||||||
return x
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_envelope(env,plan,x):
|
|
||||||
try:
|
|
||||||
from aios_online_agent_execution import GovernedExecutionBinding,validate_execution_envelope
|
|
||||||
binding=GovernedExecutionBinding.from_mapping(env)
|
|
||||||
e=dict(validate_execution_envelope(env,binding=binding,worker_capabilities=plan.required_capabilities))
|
|
||||||
except Exception as z: raise RecoveryError("AMENDMENT_EXECUTION_ENVELOPE_INVALID") from z
|
|
||||||
if e.get("action")!="deterministic" or e.get("adapter")!="deterministic":_err("AMENDMENT_EXECUTION_ACTION_INVALID")
|
|
||||||
if tuple(sorted(e.get("capability_scope") or ()))!=plan.required_capabilities:_err("AMENDMENT_EXECUTION_CAPABILITIES_MISMATCH")
|
|
||||||
if not e.get("change_set") or not e.get("verification"):_err("AMENDMENT_EXECUTION_OPERATIONS_REQUIRED")
|
|
||||||
if e.get("runtime_input") not in (None,{}) or e.get("source_signals") not in (None,[]):_err("AMENDMENT_STALE_RUNTIME_FIELDS_FORBIDDEN")
|
|
||||||
for k,w in (("task_id",x["task_id"]),("job_id",x["job_id"]),("repository",x["repository"]),
|
|
||||||
("generation",x["generation"]),("target_branch",x["branch"]),
|
|
||||||
("expected_branch_oid",x["expected_branch_oid"]),("base_oid",x["expected_branch_oid"]),
|
|
||||||
("plan_id",x["plan_id"]),("plan_sha256",x["plan_sha256"]),("recovery_epoch",x["recovery_epoch"])):
|
|
||||||
if e.get(k)!=w:_err("AMENDMENT_EXECUTION_BINDING_MISMATCH")
|
|
||||||
g=e.get("governance") or {}
|
|
||||||
if g.get("writer_lease") is not True:_err("AMENDMENT_WRITER_LEASE_REQUIRED")
|
|
||||||
if any(g.get(k,False) for k in ("main_write","production_write","tag_write","baseline_write","force_push","protected_ref_bypass")):_err("AMENDMENT_PROTECTED_WRITE_FORBIDDEN")
|
|
||||||
return e
|
|
||||||
|
|
||||||
|
|
||||||
class DeterministicExecutionAmendmentController:
|
|
||||||
def __init__(self,db_path,verifier,*,clock=None,envelope_validator=None):
|
|
||||||
self.db_path=Path(db_path); self.verifier=verifier
|
|
||||||
self.clock=clock or (lambda:datetime.now(timezone.utc)); self._init()
|
|
||||||
|
|
||||||
def _db(self):
|
|
||||||
c=sqlite3.connect(self.db_path,timeout=10,isolation_level=None); c.row_factory=sqlite3.Row
|
|
||||||
c.execute("PRAGMA busy_timeout=10000"); return c
|
|
||||||
|
|
||||||
def _now(self): return self.clock().astimezone(timezone.utc).isoformat().replace("+00:00","Z")
|
|
||||||
|
|
||||||
def _init(self):
|
|
||||||
with self._db() as c:
|
|
||||||
c.execute("DROP TRIGGER IF EXISTS aios_r7_recovery_capability_projection")
|
|
||||||
c.executescript("""
|
|
||||||
CREATE TABLE IF NOT EXISTS deterministic_execution_amendments(
|
|
||||||
amendment_id TEXT PRIMARY KEY,nonce TEXT UNIQUE NOT NULL,task_id TEXT NOT NULL,
|
|
||||||
decision_id TEXT NOT NULL,plan_id TEXT NOT NULL,plan_sha256 TEXT NOT NULL,
|
|
||||||
envelope_digest TEXT NOT NULL,envelope_json TEXT NOT NULL,payload_json TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,UNIQUE(task_id,decision_id));
|
|
||||||
DROP TRIGGER IF EXISTS aios_r7_recovery_requires_executable_amendment;
|
|
||||||
CREATE TRIGGER aios_r7_recovery_requires_executable_amendment
|
|
||||||
BEFORE UPDATE OF status ON tasks
|
|
||||||
WHEN OLD.status='FAILED' AND NEW.status='QUEUED' BEGIN
|
|
||||||
SELECT CASE WHEN NOT EXISTS(
|
|
||||||
SELECT 1 FROM deterministic_execution_amendments a
|
|
||||||
WHERE a.task_id=OLD.task_id
|
|
||||||
AND a.decision_id=json_extract(NEW.metadata_json,'$.recovery.decision_id')
|
|
||||||
AND a.amendment_id=json_extract(OLD.metadata_json,'$.recovery.execution_amendment_id')
|
|
||||||
AND a.envelope_digest=json_extract(OLD.metadata_json,'$.execution.execution_digest')
|
|
||||||
) THEN RAISE(ABORT,'SIGNED_EXECUTION_AMENDMENT_REQUIRED') END;
|
|
||||||
END;""")
|
|
||||||
|
|
||||||
def _plan(self,c,x):
|
|
||||||
r=c.execute("SELECT * FROM deterministic_plans WHERE plan_id=?",(x["plan_id"],)).fetchone()
|
|
||||||
if r is None or r["plan_sha256"]!=x["plan_sha256"]:_err("AMENDMENT_PLAN_NOT_FOUND_OR_MISMATCH")
|
|
||||||
p=DeterministicPlan.from_dict(json.loads(r["payload_json"]))
|
|
||||||
if (p.task_id,p.job_id,p.generation,p.repository,p.branch,p.expected_branch_oid,p.recovery_epoch)!=(x["task_id"],x["job_id"],x["generation"],x["repository"],x["branch"],x["expected_branch_oid"],x["recovery_epoch"]):_err("AMENDMENT_PLAN_BINDING_MISMATCH")
|
|
||||||
return p
|
|
||||||
|
|
||||||
def persist(self,raw):
|
|
||||||
x=parse_amendment(raw)
|
|
||||||
if x["amendment_id"]!=amendment_id(x):_err("AMENDMENT_ID_DIGEST_MISMATCH")
|
|
||||||
now=self._now()
|
|
||||||
if now<x["not_before"] or now>=x["expires_at"]:_err("AMENDMENT_EXPIRED_OR_NOT_YET_VALID")
|
|
||||||
if not self.verifier.verify(namespace=NAMESPACE,payload=signed_payload(x),signature=x["signature"],key_id=x["key_id"]):_err("AMENDMENT_SIGNATURE_INVALID")
|
|
||||||
with self._db() as c:
|
|
||||||
c.execute("BEGIN IMMEDIATE"); e=_validate_envelope(x["envelope"],self._plan(c,x),x)
|
|
||||||
ej=canonical_json(e); ed=e["execution_digest"]; stored=dict(x); stored["signature"]="<present>"
|
|
||||||
old=c.execute("SELECT * FROM deterministic_execution_amendments WHERE amendment_id=?",(x["amendment_id"],)).fetchone()
|
|
||||||
if old:
|
|
||||||
if old["envelope_json"]!=ej:_err("AMENDMENT_ID_REUSE_MISMATCH")
|
|
||||||
c.commit(); return {"status":"PRESENT","amendment_id":x["amendment_id"],"task_id":x["task_id"],"decision_id":x["decision_id"],"plan_id":x["plan_id"],"execution_digest":ed}
|
|
||||||
if c.execute("SELECT 1 FROM deterministic_execution_amendments WHERE nonce=? OR (task_id=? AND decision_id=?)",(x["nonce"],x["task_id"],x["decision_id"])).fetchone():_err("AMENDMENT_REPLAY_OR_DECISION_REUSE")
|
|
||||||
c.execute("INSERT INTO deterministic_execution_amendments VALUES(?,?,?,?,?,?,?,?,?,?)",(x["amendment_id"],x["nonce"],x["task_id"],x["decision_id"],x["plan_id"],x["plan_sha256"],ed,ej,canonical_json(stored),now)); c.commit()
|
|
||||||
return {"status":"PERSISTED","amendment_id":x["amendment_id"],"task_id":x["task_id"],"decision_id":x["decision_id"],"plan_id":x["plan_id"],"execution_digest":ed}
|
|
||||||
|
|
||||||
def apply(self,raw):
|
|
||||||
if raw.get("schema")!=APPLY_SCHEMA:_err("AMENDMENT_APPLY_SCHEMA_INVALID")
|
|
||||||
aid=raw.get("amendment_id"); tid=raw.get("task_id")
|
|
||||||
with self._db() as c:
|
|
||||||
c.execute("BEGIN IMMEDIATE"); a=c.execute("SELECT * FROM deterministic_execution_amendments WHERE amendment_id=? AND task_id=?",(aid,tid)).fetchone()
|
|
||||||
if a is None:_err("AMENDMENT_NOT_FOUND")
|
|
||||||
x=json.loads(a["payload_json"]); d=json.loads(a["envelope_json"])
|
|
||||||
t=c.execute("SELECT * FROM tasks WHERE task_id=?",(tid,)).fetchone()
|
|
||||||
if t is None:_err("TASK_NOT_FOUND")
|
|
||||||
m=json.loads(t["metadata_json"] or "{}"); cp=tuple(sorted(json.loads(t["required_capabilities_json"] or "[]"))); dc=tuple(sorted(d.get("capability_scope") or ()))
|
|
||||||
rec=m.get("recovery"); rec=dict(rec) if isinstance(rec,Mapping) else {}
|
|
||||||
old=m.get("execution")
|
|
||||||
if isinstance(old,Mapping) and old.get("execution_digest")==a["envelope_digest"] and cp==dc and rec.get("execution_amendment_id")==aid:
|
|
||||||
c.commit(); return {"status":"PRESENT","task_id":tid,"amendment_id":aid,"execution_digest":a["envelope_digest"],"revision":int(t["revision"]),"attempt":int(t["attempt"])}
|
|
||||||
if t["status"]!=x["expected_apply_status"] or int(t["revision"])!=x["expected_apply_revision"] or int(t["attempt"])!=x["expected_apply_attempt"] or cp!=tuple(x["expected_current_capabilities"]) or t["claimed_by"] is not None or t["lease_token_hash"] is not None:_err("AMENDMENT_APPLY_TASK_CAS_MISMATCH")
|
|
||||||
if t["status"]=="QUEUED" and rec.get("decision_id")!=x["decision_id"]:_err("AMENDMENT_DECISION_BINDING_MISMATCH")
|
|
||||||
m["execution"]=d; m["execution_projection"]={"schema":"aios.online-agent.task-projection.v1","task_id":tid,"job_id":d.get("job_id"),"execution_digest":a["envelope_digest"]}
|
|
||||||
rec["execution_amendment_id"]=aid; m["recovery"]=rec; now=self._now()
|
|
||||||
q=c.execute("""UPDATE tasks SET required_capabilities_json=?,metadata_json=?,revision=revision+1,updated_at=? WHERE task_id=? AND status=? AND revision=? AND attempt=? AND claimed_by IS NULL AND lease_token_hash IS NULL""",(canonical_json(list(dc)),canonical_json(m),now,tid,x["expected_apply_status"],x["expected_apply_revision"],x["expected_apply_attempt"]))
|
|
||||||
if q.rowcount!=1:_err("AMENDMENT_APPLY_TASK_CAS_FAILED")
|
|
||||||
c.execute("INSERT INTO events(task_id,agent_id,event_type,payload_json,created_at) VALUES(?,?,?,?,?)",(tid,None,EVENT,canonical_json({"amendment_id":aid,"decision_id":x["decision_id"],"plan_id":x["plan_id"],"execution_digest":a["envelope_digest"],"required_capabilities":list(dc)}),now)); c.commit()
|
|
||||||
return {"status":"APPLIED","task_id":tid,"amendment_id":aid,"decision_id":x["decision_id"],"plan_id":x["plan_id"],"execution_digest":a["envelope_digest"],"required_capabilities":list(dc),"revision":x["expected_apply_revision"]+1,"attempt":x["expected_apply_attempt"]}
|
|
||||||
@@ -1,798 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Fail-closed AF_UNIX builder for server-owned trusted candidate evidence."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
import socket
|
|
||||||
import stat
|
|
||||||
import struct
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Callable, Mapping, Protocol
|
|
||||||
|
|
||||||
|
|
||||||
class BuilderError(RuntimeError):
|
|
||||||
"""A stable, non-sensitive error code returned by the builder boundary."""
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical_json(value: Mapping[str, Any]) -> bytes:
|
|
||||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _sha256(*parts: str) -> str:
|
|
||||||
return hashlib.sha256("\0".join(parts).encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PeerIdentity:
|
|
||||||
pid: int
|
|
||||||
uid: int
|
|
||||||
gid: int
|
|
||||||
|
|
||||||
|
|
||||||
class DomainPurposeSigner(Protocol):
|
|
||||||
"""A signer deliberately limited to the builder's one domain purpose."""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def key_id(self) -> str:
|
|
||||||
...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def key_generation(self) -> int:
|
|
||||||
...
|
|
||||||
|
|
||||||
def sign_for_purpose(self, purpose: str, payload: bytes) -> bytes:
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
class CredentialPathEd25519Signer:
|
|
||||||
"""Reads a systemd-mounted credential path; never exports or logs key bytes."""
|
|
||||||
|
|
||||||
def __init__(self, credential_path: Path, *, key_id: str, key_generation: int, allowed_purpose: str):
|
|
||||||
self._credential_path = Path(credential_path)
|
|
||||||
self._key_id = str(key_id)
|
|
||||||
self._key_generation = key_generation
|
|
||||||
self._allowed_purpose = str(allowed_purpose)
|
|
||||||
if not self._key_id or not isinstance(key_generation, int) or key_generation < 1:
|
|
||||||
raise BuilderError("SIGNING_IDENTITY_INVALID")
|
|
||||||
if not self._credential_path.is_file():
|
|
||||||
raise BuilderError("SIGNING_CREDENTIAL_UNAVAILABLE")
|
|
||||||
try:
|
|
||||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
||||||
|
|
||||||
self._private_key = load_pem_private_key(self._credential_path.read_bytes(), password=None)
|
|
||||||
if not isinstance(self._private_key, Ed25519PrivateKey):
|
|
||||||
raise TypeError("signing key is not Ed25519")
|
|
||||||
except Exception as exc: # do not expose parse/key material details
|
|
||||||
raise BuilderError("SIGNING_CREDENTIAL_INVALID") from exc
|
|
||||||
|
|
||||||
@property
|
|
||||||
def key_id(self) -> str:
|
|
||||||
return self._key_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def key_generation(self) -> int:
|
|
||||||
return self._key_generation
|
|
||||||
|
|
||||||
def sign_for_purpose(self, purpose: str, payload: bytes) -> bytes:
|
|
||||||
if purpose != self._allowed_purpose:
|
|
||||||
raise BuilderError("SIGNING_PURPOSE_DENIED")
|
|
||||||
return self._private_key.sign(payload)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class BuilderKeyring:
|
|
||||||
builder_hmac: bytes
|
|
||||||
signing_private_key_path: Path | None = None
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
if len(self.builder_hmac) < 32:
|
|
||||||
raise BuilderError("BUILDER_HMAC_INVALID")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_credentials_directory(cls, credentials_directory: Path) -> "BuilderKeyring":
|
|
||||||
root = Path(credentials_directory)
|
|
||||||
hmac_path = root / "builder_hmac"
|
|
||||||
signing_path = root / "signing_private_key"
|
|
||||||
try:
|
|
||||||
secret = hmac_path.read_bytes().strip()
|
|
||||||
except OSError as exc:
|
|
||||||
raise BuilderError("BUILDER_HMAC_UNAVAILABLE") from exc
|
|
||||||
if not signing_path.is_file():
|
|
||||||
raise BuilderError("SIGNING_CREDENTIAL_UNAVAILABLE")
|
|
||||||
return cls(builder_hmac=secret, signing_private_key_path=signing_path)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TrustedCandidateBuilderConfig:
|
|
||||||
policy_schema: str
|
|
||||||
repository: str
|
|
||||||
request_schema: str
|
|
||||||
response_schema: str
|
|
||||||
operation: str
|
|
||||||
readiness_operation: str
|
|
||||||
maximum_request_age_seconds: int
|
|
||||||
request_timeout_seconds: int
|
|
||||||
allowed_peer_uid: int
|
|
||||||
allowed_peer_cgroups: tuple[str, ...]
|
|
||||||
allowed_peer_executable_prefix: str
|
|
||||||
allowed_peer_runtime_prefix: str
|
|
||||||
allowed_peer_runtime_suffix: str
|
|
||||||
required_checks: tuple[str, ...]
|
|
||||||
evidence_layout: Mapping[str, str]
|
|
||||||
recipes: Mapping[str, Mapping[str, str]]
|
|
||||||
governed_recipes: Mapping[str, Mapping[str, str]]
|
|
||||||
policy_sha256: str
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_policy(cls, policy: Mapping[str, Any]) -> "TrustedCandidateBuilderConfig":
|
|
||||||
required = {
|
|
||||||
"schema", "repository", "request_schema", "response_schema", "operation",
|
|
||||||
"readiness_operation", "maximum_request_age_seconds", "request_timeout_seconds",
|
|
||||||
"allowed_peer_uid", "allowed_peer_cgroups", "allowed_peer_executable_prefix",
|
|
||||||
"allowed_peer_runtime_prefix", "allowed_peer_runtime_suffix",
|
|
||||||
"required_checks", "evidence_layout", "recipes", "governed_recipes",
|
|
||||||
}
|
|
||||||
if set(policy) != required or policy.get("schema") != "aios.trusted-candidate-builder-policy.v1":
|
|
||||||
raise BuilderError("POLICY_SCHEMA_INVALID")
|
|
||||||
recipes = policy.get("recipes")
|
|
||||||
if not isinstance(recipes, Mapping) or set(recipes) != {"V2R2C2", "V2R2C3"}:
|
|
||||||
raise BuilderError("POLICY_RECIPES_INVALID")
|
|
||||||
c2, c3 = recipes["V2R2C2"], recipes["V2R2C3"]
|
|
||||||
if not isinstance(c2, Mapping) or set(c2) != {"mode", "task_id", "candidate_id", "base_oid", "source_commit", "version", "purpose"} or c2.get("mode") != "fixed":
|
|
||||||
raise BuilderError("POLICY_V2R2C2_INVALID")
|
|
||||||
if not isinstance(c3, Mapping) or set(c3) != {"mode", "derives_from", "version", "purpose"} or c3.get("mode") != "governed-derived-only" or c3.get("derives_from") != "V2R2C2":
|
|
||||||
raise BuilderError("POLICY_V2R2C3_INVALID")
|
|
||||||
if (
|
|
||||||
policy["repository"] != "aios/aios"
|
|
||||||
or policy["operation"] != "BuildTrustedCandidateV1"
|
|
||||||
or policy["readiness_operation"] != "ProbeTrustedCandidateBuilderV1"
|
|
||||||
):
|
|
||||||
raise BuilderError("POLICY_SCOPE_INVALID")
|
|
||||||
if not isinstance(policy["maximum_request_age_seconds"], int) or not 1 <= policy["maximum_request_age_seconds"] <= 300:
|
|
||||||
raise BuilderError("POLICY_FRESHNESS_INVALID")
|
|
||||||
if not isinstance(policy["request_timeout_seconds"], int) or not 1 <= policy["request_timeout_seconds"] <= 120:
|
|
||||||
raise BuilderError("POLICY_TIMEOUT_INVALID")
|
|
||||||
if not isinstance(policy["allowed_peer_uid"], int) or policy["allowed_peer_uid"] != 1000:
|
|
||||||
raise BuilderError("POLICY_PEER_UID_INVALID")
|
|
||||||
cgroups, checks = policy["allowed_peer_cgroups"], policy["required_checks"]
|
|
||||||
if not isinstance(cgroups, list) or not cgroups or not all(isinstance(value, str) and value.startswith("/") for value in cgroups):
|
|
||||||
raise BuilderError("POLICY_CGROUPS_INVALID")
|
|
||||||
for field in (
|
|
||||||
"allowed_peer_executable_prefix",
|
|
||||||
"allowed_peer_runtime_prefix",
|
|
||||||
"allowed_peer_runtime_suffix",
|
|
||||||
):
|
|
||||||
if not isinstance(policy[field], str) or not policy[field].startswith("/"):
|
|
||||||
raise BuilderError("POLICY_PROCESS_IDENTITY_INVALID")
|
|
||||||
if not isinstance(checks, list) or not checks or len(set(checks)) != len(checks) or not all(isinstance(value, str) for value in checks):
|
|
||||||
raise BuilderError("POLICY_CHECKS_INVALID")
|
|
||||||
layout = policy["evidence_layout"]
|
|
||||||
evidence_names = {
|
|
||||||
"artifact", "manifest", "package_closure", "tests", "canary",
|
|
||||||
"gitea", "rollback", "policy", "secret_scan",
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
not isinstance(layout, Mapping)
|
|
||||||
or set(layout) != evidence_names
|
|
||||||
or not all(
|
|
||||||
isinstance(value, str)
|
|
||||||
and value.startswith("{version}/")
|
|
||||||
and ".." not in Path(value).parts
|
|
||||||
and not Path(value).is_absolute()
|
|
||||||
for value in layout.values()
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise BuilderError("POLICY_EVIDENCE_LAYOUT_INVALID")
|
|
||||||
return cls(
|
|
||||||
policy_schema=str(policy["schema"]), repository=str(policy["repository"]),
|
|
||||||
request_schema=str(policy["request_schema"]), response_schema=str(policy["response_schema"]),
|
|
||||||
operation=str(policy["operation"]), readiness_operation=str(policy["readiness_operation"]),
|
|
||||||
maximum_request_age_seconds=policy["maximum_request_age_seconds"],
|
|
||||||
request_timeout_seconds=policy["request_timeout_seconds"],
|
|
||||||
allowed_peer_uid=policy["allowed_peer_uid"], allowed_peer_cgroups=tuple(cgroups),
|
|
||||||
allowed_peer_executable_prefix=policy["allowed_peer_executable_prefix"],
|
|
||||||
allowed_peer_runtime_prefix=policy["allowed_peer_runtime_prefix"],
|
|
||||||
allowed_peer_runtime_suffix=policy["allowed_peer_runtime_suffix"],
|
|
||||||
required_checks=tuple(checks), evidence_layout=dict(layout),
|
|
||||||
recipes={name: dict(value) for name, value in recipes.items()},
|
|
||||||
governed_recipes={name: dict(value) for name, value in policy["governed_recipes"].items()} if isinstance(policy["governed_recipes"], Mapping) else _invalid_governed_recipes(),
|
|
||||||
policy_sha256=hashlib.sha256(_canonical_json(dict(policy))).hexdigest(),
|
|
||||||
)
|
|
||||||
|
|
||||||
def fixed_recipe(self, name: str) -> Mapping[str, str]:
|
|
||||||
recipe = self.recipes.get(name)
|
|
||||||
if recipe is None:
|
|
||||||
raise BuilderError("RECIPE_UNKNOWN")
|
|
||||||
if recipe["mode"] == "governed-derived-only":
|
|
||||||
derived = self.governed_recipes.get(name)
|
|
||||||
required = {"mode", "task_id", "candidate_id", "base_oid", "source_commit", "version", "purpose", "derived_from_v2r2c2_attestation", "builder_engineering_oid"}
|
|
||||||
if not isinstance(derived, Mapping) or set(derived) != required or derived.get("mode") != "governed-derived" or derived.get("version") != name or derived.get("purpose") != recipe.get("purpose"):
|
|
||||||
raise BuilderError("GOVERNED_V2R2C3_DERIVATION_REQUIRED")
|
|
||||||
for name in ("base_oid", "source_commit", "builder_engineering_oid"):
|
|
||||||
value = derived.get(name)
|
|
||||||
if not isinstance(value, str) or len(value) != 40 or any(char not in "0123456789abcdef" for char in value.lower()):
|
|
||||||
raise BuilderError("GOVERNED_V2R2C3_DERIVATION_REQUIRED")
|
|
||||||
return dict(derived)
|
|
||||||
return recipe
|
|
||||||
|
|
||||||
def resolve_recipe(self, version: str, candidate_id: str | None, source_commit: str) -> Mapping[str, str]:
|
|
||||||
recipe = self.fixed_recipe(version)
|
|
||||||
# Intent-only callers do not choose a candidate identity. The
|
|
||||||
# policy-owned recipe supplies it; explicit worker requests are
|
|
||||||
# still checked against the same immutable recipe.
|
|
||||||
effective_candidate_id = str(candidate_id or recipe.get("candidate_id") or "")
|
|
||||||
if {"version": recipe.get("version"), "candidate_id": recipe.get("candidate_id"), "source_commit": recipe.get("source_commit")} != {"version": version, "candidate_id": effective_candidate_id, "source_commit": source_commit}:
|
|
||||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
|
||||||
return recipe
|
|
||||||
|
|
||||||
|
|
||||||
def _invalid_governed_recipes() -> Mapping[str, Mapping[str, str]]:
|
|
||||||
raise BuilderError("POLICY_GOVERNED_RECIPES_INVALID")
|
|
||||||
|
|
||||||
|
|
||||||
def load_builder_policy(path: Path) -> Mapping[str, Any]:
|
|
||||||
try:
|
|
||||||
decoded = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
|
||||||
raise BuilderError("POLICY_UNAVAILABLE") from exc
|
|
||||||
if not isinstance(decoded, Mapping):
|
|
||||||
raise BuilderError("POLICY_SCHEMA_INVALID")
|
|
||||||
TrustedCandidateBuilderConfig.from_policy(decoded)
|
|
||||||
return decoded
|
|
||||||
|
|
||||||
|
|
||||||
class ServerOwnedEvidenceSource:
|
|
||||||
"""Recomputes a fixed on-host evidence closure without caller-owned paths."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
config: TrustedCandidateBuilderConfig,
|
|
||||||
authority_git_dir: Path,
|
|
||||||
evidence_root: Path,
|
|
||||||
*,
|
|
||||||
expected_owner_uid: int | None = 0,
|
|
||||||
git_runner: Callable[..., Any] = subprocess.run,
|
|
||||||
):
|
|
||||||
self._config = config
|
|
||||||
self._authority_git_dir = Path(authority_git_dir)
|
|
||||||
self._evidence_root = Path(evidence_root)
|
|
||||||
self._expected_owner_uid = expected_owner_uid
|
|
||||||
self._git_runner = git_runner
|
|
||||||
|
|
||||||
def _regular_path(self, relative: str) -> Path:
|
|
||||||
root = self._evidence_root.resolve(strict=True)
|
|
||||||
candidate = self._evidence_root / relative
|
|
||||||
current = self._evidence_root
|
|
||||||
for part in Path(relative).parts:
|
|
||||||
current = current / part
|
|
||||||
if current.is_symlink():
|
|
||||||
raise BuilderError("COLLECTOR_SYMLINK_DENIED")
|
|
||||||
try:
|
|
||||||
resolved = candidate.resolve(strict=True)
|
|
||||||
resolved.relative_to(root)
|
|
||||||
metadata = resolved.stat(follow_symlinks=False)
|
|
||||||
except (OSError, ValueError) as exc:
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_UNAVAILABLE") from exc
|
|
||||||
if not stat.S_ISREG(metadata.st_mode):
|
|
||||||
raise BuilderError("COLLECTOR_SPECIAL_FILE_DENIED")
|
|
||||||
if os.name != "nt" and metadata.st_mode & 0o022:
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_MODE_DENIED")
|
|
||||||
if (
|
|
||||||
os.name != "nt"
|
|
||||||
and
|
|
||||||
self._expected_owner_uid is not None
|
|
||||||
and metadata.st_uid != self._expected_owner_uid
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_OWNER_DENIED")
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
def _digest(self, path: Path) -> str:
|
|
||||||
digest = hashlib.sha256()
|
|
||||||
with path.open("rb") as handle:
|
|
||||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
||||||
digest.update(chunk)
|
|
||||||
return digest.hexdigest()
|
|
||||||
|
|
||||||
def _json(self, path: Path) -> Mapping[str, Any]:
|
|
||||||
try:
|
|
||||||
if path.stat().st_size > 2 * 1024 * 1024:
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_SIZE_INVALID")
|
|
||||||
value = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID") from exc
|
|
||||||
if not isinstance(value, Mapping):
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
|
||||||
return value
|
|
||||||
|
|
||||||
def __call__(self, recipe_name: str, recipe: Mapping[str, str]) -> Mapping[str, Any]:
|
|
||||||
try:
|
|
||||||
tree_result = self._git_runner(
|
|
||||||
["git", f"--git-dir={self._authority_git_dir}", "rev-parse", f"{recipe['source_commit']}^{{tree}}"],
|
|
||||||
check=True, capture_output=True, text=True, timeout=15,
|
|
||||||
env={"PATH": os.environ.get("PATH", "")},
|
|
||||||
)
|
|
||||||
ancestry_result = self._git_runner(
|
|
||||||
["git", f"--git-dir={self._authority_git_dir}", "merge-base", "--is-ancestor", recipe["base_oid"], recipe["source_commit"]],
|
|
||||||
check=False, capture_output=True, text=True, timeout=15,
|
|
||||||
env={"PATH": os.environ.get("PATH", "")},
|
|
||||||
)
|
|
||||||
tree_oid = tree_result.stdout.strip().lower()
|
|
||||||
except (OSError, subprocess.SubprocessError) as exc:
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_UNAVAILABLE") from exc
|
|
||||||
if getattr(ancestry_result, "returncode", 1) != 0:
|
|
||||||
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
|
||||||
|
|
||||||
receipt = self._json(self._regular_path(f"{recipe_name}/receipt.json"))
|
|
||||||
if set(receipt) != {"schema", "version", "candidate", "release_plan"}:
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
|
||||||
if (
|
|
||||||
receipt.get("schema") != "aios.trusted-candidate-builder.evidence.v1"
|
|
||||||
or receipt.get("version") != recipe_name
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
|
||||||
candidate = receipt.get("candidate")
|
|
||||||
release_plan = receipt.get("release_plan")
|
|
||||||
if not isinstance(candidate, Mapping) or not isinstance(release_plan, Mapping):
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
|
||||||
if (
|
|
||||||
candidate.get("candidate_id") != recipe["candidate_id"]
|
|
||||||
or candidate.get("source_commit") != recipe["source_commit"]
|
|
||||||
or candidate.get("tree_oid") != tree_oid
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
|
||||||
git_consistency = candidate.get("git_consistency")
|
|
||||||
if (
|
|
||||||
not isinstance(git_consistency, Mapping)
|
|
||||||
or git_consistency.get("consistent") is not True
|
|
||||||
or git_consistency.get("source_commit") != recipe["source_commit"]
|
|
||||||
or git_consistency.get("tree_oid") != tree_oid
|
|
||||||
or git_consistency.get("repository") != self._config.repository
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
|
||||||
|
|
||||||
paths = {
|
|
||||||
name: self._regular_path(template.format(version=recipe_name))
|
|
||||||
for name, template in self._config.evidence_layout.items()
|
|
||||||
}
|
|
||||||
deployment = release_plan.get("deployment")
|
|
||||||
if not isinstance(deployment, Mapping):
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
|
||||||
digests = {name: self._digest(path) for name, path in paths.items()}
|
|
||||||
if (
|
|
||||||
digests["artifact"] != candidate.get("artifact_sha256")
|
|
||||||
or digests["manifest"] != candidate.get("manifest_sha256")
|
|
||||||
or digests["package_closure"] != deployment.get("package_closure_sha256")
|
|
||||||
or digests["policy"] != release_plan.get("policy_sha256")
|
|
||||||
or digests["policy"] != deployment.get("policy_sha256")
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_DIGEST_MISMATCH")
|
|
||||||
|
|
||||||
tests = self._json(paths["tests"])
|
|
||||||
canary = self._json(paths["canary"])
|
|
||||||
gitea = self._json(paths["gitea"])
|
|
||||||
rollback = self._json(paths["rollback"])
|
|
||||||
secret_scan = self._json(paths["secret_scan"])
|
|
||||||
if tests != candidate.get("tests") or tests.get("passed") is not True:
|
|
||||||
raise BuilderError("COLLECTOR_TESTS_MISMATCH")
|
|
||||||
if canary != candidate.get("canary") or canary.get("passed") is not True:
|
|
||||||
raise BuilderError("COLLECTOR_CANARY_MISMATCH")
|
|
||||||
if (
|
|
||||||
digests["tests"] != hashlib.sha256(_canonical_json(dict(tests))).hexdigest()
|
|
||||||
or digests["canary"] != hashlib.sha256(_canonical_json(dict(canary))).hexdigest()
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_CANONICALIZATION_MISMATCH")
|
|
||||||
if (
|
|
||||||
gitea != candidate.get("gitea_consistency")
|
|
||||||
or gitea.get("consistent") is not True
|
|
||||||
or gitea.get("repository") != self._config.repository
|
|
||||||
or gitea.get("source_commit") != recipe["source_commit"]
|
|
||||||
or gitea.get("tree_oid") != tree_oid
|
|
||||||
or gitea.get("remote_ref_oid") != recipe["source_commit"]
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_GITEA_MISMATCH")
|
|
||||||
if (
|
|
||||||
candidate.get("rollback_ready") is not True
|
|
||||||
or rollback.get("ready") is not True
|
|
||||||
or rollback.get("source_commit") != recipe["source_commit"]
|
|
||||||
or rollback.get("rollback_sha256") != deployment.get("rollback_sha256")
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_ROLLBACK_MISMATCH")
|
|
||||||
if (
|
|
||||||
digests["gitea"] != hashlib.sha256(_canonical_json(dict(gitea))).hexdigest()
|
|
||||||
or digests["rollback"] != hashlib.sha256(_canonical_json(dict(rollback))).hexdigest()
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_CANONICALIZATION_MISMATCH")
|
|
||||||
if (
|
|
||||||
secret_scan.get("passed") is not True
|
|
||||||
or secret_scan.get("artifact_sha256") != candidate.get("artifact_sha256")
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_SECRET_SCAN_MISMATCH")
|
|
||||||
checks = {name: True for name in self._config.required_checks}
|
|
||||||
return {
|
|
||||||
"candidate": dict(candidate),
|
|
||||||
"release_plan": dict(release_plan),
|
|
||||||
"checks": checks,
|
|
||||||
"evidence_digests": digests,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class DeterministicEvidenceCollector:
|
|
||||||
"""Validates a server-owned source; request fields never influence collection."""
|
|
||||||
|
|
||||||
_BUNDLE_FIELDS = {"candidate", "release_plan", "checks", "evidence_digests"}
|
|
||||||
|
|
||||||
def __init__(self, config: TrustedCandidateBuilderConfig, *, evidence_source: Callable[[str, Mapping[str, str]], Mapping[str, Any]] | None = None):
|
|
||||||
self._config = config
|
|
||||||
self._evidence_source = evidence_source
|
|
||||||
|
|
||||||
def collect(self, recipe_name: str, recipe: Mapping[str, str]) -> Mapping[str, Any]:
|
|
||||||
if self._evidence_source is None:
|
|
||||||
raise BuilderError("COLLECTOR_UNCONFIGURED")
|
|
||||||
bundle = self._evidence_source(recipe_name, recipe)
|
|
||||||
if not isinstance(bundle, Mapping) or set(bundle) != self._BUNDLE_FIELDS:
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
|
||||||
raw_checks = bundle.get("checks")
|
|
||||||
if (
|
|
||||||
not isinstance(raw_checks, Mapping)
|
|
||||||
or set(raw_checks) != set(self._config.required_checks)
|
|
||||||
or any(raw_checks.get(name) is not True for name in self._config.required_checks)
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_CHECKS_INCOMPLETE")
|
|
||||||
evidence_digests = bundle.get("evidence_digests")
|
|
||||||
if (
|
|
||||||
not isinstance(evidence_digests, Mapping)
|
|
||||||
or set(evidence_digests) != set(self._config.evidence_layout)
|
|
||||||
or any(
|
|
||||||
not isinstance(value, str)
|
|
||||||
or len(value) != 64
|
|
||||||
or any(char not in "0123456789abcdef" for char in value.lower())
|
|
||||||
for value in evidence_digests.values()
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_DIGESTS_INVALID")
|
|
||||||
try:
|
|
||||||
from silent_release_controller import ReleaseCandidate
|
|
||||||
from silent_release_hardening import ReleasePlan, TrustedCandidateBundle
|
|
||||||
|
|
||||||
candidate = ReleaseCandidate.from_dict(dict(bundle["candidate"]))
|
|
||||||
release_plan = ReleasePlan.from_dict(dict(bundle["release_plan"]))
|
|
||||||
checks = dict(raw_checks)
|
|
||||||
TrustedCandidateBundle(candidate, release_plan, checks).validate()
|
|
||||||
except (ImportError, KeyError, TypeError, ValueError, RuntimeError) as exc:
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID") from exc
|
|
||||||
if candidate.source_commit != recipe["source_commit"] or candidate.candidate_id != recipe["candidate_id"]:
|
|
||||||
raise BuilderError("COLLECTOR_SOURCE_MISMATCH")
|
|
||||||
return {
|
|
||||||
"candidate": candidate.to_dict(),
|
|
||||||
"release_plan": release_plan.to_dict(),
|
|
||||||
"checks": checks,
|
|
||||||
"evidence_digests": dict(evidence_digests),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_peer_identity(connection: socket.socket) -> PeerIdentity:
|
|
||||||
if not hasattr(socket, "SO_PEERCRED"):
|
|
||||||
raise BuilderError("SO_PEERCRED_UNAVAILABLE")
|
|
||||||
raw = connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i"))
|
|
||||||
if len(raw) != struct.calcsize("3i"):
|
|
||||||
raise BuilderError("SO_PEERCRED_INVALID")
|
|
||||||
return PeerIdentity(*struct.unpack("3i", raw))
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_cgroups(pid: int) -> tuple[str, ...]:
|
|
||||||
try:
|
|
||||||
return tuple(
|
|
||||||
line.rsplit(":", 1)[1].strip()
|
|
||||||
for line in Path(f"/proc/{pid}/cgroup").read_text(encoding="utf-8").splitlines()
|
|
||||||
if ":" in line and line.rsplit(":", 1)[1].strip().startswith("/")
|
|
||||||
)
|
|
||||||
except OSError as exc:
|
|
||||||
raise BuilderError("PEER_CGROUP_UNAVAILABLE") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_process_identity(pid: int) -> tuple[str, tuple[str, ...]]:
|
|
||||||
try:
|
|
||||||
executable = os.path.realpath(f"/proc/{pid}/exe")
|
|
||||||
argv = tuple(
|
|
||||||
item.decode("utf-8", "strict")
|
|
||||||
for item in Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0")
|
|
||||||
if item
|
|
||||||
)
|
|
||||||
except (OSError, UnicodeDecodeError) as exc:
|
|
||||||
raise BuilderError("PEER_PROCESS_UNAVAILABLE") from exc
|
|
||||||
if not executable or not argv:
|
|
||||||
raise BuilderError("PEER_PROCESS_UNAVAILABLE")
|
|
||||||
return executable, argv
|
|
||||||
|
|
||||||
|
|
||||||
class TrustedCandidateBuilder:
|
|
||||||
def __init__(
|
|
||||||
self, config: TrustedCandidateBuilderConfig, keyring: BuilderKeyring, *,
|
|
||||||
collector: DeterministicEvidenceCollector, signer: DomainPurposeSigner,
|
|
||||||
peer_identity_resolver: Callable[[socket.socket], PeerIdentity] = _extract_peer_identity,
|
|
||||||
cgroup_resolver: Callable[[int], tuple[str, ...] | str] = _resolve_cgroups,
|
|
||||||
process_identity_resolver: Callable[[int], tuple[str, tuple[str, ...]]] = _resolve_process_identity,
|
|
||||||
clock: Callable[[], float] = time.time, nonce_ledger_path: Path | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.config, self.keyring, self.collector, self.signer = config, keyring, collector, signer
|
|
||||||
self.peer_identity_resolver, self.cgroup_resolver, self.clock = peer_identity_resolver, cgroup_resolver, clock
|
|
||||||
self.process_identity_resolver = process_identity_resolver
|
|
||||||
self._nonce_ledger_path = Path(nonce_ledger_path) if nonce_ledger_path else None
|
|
||||||
self._nonce_ledger: dict[str, tuple[bytes, dict[str, Any]]] = self._load_nonce_ledger()
|
|
||||||
|
|
||||||
def _load_nonce_ledger(self) -> dict[str, tuple[bytes, dict[str, Any]]]:
|
|
||||||
if self._nonce_ledger_path is None or not self._nonce_ledger_path.exists():
|
|
||||||
return {}
|
|
||||||
if self._nonce_ledger_path.is_symlink():
|
|
||||||
raise BuilderError("NONCE_LEDGER_INVALID")
|
|
||||||
try:
|
|
||||||
raw = json.loads(self._nonce_ledger_path.read_text(encoding="utf-8"))
|
|
||||||
return {nonce: (bytes.fromhex(value["request_sha256"]), dict(value["result"])) for nonce, value in raw.items()}
|
|
||||||
except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError) as exc:
|
|
||||||
raise BuilderError("NONCE_LEDGER_INVALID") from exc
|
|
||||||
|
|
||||||
def _persist_nonce_ledger(self) -> None:
|
|
||||||
if self._nonce_ledger_path is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
self._nonce_ledger_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
||||||
temporary = self._nonce_ledger_path.with_suffix(".tmp")
|
|
||||||
payload = {nonce: {"request_sha256": digest.hex(), "result": result} for nonce, (digest, result) in self._nonce_ledger.items()}
|
|
||||||
with temporary.open("wb") as handle:
|
|
||||||
handle.write(_canonical_json(payload))
|
|
||||||
handle.flush()
|
|
||||||
os.fsync(handle.fileno())
|
|
||||||
os.chmod(temporary, 0o600)
|
|
||||||
os.replace(temporary, self._nonce_ledger_path)
|
|
||||||
if os.name != "nt":
|
|
||||||
descriptor = os.open(self._nonce_ledger_path.parent, os.O_RDONLY)
|
|
||||||
try:
|
|
||||||
os.fsync(descriptor)
|
|
||||||
finally:
|
|
||||||
os.close(descriptor)
|
|
||||||
except OSError as exc:
|
|
||||||
raise BuilderError("NONCE_LEDGER_PERSIST_FAILED") from exc
|
|
||||||
|
|
||||||
def _verify_peer(self, connection: socket.socket) -> None:
|
|
||||||
peer = self.peer_identity_resolver(connection)
|
|
||||||
if peer.uid != self.config.allowed_peer_uid or peer.pid <= 0:
|
|
||||||
raise BuilderError("PEER_IDENTITY_DENIED")
|
|
||||||
resolved = self.cgroup_resolver(peer.pid)
|
|
||||||
cgroups = (resolved,) if isinstance(resolved, str) else tuple(resolved)
|
|
||||||
if not cgroups or not any(item in self.config.allowed_peer_cgroups for item in cgroups):
|
|
||||||
raise BuilderError("PEER_CGROUP_DENIED")
|
|
||||||
executable, argv = self.process_identity_resolver(peer.pid)
|
|
||||||
if not executable.startswith(self.config.allowed_peer_executable_prefix):
|
|
||||||
raise BuilderError("PEER_PROCESS_DENIED")
|
|
||||||
runtime_arguments = [
|
|
||||||
value
|
|
||||||
for value in argv
|
|
||||||
if value.startswith(self.config.allowed_peer_runtime_prefix)
|
|
||||||
and value.endswith(self.config.allowed_peer_runtime_suffix)
|
|
||||||
]
|
|
||||||
if len(runtime_arguments) != 1:
|
|
||||||
raise BuilderError("PEER_PROCESS_DENIED")
|
|
||||||
|
|
||||||
def handle_request(self, request: Mapping[str, Any], *, connection: socket.socket) -> dict[str, Any]:
|
|
||||||
if not isinstance(request, Mapping):
|
|
||||||
raise BuilderError("REQUEST_SCHEMA_INVALID")
|
|
||||||
expected_keys = {"schema", "operation", "recipe", "repository", "task_id", "candidate_id", "base_oid", "source_commit", "version", "purpose", "issued_at", "nonce", "hmac"}
|
|
||||||
if (
|
|
||||||
set(request) != expected_keys
|
|
||||||
or request.get("schema") != self.config.request_schema
|
|
||||||
or request.get("operation") not in {
|
|
||||||
self.config.operation,
|
|
||||||
self.config.readiness_operation,
|
|
||||||
}
|
|
||||||
):
|
|
||||||
raise BuilderError("REQUEST_SCHEMA_INVALID")
|
|
||||||
supplied_mac = request.get("hmac")
|
|
||||||
unsigned = {key: value for key, value in request.items() if key != "hmac"}
|
|
||||||
if not isinstance(supplied_mac, str) or not hmac.compare_digest(hmac.new(self.keyring.builder_hmac, _canonical_json(unsigned), hashlib.sha256).hexdigest(), supplied_mac):
|
|
||||||
raise BuilderError("REQUEST_HMAC_INVALID")
|
|
||||||
issued_at, nonce = request.get("issued_at"), request.get("nonce")
|
|
||||||
if not isinstance(issued_at, int) or not isinstance(nonce, str) or len(nonce) < 16 or len(nonce) > 256:
|
|
||||||
raise BuilderError("REQUEST_SCHEMA_INVALID")
|
|
||||||
self._verify_peer(connection)
|
|
||||||
request_bytes = _canonical_json(dict(request))
|
|
||||||
replay = self._nonce_ledger.get(nonce)
|
|
||||||
if replay is not None:
|
|
||||||
if hmac.compare_digest(replay[0], hashlib.sha256(request_bytes).digest()):
|
|
||||||
return dict(replay[1])
|
|
||||||
raise BuilderError("REPLAY_CONFLICT")
|
|
||||||
if abs(int(self.clock()) - issued_at) > self.config.maximum_request_age_seconds:
|
|
||||||
raise BuilderError("REQUEST_STALE")
|
|
||||||
recipe_name = request.get("recipe")
|
|
||||||
if not isinstance(recipe_name, str):
|
|
||||||
raise BuilderError("REQUEST_SCHEMA_INVALID")
|
|
||||||
recipe = self.config.resolve_recipe(str(request.get("version") or ""), str(request.get("candidate_id") or ""), str(request.get("source_commit") or ""))
|
|
||||||
if recipe_name != recipe.get("version"):
|
|
||||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
|
||||||
bound = {"repository": self.config.repository, "task_id": recipe["task_id"], "candidate_id": recipe["candidate_id"], "base_oid": recipe["base_oid"], "source_commit": recipe["source_commit"], "version": recipe["version"], "purpose": recipe["purpose"]}
|
|
||||||
if {key: request.get(key) for key in bound} != bound:
|
|
||||||
raise BuilderError("REQUEST_SCOPE_INVALID")
|
|
||||||
bundle = dict(self.collector.collect(recipe_name, recipe))
|
|
||||||
candidate = bundle["candidate"]
|
|
||||||
release_plan = bundle["release_plan"]
|
|
||||||
checks = bundle.get("checks")
|
|
||||||
evidence_digests = bundle["evidence_digests"]
|
|
||||||
if not isinstance(checks, Mapping) or set(checks) != set(self.config.required_checks) or not all(value is True for value in checks.values()):
|
|
||||||
raise BuilderError("COLLECTOR_CHECKS_INCOMPLETE")
|
|
||||||
if request["operation"] == self.config.readiness_operation:
|
|
||||||
result = {
|
|
||||||
"schema": self.config.response_schema,
|
|
||||||
"status": "READY",
|
|
||||||
"repository": self.config.repository,
|
|
||||||
"recipe": recipe_name,
|
|
||||||
"candidate_id": recipe["candidate_id"],
|
|
||||||
"source_commit": recipe["source_commit"],
|
|
||||||
"builder_key_id": self.signer.key_id,
|
|
||||||
"builder_key_generation": self.signer.key_generation,
|
|
||||||
"signature_algorithm": "Ed25519",
|
|
||||||
"signature_purpose": "AIOS_TRUSTED_RELEASE_BUILDER",
|
|
||||||
"policy_sha256": self.config.policy_sha256,
|
|
||||||
"collector_ready": True,
|
|
||||||
"signer_ready": True,
|
|
||||||
}
|
|
||||||
if len(self._nonce_ledger) >= 2048:
|
|
||||||
self._nonce_ledger.pop(next(iter(self._nonce_ledger)))
|
|
||||||
self._nonce_ledger[nonce] = (
|
|
||||||
hashlib.sha256(request_bytes).digest(),
|
|
||||||
dict(result),
|
|
||||||
)
|
|
||||||
self._persist_nonce_ledger()
|
|
||||||
return result
|
|
||||||
issued = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
||||||
attestation = {
|
|
||||||
"builder_attestation_id": "builder-attestation-" + _sha256(recipe_name, recipe["task_id"], candidate["artifact_sha256"])[:24],
|
|
||||||
"builder_key_id": self.signer.key_id,
|
|
||||||
"builder_key_generation": self.signer.key_generation,
|
|
||||||
"signature_algorithm": "Ed25519",
|
|
||||||
"signature_purpose": "AIOS_TRUSTED_RELEASE_BUILDER",
|
|
||||||
"source_commit": candidate["source_commit"], "tree_oid": candidate["tree_oid"],
|
|
||||||
"artifact_sha256": candidate["artifact_sha256"], "manifest_sha256": candidate["manifest_sha256"],
|
|
||||||
"package_closure_sha256": release_plan["deployment"]["package_closure_sha256"],
|
|
||||||
"test_evidence_sha256": evidence_digests["tests"],
|
|
||||||
"canary_evidence_sha256": evidence_digests["canary"],
|
|
||||||
"gitea_evidence_sha256": evidence_digests["gitea"],
|
|
||||||
"rollback_evidence_sha256": evidence_digests["rollback"],
|
|
||||||
"policy_evidence_sha256": evidence_digests["policy"],
|
|
||||||
"secret_scan_evidence_sha256": evidence_digests["secret_scan"],
|
|
||||||
"issued_at": issued, "trust_domain": "aios.trusted-builder.attestation.v1",
|
|
||||||
}
|
|
||||||
canonical_attestation = _canonical_json(attestation)
|
|
||||||
fingerprint = hashlib.sha256(canonical_attestation).hexdigest()
|
|
||||||
signature = base64.b64encode(self.signer.sign_for_purpose("AIOS_TRUSTED_RELEASE_BUILDER", canonical_attestation)).decode("ascii")
|
|
||||||
result = {
|
|
||||||
"schema": self.config.response_schema, "status": "BUILT", "recipe": recipe_name,
|
|
||||||
"repository": self.config.repository, "task_id": recipe["task_id"], "base_oid": recipe["base_oid"],
|
|
||||||
"purpose": recipe["purpose"], "checks": dict(checks),
|
|
||||||
"evidence": {"candidate_id": candidate["candidate_id"], "source_commit": candidate["source_commit"], "tree_oid": candidate["tree_oid"], "artifact_sha256": candidate["artifact_sha256"], "manifest_sha256": candidate["manifest_sha256"], "package_closure_sha256": release_plan["deployment"]["package_closure_sha256"], "test_evidence_sha256": attestation["test_evidence_sha256"], "canary_evidence_sha256": attestation["canary_evidence_sha256"]},
|
|
||||||
"attestation": {**attestation, "fingerprint": fingerprint, "signature": signature},
|
|
||||||
}
|
|
||||||
result["silent_release_bundle"] = {"candidate": candidate, "release_plan": release_plan, "checks": dict(checks), "attestation": result["attestation"]}
|
|
||||||
if len(self._nonce_ledger) >= 2048:
|
|
||||||
self._nonce_ledger.pop(next(iter(self._nonce_ledger)))
|
|
||||||
self._nonce_ledger[nonce] = (hashlib.sha256(request_bytes).digest(), dict(result))
|
|
||||||
self._persist_nonce_ledger()
|
|
||||||
return result
|
|
||||||
|
|
||||||
def serve_forever(self, listener: socket.socket) -> None:
|
|
||||||
while True:
|
|
||||||
connection, _ = listener.accept()
|
|
||||||
with connection:
|
|
||||||
try:
|
|
||||||
raw = connection.makefile("rb").readline(65537)
|
|
||||||
if not raw or len(raw) > 65536:
|
|
||||||
raise BuilderError("REQUEST_SIZE_INVALID")
|
|
||||||
result = self.handle_request(json.loads(raw), connection=connection)
|
|
||||||
except (BuilderError, UnicodeDecodeError, json.JSONDecodeError):
|
|
||||||
result = {"schema": self.config.response_schema, "status": "REJECTED", "error_code": "REQUEST_REJECTED"}
|
|
||||||
connection.sendall(_canonical_json(result) + b"\n")
|
|
||||||
|
|
||||||
|
|
||||||
class UnixTrustedCandidateBuilderClient:
|
|
||||||
def __init__(self, config: TrustedCandidateBuilderConfig, keyring: BuilderKeyring, *, socket_path: Path | None = None, clock: Callable[[], float] = time.time) -> None:
|
|
||||||
self.config, self.keyring, self.socket_path, self.clock = config, keyring, socket_path, clock
|
|
||||||
|
|
||||||
def create_request(self, version: str, candidate_id: str | None, source_commit: str, *, nonce: str, operation: str | None = None) -> dict[str, Any]:
|
|
||||||
selected = self.config.resolve_recipe(version, candidate_id, source_commit)
|
|
||||||
selected_operation = operation or self.config.operation
|
|
||||||
if selected_operation not in {self.config.operation, self.config.readiness_operation}:
|
|
||||||
raise BuilderError("REQUEST_OPERATION_INVALID")
|
|
||||||
request = {"schema": self.config.request_schema, "operation": selected_operation, "recipe": version, "repository": self.config.repository, "task_id": selected["task_id"], "candidate_id": selected["candidate_id"], "base_oid": selected["base_oid"], "source_commit": selected["source_commit"], "version": selected["version"], "purpose": selected["purpose"], "issued_at": int(self.clock()), "nonce": nonce}
|
|
||||||
request["hmac"] = hmac.new(self.keyring.builder_hmac, _canonical_json(request), hashlib.sha256).hexdigest()
|
|
||||||
return request
|
|
||||||
|
|
||||||
def _exchange(self, request: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
||||||
if self.socket_path is None:
|
|
||||||
raise BuilderError("BUILDER_SOCKET_UNCONFIGURED")
|
|
||||||
try:
|
|
||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
|
||||||
connection.settimeout(self.config.request_timeout_seconds)
|
|
||||||
connection.connect(os.fspath(self.socket_path))
|
|
||||||
connection.sendall(_canonical_json(dict(request)) + b"\n")
|
|
||||||
raw = connection.makefile("rb").readline(65537)
|
|
||||||
except (OSError, TimeoutError) as exc:
|
|
||||||
raise BuilderError("BUILDER_TRANSPORT_UNAVAILABLE") from exc
|
|
||||||
try:
|
|
||||||
result = json.loads(raw)
|
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
||||||
raise BuilderError("BUILDER_RESPONSE_INVALID") from exc
|
|
||||||
if not isinstance(result, Mapping) or result.get("schema") != self.config.response_schema:
|
|
||||||
raise BuilderError("BUILDER_RESPONSE_INVALID")
|
|
||||||
return result
|
|
||||||
|
|
||||||
def build(self, version: str, candidate_id: str | None, source_commit: str, *, nonce: str) -> Mapping[str, Any]:
|
|
||||||
result = self._exchange(
|
|
||||||
self.create_request(version, candidate_id, source_commit, nonce=nonce)
|
|
||||||
)
|
|
||||||
if result.get("status") != "BUILT":
|
|
||||||
raise BuilderError("BUILDER_REQUEST_REJECTED")
|
|
||||||
return result
|
|
||||||
|
|
||||||
def probe(self, *, nonce: str | None = None) -> Mapping[str, Any]:
|
|
||||||
recipe = self.config.fixed_recipe("V2R2C2")
|
|
||||||
result = self._exchange(
|
|
||||||
self.create_request(
|
|
||||||
"V2R2C2",
|
|
||||||
recipe["candidate_id"],
|
|
||||||
recipe["source_commit"],
|
|
||||||
nonce=nonce or secrets.token_hex(32),
|
|
||||||
operation=self.config.readiness_operation,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if result.get("status") != "READY":
|
|
||||||
raise BuilderError("BUILDER_NOT_READY")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def readiness_probe(client: UnixTrustedCandidateBuilderClient) -> Mapping[str, Any]:
|
|
||||||
result = dict(client.probe())
|
|
||||||
required_true = {"collector_ready", "signer_ready"}
|
|
||||||
if any(result.get(name) is not True for name in required_true):
|
|
||||||
raise BuilderError("BUILDER_NOT_READY")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _main() -> None:
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--socket-fd", required=True, type=int)
|
|
||||||
parser.add_argument("--policy", required=True, type=Path)
|
|
||||||
arguments = parser.parse_args()
|
|
||||||
config = TrustedCandidateBuilderConfig.from_policy(load_builder_policy(arguments.policy))
|
|
||||||
credentials = os.environ.get("CREDENTIALS_DIRECTORY")
|
|
||||||
if not credentials:
|
|
||||||
raise SystemExit("CREDENTIALS_DIRECTORY is required")
|
|
||||||
keyring = BuilderKeyring.from_credentials_directory(Path(credentials))
|
|
||||||
signer = CredentialPathEd25519Signer(
|
|
||||||
keyring.signing_private_key_path,
|
|
||||||
key_id=os.environ["AIOS_TRUSTED_BUILDER_KEY_ID"],
|
|
||||||
key_generation=int(os.environ["AIOS_TRUSTED_BUILDER_KEY_GENERATION"]),
|
|
||||||
allowed_purpose="AIOS_TRUSTED_RELEASE_BUILDER",
|
|
||||||
)
|
|
||||||
listener = socket.fromfd(arguments.socket_fd, socket.AF_UNIX, socket.SOCK_STREAM)
|
|
||||||
collector = DeterministicEvidenceCollector(
|
|
||||||
config,
|
|
||||||
evidence_source=ServerOwnedEvidenceSource(
|
|
||||||
config,
|
|
||||||
Path(os.environ["AIOS_TRUSTED_BUILDER_GIT_DIR"]),
|
|
||||||
Path(os.environ["AIOS_TRUSTED_BUILDER_EVIDENCE_ROOT"]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
TrustedCandidateBuilder(config, keyring, collector=collector, signer=signer, nonce_ledger_path=Path(os.environ["AIOS_TRUSTED_BUILDER_NONCE_LEDGER"])).serve_forever(listener)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
_main()
|
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
"""Self-healing availability guard for persistent Online Agent workers.
|
||||||
|
|
||||||
|
The guard never claims Tasks and never writes Task/Job/Git databases. It only:
|
||||||
|
- ensures required worker/executor systemd units are enabled + active;
|
||||||
|
- checks worker-owned liveness JSON written by the real poll/heartbeat loop;
|
||||||
|
- restarts an active worker only when its liveness is stale;
|
||||||
|
- honors an explicit maintenance inhibit file;
|
||||||
|
- writes a bounded status JSON under /run for observability.
|
||||||
|
|
||||||
|
Idle liveness is the successful poll loop itself. Busy liveness is refreshed by the
|
||||||
|
Task lease-heartbeat thread, so long-running Tasks are not mistaken for hung workers.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from typing import Any, Callable, Mapping, Sequence
|
||||||
|
|
||||||
|
SCHEMA = "aios.worker-availability.status.v1"
|
||||||
|
DEFAULT_WORKER_UNITS = (
|
||||||
|
"aios-online-agent-worker-20260815.service",
|
||||||
|
"aios-hermes-online-agent-worker.service",
|
||||||
|
"aios-openclaw-online-agent-worker.service",
|
||||||
|
)
|
||||||
|
DEFAULT_DEPENDENCY_UNITS = (
|
||||||
|
"aios-external-agent-executor.service",
|
||||||
|
)
|
||||||
|
DEFAULT_RUN_ROOT = Path("/run/aios-worker-availability")
|
||||||
|
DEFAULT_STATUS = DEFAULT_RUN_ROOT / "status.json"
|
||||||
|
DEFAULT_RECOVERY = DEFAULT_RUN_ROOT / "recovery.json"
|
||||||
|
DEFAULT_INHIBIT = DEFAULT_RUN_ROOT / "maintenance.lock"
|
||||||
|
DEFAULT_MAX_STALE_SECONDS = 90
|
||||||
|
DEFAULT_RESTART_COOLDOWN_SECONDS = 60
|
||||||
|
DEFAULT_STARTUP_WAIT_SECONDS = 10
|
||||||
|
ACCEPTED_ENABLED_STATES = frozenset({"enabled", "enabled-runtime", "static", "indirect", "generated"})
|
||||||
|
|
||||||
|
|
||||||
|
class AvailabilityGuardError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class UnitSnapshot:
|
||||||
|
unit: str
|
||||||
|
role: str
|
||||||
|
load_state: str
|
||||||
|
active_state: str
|
||||||
|
unit_file_state: str
|
||||||
|
enabled_ok: bool
|
||||||
|
liveness_path: str | None = None
|
||||||
|
liveness_state: str | None = None
|
||||||
|
last_seen_epoch: float | None = None
|
||||||
|
liveness_age_seconds: float | None = None
|
||||||
|
fresh: bool | None = None
|
||||||
|
action: str = "NONE"
|
||||||
|
detail: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Systemd:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
runner: Callable[..., subprocess.CompletedProcess[str]] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.runner = runner or subprocess.run
|
||||||
|
|
||||||
|
def _run(self, args: Sequence[str], *, check: bool = False) -> subprocess.CompletedProcess[str]:
|
||||||
|
result = self.runner(
|
||||||
|
["/usr/bin/systemctl", *args],
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
if check and result.returncode != 0:
|
||||||
|
raise AvailabilityGuardError(
|
||||||
|
"SYSTEMD_COMMAND_FAILED:"
|
||||||
|
+ ":".join(args)
|
||||||
|
+ ":"
|
||||||
|
+ str(result.returncode)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def property(self, unit: str, name: str) -> str:
|
||||||
|
result = self._run(["show", unit, f"--property={name}", "--value"])
|
||||||
|
if result.returncode != 0:
|
||||||
|
return ""
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
def environment(self, unit: str) -> dict[str, str]:
|
||||||
|
text = self.property(unit, "Environment")
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
for item in shlex.split(text):
|
||||||
|
if "=" not in item:
|
||||||
|
continue
|
||||||
|
key, value = item.split("=", 1)
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
def enable(self, unit: str) -> None:
|
||||||
|
self._run(["enable", unit], check=True)
|
||||||
|
|
||||||
|
def start(self, unit: str) -> None:
|
||||||
|
self._run(["start", unit], check=True)
|
||||||
|
|
||||||
|
def restart(self, unit: str) -> None:
|
||||||
|
self._run(["restart", unit], check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_json(path: Path, value: Mapping[str, Any]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True, mode=0o755)
|
||||||
|
if path.parent.is_symlink() or path.is_symlink():
|
||||||
|
raise AvailabilityGuardError("STATUS_PATH_SYMLINK_REJECTED")
|
||||||
|
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||||||
|
try:
|
||||||
|
os.fchmod(fd, 0o644)
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(value, handle, ensure_ascii=False, sort_keys=True, indent=2)
|
||||||
|
handle.write("\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(temporary, path)
|
||||||
|
os.chmod(path, 0o644)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temporary):
|
||||||
|
os.unlink(temporary)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json(path: Path) -> dict[str, Any] | None:
|
||||||
|
if path.is_symlink() or not path.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
return dict(value) if isinstance(value, Mapping) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _state_root(environment: Mapping[str, str], unit: str) -> Path:
|
||||||
|
explicit = environment.get("AIOS_WORKER_STATE_ROOT")
|
||||||
|
if explicit:
|
||||||
|
return Path(explicit)
|
||||||
|
fallbacks = {
|
||||||
|
"aios-online-agent-worker-20260815.service": Path(
|
||||||
|
"/var/lib/aios-online-agent-worker-20260815"
|
||||||
|
),
|
||||||
|
"aios-hermes-online-agent-worker.service": Path(
|
||||||
|
"/var/lib/aios-online-agent-hermes"
|
||||||
|
),
|
||||||
|
"aios-openclaw-online-agent-worker.service": Path(
|
||||||
|
"/var/lib/aios-online-agent-openclaw"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if unit not in fallbacks:
|
||||||
|
raise AvailabilityGuardError("WORKER_STATE_ROOT_UNAVAILABLE:" + unit)
|
||||||
|
return fallbacks[unit]
|
||||||
|
|
||||||
|
|
||||||
|
def _liveness_path(environment: Mapping[str, str], unit: str) -> Path:
|
||||||
|
explicit = environment.get("AIOS_WORKER_LIVENESS_PATH")
|
||||||
|
if explicit:
|
||||||
|
return Path(explicit)
|
||||||
|
return _state_root(environment, unit) / "worker-liveness.json"
|
||||||
|
|
||||||
|
|
||||||
|
class AvailabilityGuard:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
systemd: Systemd | None = None,
|
||||||
|
worker_units: Sequence[str] = DEFAULT_WORKER_UNITS,
|
||||||
|
dependency_units: Sequence[str] = DEFAULT_DEPENDENCY_UNITS,
|
||||||
|
status_path: Path = DEFAULT_STATUS,
|
||||||
|
recovery_path: Path = DEFAULT_RECOVERY,
|
||||||
|
inhibit_path: Path = DEFAULT_INHIBIT,
|
||||||
|
max_stale_seconds: int = DEFAULT_MAX_STALE_SECONDS,
|
||||||
|
restart_cooldown_seconds: int = DEFAULT_RESTART_COOLDOWN_SECONDS,
|
||||||
|
startup_wait_seconds: int = DEFAULT_STARTUP_WAIT_SECONDS,
|
||||||
|
now_fn: Callable[[], float] = time.time,
|
||||||
|
sleep_fn: Callable[[float], None] = time.sleep,
|
||||||
|
) -> None:
|
||||||
|
self.systemd = systemd or Systemd()
|
||||||
|
self.worker_units = tuple(worker_units)
|
||||||
|
self.dependency_units = tuple(dependency_units)
|
||||||
|
self.status_path = Path(status_path)
|
||||||
|
self.recovery_path = Path(recovery_path)
|
||||||
|
self.inhibit_path = Path(inhibit_path)
|
||||||
|
self.max_stale_seconds = int(max_stale_seconds)
|
||||||
|
self.restart_cooldown_seconds = int(restart_cooldown_seconds)
|
||||||
|
self.startup_wait_seconds = int(startup_wait_seconds)
|
||||||
|
self.now_fn = now_fn
|
||||||
|
self.sleep_fn = sleep_fn
|
||||||
|
if self.max_stale_seconds < 15:
|
||||||
|
raise AvailabilityGuardError("MAX_STALE_SECONDS_TOO_SMALL")
|
||||||
|
if self.restart_cooldown_seconds < 15:
|
||||||
|
raise AvailabilityGuardError("RESTART_COOLDOWN_TOO_SMALL")
|
||||||
|
if self.startup_wait_seconds < 0 or self.startup_wait_seconds > 60:
|
||||||
|
raise AvailabilityGuardError("STARTUP_WAIT_INVALID")
|
||||||
|
|
||||||
|
def _recovery_state(self) -> dict[str, float]:
|
||||||
|
value = _load_json(self.recovery_path) or {}
|
||||||
|
raw = value.get("last_restart_epoch")
|
||||||
|
if not isinstance(raw, Mapping):
|
||||||
|
return {}
|
||||||
|
result: dict[str, float] = {}
|
||||||
|
for unit, epoch in raw.items():
|
||||||
|
try:
|
||||||
|
result[str(unit)] = float(epoch)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _save_recovery_state(self, state: Mapping[str, float]) -> None:
|
||||||
|
_atomic_json(
|
||||||
|
self.recovery_path,
|
||||||
|
{
|
||||||
|
"schema": "aios.worker-availability.recovery.v1",
|
||||||
|
"last_restart_epoch": dict(state),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _basic_snapshot(self, unit: str, role: str) -> tuple[str, str, str, bool]:
|
||||||
|
load = self.systemd.property(unit, "LoadState")
|
||||||
|
active = self.systemd.property(unit, "ActiveState")
|
||||||
|
enabled = self.systemd.property(unit, "UnitFileState")
|
||||||
|
return load, active, enabled, enabled in ACCEPTED_ENABLED_STATES
|
||||||
|
|
||||||
|
def _ensure_enabled_active(self, unit: str, role: str) -> tuple[str, bool]:
|
||||||
|
load, active, enabled, enabled_ok = self._basic_snapshot(unit, role)
|
||||||
|
if load != "loaded":
|
||||||
|
raise AvailabilityGuardError("REQUIRED_UNIT_NOT_LOADED:" + unit)
|
||||||
|
action = "NONE"
|
||||||
|
if not enabled_ok:
|
||||||
|
self.systemd.enable(unit)
|
||||||
|
action = "ENABLE"
|
||||||
|
if active != "active":
|
||||||
|
self.systemd.start(unit)
|
||||||
|
action = "START" if action == "NONE" else "ENABLE_START"
|
||||||
|
return action, active == "active"
|
||||||
|
|
||||||
|
def _wait_for_liveness(self, path: Path) -> dict[str, Any] | None:
|
||||||
|
deadline = self.now_fn() + self.startup_wait_seconds
|
||||||
|
while True:
|
||||||
|
value = _load_json(path)
|
||||||
|
if value is not None:
|
||||||
|
try:
|
||||||
|
epoch = float(value.get("last_seen_epoch"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
epoch = 0.0
|
||||||
|
if epoch > 0 and self.now_fn() - epoch <= self.max_stale_seconds:
|
||||||
|
return value
|
||||||
|
if self.now_fn() >= deadline:
|
||||||
|
return value
|
||||||
|
self.sleep_fn(0.5)
|
||||||
|
|
||||||
|
def run(self, *, heal: bool) -> dict[str, Any]:
|
||||||
|
now = self.now_fn()
|
||||||
|
if self.inhibit_path.exists():
|
||||||
|
result = {
|
||||||
|
"schema": SCHEMA,
|
||||||
|
"status": "MAINTENANCE_INHIBITED",
|
||||||
|
"generated_at_epoch": now,
|
||||||
|
"maintenance_inhibit": str(self.inhibit_path),
|
||||||
|
"workers": [],
|
||||||
|
"dependencies": [],
|
||||||
|
"all_fresh": False,
|
||||||
|
"actions": [],
|
||||||
|
}
|
||||||
|
_atomic_json(self.status_path, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
actions: list[dict[str, str]] = []
|
||||||
|
dependencies: list[UnitSnapshot] = []
|
||||||
|
for unit in self.dependency_units:
|
||||||
|
load, active, enabled, enabled_ok = self._basic_snapshot(unit, "dependency")
|
||||||
|
action = "NONE"
|
||||||
|
if heal:
|
||||||
|
if load != "loaded":
|
||||||
|
raise AvailabilityGuardError("REQUIRED_UNIT_NOT_LOADED:" + unit)
|
||||||
|
if not enabled_ok:
|
||||||
|
self.systemd.enable(unit)
|
||||||
|
action = "ENABLE"
|
||||||
|
if active != "active":
|
||||||
|
self.systemd.start(unit)
|
||||||
|
action = "START" if action == "NONE" else "ENABLE_START"
|
||||||
|
if action != "NONE":
|
||||||
|
actions.append({"unit": unit, "action": action})
|
||||||
|
load, active, enabled, enabled_ok = self._basic_snapshot(unit, "dependency")
|
||||||
|
dependencies.append(
|
||||||
|
UnitSnapshot(
|
||||||
|
unit=unit,
|
||||||
|
role="dependency",
|
||||||
|
load_state=load,
|
||||||
|
active_state=active,
|
||||||
|
unit_file_state=enabled,
|
||||||
|
enabled_ok=enabled_ok,
|
||||||
|
fresh=(active == "active"),
|
||||||
|
action=action,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
recovery = self._recovery_state()
|
||||||
|
workers: list[UnitSnapshot] = []
|
||||||
|
for unit in self.worker_units:
|
||||||
|
load, active, enabled, enabled_ok = self._basic_snapshot(unit, "worker")
|
||||||
|
environment = self.systemd.environment(unit) if load == "loaded" else {}
|
||||||
|
path = _liveness_path(environment, unit) if load == "loaded" else None
|
||||||
|
action = "NONE"
|
||||||
|
|
||||||
|
if heal:
|
||||||
|
if load != "loaded":
|
||||||
|
raise AvailabilityGuardError("REQUIRED_UNIT_NOT_LOADED:" + unit)
|
||||||
|
if not enabled_ok:
|
||||||
|
self.systemd.enable(unit)
|
||||||
|
action = "ENABLE"
|
||||||
|
if active != "active":
|
||||||
|
self.systemd.start(unit)
|
||||||
|
action = "START" if action == "NONE" else "ENABLE_START"
|
||||||
|
actions.append({"unit": unit, "action": action})
|
||||||
|
if path is not None:
|
||||||
|
self._wait_for_liveness(path)
|
||||||
|
load, active, enabled, enabled_ok = self._basic_snapshot(unit, "worker")
|
||||||
|
|
||||||
|
live = _load_json(path) if path is not None else None
|
||||||
|
last_seen: float | None = None
|
||||||
|
state: str | None = None
|
||||||
|
age: float | None = None
|
||||||
|
fresh = False
|
||||||
|
detail: str | None = None
|
||||||
|
if live is not None:
|
||||||
|
state = str(live.get("state") or "")
|
||||||
|
try:
|
||||||
|
last_seen = float(live.get("last_seen_epoch"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
last_seen = None
|
||||||
|
if last_seen is not None:
|
||||||
|
age = max(0.0, now - last_seen)
|
||||||
|
fresh = age <= self.max_stale_seconds
|
||||||
|
if live.get("agent_id") in (None, ""):
|
||||||
|
fresh = False
|
||||||
|
detail = "LIVENESS_AGENT_ID_MISSING"
|
||||||
|
else:
|
||||||
|
detail = "LIVENESS_MISSING"
|
||||||
|
|
||||||
|
if heal and active == "active" and not fresh:
|
||||||
|
previous = float(recovery.get(unit, 0.0))
|
||||||
|
if now - previous >= self.restart_cooldown_seconds:
|
||||||
|
self.systemd.restart(unit)
|
||||||
|
recovery[unit] = now
|
||||||
|
action = "RESTART_STALE"
|
||||||
|
actions.append({"unit": unit, "action": action})
|
||||||
|
if path is not None:
|
||||||
|
live = self._wait_for_liveness(path)
|
||||||
|
load, active, enabled, enabled_ok = self._basic_snapshot(unit, "worker")
|
||||||
|
if live is not None:
|
||||||
|
state = str(live.get("state") or "")
|
||||||
|
try:
|
||||||
|
last_seen = float(live.get("last_seen_epoch"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
last_seen = None
|
||||||
|
if last_seen is not None:
|
||||||
|
age = max(0.0, self.now_fn() - last_seen)
|
||||||
|
fresh = age <= self.max_stale_seconds
|
||||||
|
detail = None if fresh else "LIVENESS_STALE_AFTER_RESTART"
|
||||||
|
else:
|
||||||
|
detail = "RESTART_COOLDOWN_ACTIVE"
|
||||||
|
|
||||||
|
workers.append(
|
||||||
|
UnitSnapshot(
|
||||||
|
unit=unit,
|
||||||
|
role="worker",
|
||||||
|
load_state=load,
|
||||||
|
active_state=active,
|
||||||
|
unit_file_state=enabled,
|
||||||
|
enabled_ok=enabled_ok,
|
||||||
|
liveness_path=str(path) if path is not None else None,
|
||||||
|
liveness_state=state,
|
||||||
|
last_seen_epoch=last_seen,
|
||||||
|
liveness_age_seconds=age,
|
||||||
|
fresh=bool(active == "active" and enabled_ok and fresh),
|
||||||
|
action=action,
|
||||||
|
detail=detail,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if heal:
|
||||||
|
self._save_recovery_state(recovery)
|
||||||
|
|
||||||
|
all_fresh = (
|
||||||
|
all(item.active_state == "active" and item.enabled_ok and item.fresh for item in workers)
|
||||||
|
and all(item.active_state == "active" for item in dependencies)
|
||||||
|
)
|
||||||
|
result = {
|
||||||
|
"schema": SCHEMA,
|
||||||
|
"status": "HEALTHY" if all_fresh else "DEGRADED",
|
||||||
|
"generated_at_epoch": self.now_fn(),
|
||||||
|
"max_stale_seconds": self.max_stale_seconds,
|
||||||
|
"maintenance_inhibit": str(self.inhibit_path),
|
||||||
|
"workers": [asdict(item) for item in workers],
|
||||||
|
"dependencies": [asdict(item) for item in dependencies],
|
||||||
|
"all_fresh": all_fresh,
|
||||||
|
"actions": actions,
|
||||||
|
}
|
||||||
|
_atomic_json(self.status_path, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
mode = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
mode.add_argument("--heal", action="store_true")
|
||||||
|
mode.add_argument("--check", action="store_true")
|
||||||
|
parser.add_argument("--status-file", default=str(DEFAULT_STATUS))
|
||||||
|
parser.add_argument("--recovery-file", default=str(DEFAULT_RECOVERY))
|
||||||
|
parser.add_argument("--inhibit-file", default=str(DEFAULT_INHIBIT))
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-stale-seconds",
|
||||||
|
type=int,
|
||||||
|
default=int(
|
||||||
|
os.environ.get(
|
||||||
|
"AIOS_WORKER_AVAILABILITY_MAX_STALE_SECONDS",
|
||||||
|
str(DEFAULT_MAX_STALE_SECONDS),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--restart-cooldown-seconds",
|
||||||
|
type=int,
|
||||||
|
default=int(
|
||||||
|
os.environ.get(
|
||||||
|
"AIOS_WORKER_AVAILABILITY_RESTART_COOLDOWN_SECONDS",
|
||||||
|
str(DEFAULT_RESTART_COOLDOWN_SECONDS),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
guard = AvailabilityGuard(
|
||||||
|
status_path=Path(args.status_file),
|
||||||
|
recovery_path=Path(args.recovery_file),
|
||||||
|
inhibit_path=Path(args.inhibit_file),
|
||||||
|
max_stale_seconds=args.max_stale_seconds,
|
||||||
|
restart_cooldown_seconds=args.restart_cooldown_seconds,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = guard.run(heal=args.heal)
|
||||||
|
except AvailabilityGuardError as exc:
|
||||||
|
print(json.dumps({"status": "FAILED", "error": str(exc)}, sort_keys=True))
|
||||||
|
return 20
|
||||||
|
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
||||||
|
if args.check and not result.get("all_fresh"):
|
||||||
|
return 10
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -31,8 +31,6 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Mapping, Sequence
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
from trusted_release_execution_plan import PlanError, TrustedReleasePlan
|
|
||||||
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||||
PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||||
@@ -75,24 +73,6 @@ class Binding:
|
|||||||
production_port: int
|
production_port: int
|
||||||
repository_identity: str = "aios/aios"
|
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:
|
def __post_init__(self) -> None:
|
||||||
if not self.version or not OID_RE.fullmatch(self.target_oid):
|
if not self.version or not OID_RE.fullmatch(self.target_oid):
|
||||||
raise ValueError("invalid release target binding")
|
raise ValueError("invalid release target binding")
|
||||||
@@ -127,8 +107,6 @@ class Binding:
|
|||||||
"production_service": self.production_service,
|
"production_service": self.production_service,
|
||||||
"production_port": self.production_port,
|
"production_port": self.production_port,
|
||||||
"repository_identity": self.repository_identity,
|
"repository_identity": self.repository_identity,
|
||||||
"candidate_id": self.candidate_id,
|
|
||||||
"release_plan_sha256": self.release_plan_sha256,
|
|
||||||
}
|
}
|
||||||
return hashlib.sha256(canonical_json(payload)).hexdigest()
|
return hashlib.sha256(canonical_json(payload)).hexdigest()
|
||||||
|
|
||||||
@@ -148,12 +126,9 @@ class Binding:
|
|||||||
"production_service": self.production_service,
|
"production_service": self.production_service,
|
||||||
"production_port": self.production_port,
|
"production_port": self.production_port,
|
||||||
"repository_identity": self.repository_identity,
|
"repository_identity": self.repository_identity,
|
||||||
"candidate_id": self.candidate_id,
|
|
||||||
"release_plan_sha256": self.release_plan_sha256,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_json(value: object) -> bytes:
|
def canonical_json(value: object) -> bytes:
|
||||||
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
||||||
|
|
||||||
@@ -307,12 +282,6 @@ def verify_authorization(receipt: Mapping[str, Any], binding: Binding) -> dict[s
|
|||||||
"artifact_sha256": binding.artifact_sha256,
|
"artifact_sha256": binding.artifact_sha256,
|
||||||
"approval_plan_id": binding.approval_plan_id,
|
"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():
|
for key, value in expected.items():
|
||||||
if receipt.get(key) != value:
|
if receipt.get(key) != value:
|
||||||
raise ManualOwnerReviewRequired(f"Owner authorization mismatch: {key}")
|
raise ManualOwnerReviewRequired(f"Owner authorization mismatch: {key}")
|
||||||
@@ -529,14 +498,7 @@ def execute(
|
|||||||
deploy_command: Sequence[str],
|
deploy_command: Sequence[str],
|
||||||
registry_command: Sequence[str],
|
registry_command: Sequence[str],
|
||||||
baseline_command: Sequence[str],
|
baseline_command: Sequence[str],
|
||||||
release_plan_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> 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)
|
authorization = verify_authorization(secure_json(authorization_path), binding)
|
||||||
lineage = verify_git_lineage(repository, binding)
|
lineage = verify_git_lineage(repository, binding)
|
||||||
artifact = verify_artifact(binding)
|
artifact = verify_artifact(binding)
|
||||||
@@ -629,18 +591,17 @@ def execute(
|
|||||||
def parser() -> argparse.ArgumentParser:
|
def parser() -> argparse.ArgumentParser:
|
||||||
p = argparse.ArgumentParser()
|
p = argparse.ArgumentParser()
|
||||||
p.add_argument("--authorization", required=True, type=Path)
|
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("--repository", required=True, type=Path)
|
||||||
p.add_argument("--state-root", required=True, type=Path)
|
p.add_argument("--state-root", required=True, type=Path)
|
||||||
p.add_argument("--artifact", type=Path)
|
p.add_argument("--artifact", required=True, type=Path)
|
||||||
p.add_argument("--artifact-size", type=int)
|
p.add_argument("--artifact-size", required=True, type=int)
|
||||||
p.add_argument("--version")
|
p.add_argument("--version", required=True)
|
||||||
p.add_argument("--target-oid")
|
p.add_argument("--target-oid", required=True)
|
||||||
p.add_argument("--evidence-closure-oid")
|
p.add_argument("--evidence-closure-oid", required=True)
|
||||||
p.add_argument("--artifact-sha256")
|
p.add_argument("--artifact-sha256", required=True)
|
||||||
p.add_argument("--approval-plan-id")
|
p.add_argument("--approval-plan-id", required=True)
|
||||||
p.add_argument("--production-service")
|
p.add_argument("--production-service", required=True)
|
||||||
p.add_argument("--production-port", type=int)
|
p.add_argument("--production-port", required=True, type=int)
|
||||||
p.add_argument("--repository-identity", default="aios/aios")
|
p.add_argument("--repository-identity", default="aios/aios")
|
||||||
p.add_argument("--tag-adapter", required=True, nargs="+")
|
p.add_argument("--tag-adapter", required=True, nargs="+")
|
||||||
p.add_argument("--deploy-adapter", required=True, nargs="+")
|
p.add_argument("--deploy-adapter", required=True, nargs="+")
|
||||||
@@ -651,13 +612,18 @@ def parser() -> argparse.ArgumentParser:
|
|||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
args = parser().parse_args()
|
args = parser().parse_args()
|
||||||
plan = TrustedReleasePlan.load(args.release_plan) if args.release_plan else None
|
binding = Binding(
|
||||||
if plan is not None:
|
version=args.version,
|
||||||
binding = Binding.from_trusted_plan(plan)
|
target_oid=args.target_oid,
|
||||||
else:
|
evidence_closure_oid=args.evidence_closure_oid,
|
||||||
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)
|
artifact_sha256=args.artifact_sha256,
|
||||||
if any(value is None for value in legacy): raise SystemExit("legacy binding is incomplete")
|
approval_plan_id=args.approval_plan_id,
|
||||||
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)
|
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:
|
try:
|
||||||
result = execute(
|
result = execute(
|
||||||
binding=binding,
|
binding=binding,
|
||||||
@@ -668,7 +634,6 @@ def main() -> int:
|
|||||||
deploy_command=parse_command(args.deploy_adapter),
|
deploy_command=parse_command(args.deploy_adapter),
|
||||||
registry_command=parse_command(args.registry_adapter),
|
registry_command=parse_command(args.registry_adapter),
|
||||||
baseline_command=parse_command(args.baseline_adapter),
|
baseline_command=parse_command(args.baseline_adapter),
|
||||||
release_plan_path=args.release_plan,
|
|
||||||
)
|
)
|
||||||
print(json.dumps(result, sort_keys=True))
|
print(json.dumps(result, sort_keys=True))
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
@@ -307,76 +306,14 @@ def authoritative_diff(
|
|||||||
from_oid = validate_git_revision(from_revision, "from", git_dir=path)
|
from_oid = validate_git_revision(from_revision, "from", git_dir=path)
|
||||||
to_oid = validate_git_revision(to_revision, "to", git_dir=path)
|
to_oid = validate_git_revision(to_revision, "to", git_dir=path)
|
||||||
args = ["diff", "--no-ext-diff", "--no-textconv", from_oid, to_oid]
|
args = ["diff", "--no-ext-diff", "--no-textconv", from_oid, to_oid]
|
||||||
patch_limit = max(1, min(_MAX_OUTPUT, 256 * 1024))
|
result = run_authoritative_git(args, git_dir=path)
|
||||||
process = subprocess.Popen(
|
|
||||||
["git", "--git-dir", str(path), *args],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
|
|
||||||
)
|
|
||||||
chunks: list[bytes] = []
|
|
||||||
stored_bytes = 0
|
|
||||||
total_bytes = 0
|
|
||||||
started = time.monotonic()
|
|
||||||
try:
|
|
||||||
while process.stdout is not None:
|
|
||||||
chunk = process.stdout.read(64 * 1024)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
total_bytes += len(chunk)
|
|
||||||
if stored_bytes < patch_limit:
|
|
||||||
remaining = patch_limit - stored_bytes
|
|
||||||
selected = chunk[:remaining]
|
|
||||||
chunks.append(selected)
|
|
||||||
stored_bytes += len(selected)
|
|
||||||
if time.monotonic() - started > _TIMEOUT:
|
|
||||||
process.kill()
|
|
||||||
raise AuthoritativeGitError("GIT_REF_QUERY_TIMEOUT")
|
|
||||||
stderr = (
|
|
||||||
process.stderr.read(8192).decode("utf-8", errors="replace")
|
|
||||||
if process.stderr is not None
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
returncode = process.wait(timeout=max(0.1, _TIMEOUT - (time.monotonic() - started)))
|
|
||||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
||||||
process.kill()
|
|
||||||
process.wait()
|
|
||||||
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED") from exc
|
|
||||||
if returncode != 0:
|
|
||||||
raise AuthoritativeGitError(_redact(stderr.strip() or "GIT_REF_QUERY_FAILED")[:200])
|
|
||||||
try:
|
|
||||||
names_result = run_authoritative_git(
|
|
||||||
["diff", "--no-ext-diff", "--no-textconv", "--name-status", from_oid, to_oid],
|
|
||||||
git_dir=path,
|
|
||||||
)
|
|
||||||
changed_paths = [
|
|
||||||
line.split("\t", 1)[-1]
|
|
||||||
for line in names_result.stdout.splitlines()
|
|
||||||
if line.strip()
|
|
||||||
]
|
|
||||||
except AuthoritativeGitOutputTooLarge:
|
|
||||||
changed_paths = []
|
|
||||||
try:
|
|
||||||
stats_result = run_authoritative_git(
|
|
||||||
["diff", "--no-ext-diff", "--no-textconv", "--stat", from_oid, to_oid],
|
|
||||||
git_dir=path,
|
|
||||||
)
|
|
||||||
stats = stats_result.stdout.strip()
|
|
||||||
except AuthoritativeGitOutputTooLarge:
|
|
||||||
stats = "GIT_STAT_OUTPUT_TRUNCATED"
|
|
||||||
patch = b"".join(chunks).decode("utf-8", errors="replace")
|
|
||||||
return {
|
return {
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"stdout": patch,
|
"stdout": result.stdout,
|
||||||
"stderr": "",
|
"stderr": "",
|
||||||
"cmd": ["git", f"--git-dir={path}", *args],
|
"cmd": ["git", f"--git-dir={path}", *args],
|
||||||
"from_oid": from_oid,
|
"from_oid": from_oid,
|
||||||
"to_oid": to_oid,
|
"to_oid": to_oid,
|
||||||
"changed_paths": changed_paths,
|
|
||||||
"stats": stats,
|
|
||||||
"total_size_bytes": total_bytes,
|
|
||||||
"truncated": total_bytes > patch_limit,
|
|
||||||
"patch_limit_bytes": patch_limit,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -964,6 +964,7 @@ def validate_writer_lease_request_v2(raw: Mapping[str, Any], probe: Any) -> dict
|
|||||||
required = {
|
required = {
|
||||||
"schema",
|
"schema",
|
||||||
"task_id",
|
"task_id",
|
||||||
|
"job_id",
|
||||||
"repository",
|
"repository",
|
||||||
"branch_mode",
|
"branch_mode",
|
||||||
"base_oid",
|
"base_oid",
|
||||||
@@ -976,6 +977,7 @@ def validate_writer_lease_request_v2(raw: Mapping[str, Any], probe: Any) -> dict
|
|||||||
raise ProvisioningError("LEASE_REQUEST_V2_FIELDS_REJECTED", 409)
|
raise ProvisioningError("LEASE_REQUEST_V2_FIELDS_REJECTED", 409)
|
||||||
request = WriterLeaseRequestV2(
|
request = WriterLeaseRequestV2(
|
||||||
task_id=str(raw["task_id"]),
|
task_id=str(raw["task_id"]),
|
||||||
|
job_id=str(raw["job_id"]),
|
||||||
repository=str(raw["repository"]),
|
repository=str(raw["repository"]),
|
||||||
branch_mode=str(raw["branch_mode"]),
|
branch_mode=str(raw["branch_mode"]),
|
||||||
base_oid=str(raw["base_oid"]),
|
base_oid=str(raw["base_oid"]),
|
||||||
@@ -988,6 +990,7 @@ def validate_writer_lease_request_v2(raw: Mapping[str, Any], probe: Any) -> dict
|
|||||||
return {
|
return {
|
||||||
"schema": WriterLeaseRequestV2.schema,
|
"schema": WriterLeaseRequestV2.schema,
|
||||||
"task_id": request.task_id,
|
"task_id": request.task_id,
|
||||||
|
"job_id": request.job_id,
|
||||||
"repository": request.repository,
|
"repository": request.repository,
|
||||||
"branch_mode": request.branch_mode,
|
"branch_mode": request.branch_mode,
|
||||||
"base_oid": request.base_oid,
|
"base_oid": request.base_oid,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import tempfile
|
|||||||
from typing import Any, Mapping
|
from typing import Any, Mapping
|
||||||
|
|
||||||
from release_campaign_authorization import CampaignAuthorization, CampaignError
|
from release_campaign_authorization import CampaignAuthorization, CampaignError
|
||||||
from trusted_release_execution_plan import PlanError, TrustedReleasePlan
|
|
||||||
|
|
||||||
|
|
||||||
class AcceptanceError(RuntimeError):
|
class AcceptanceError(RuntimeError):
|
||||||
@@ -62,7 +61,6 @@ def evaluate(
|
|||||||
staging: Mapping[str, Any],
|
staging: Mapping[str, Any],
|
||||||
main: Mapping[str, Any],
|
main: Mapping[str, Any],
|
||||||
tag: Mapping[str, Any],
|
tag: Mapping[str, Any],
|
||||||
release_plan: Mapping[str, Any] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
required_binding = {
|
required_binding = {
|
||||||
"version",
|
"version",
|
||||||
@@ -100,27 +98,8 @@ def evaluate(
|
|||||||
raise AcceptanceError("rollback_readiness_failed")
|
raise AcceptanceError("rollback_readiness_failed")
|
||||||
if tag.get("conflict") is not False:
|
if tag.get("conflict") is not False:
|
||||||
raise AcceptanceError("tag_conflict")
|
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")
|
campaign.assert_operation("derive_campaign_acceptance")
|
||||||
result = {
|
return {
|
||||||
"schema": "aios.campaign-release-acceptance.v1",
|
"schema": "aios.campaign-release-acceptance.v1",
|
||||||
"accepted": True,
|
"accepted": True,
|
||||||
"binding_replaced": False,
|
"binding_replaced": False,
|
||||||
@@ -129,22 +108,6 @@ def evaluate(
|
|||||||
**dict(binding),
|
**dict(binding),
|
||||||
"owner_interaction_required": False,
|
"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:
|
def main() -> int:
|
||||||
@@ -157,7 +120,6 @@ def main() -> int:
|
|||||||
p.add_argument("--staging", type=Path, required=True)
|
p.add_argument("--staging", type=Path, required=True)
|
||||||
p.add_argument("--main-facts", 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("--tag-facts", type=Path, required=True)
|
||||||
p.add_argument("--release-plan", type=Path)
|
|
||||||
p.add_argument("--output", type=Path, required=True)
|
p.add_argument("--output", type=Path, required=True)
|
||||||
a = p.parse_args()
|
a = p.parse_args()
|
||||||
|
|
||||||
@@ -174,7 +136,6 @@ def main() -> int:
|
|||||||
secure_json(a.staging),
|
secure_json(a.staging),
|
||||||
secure_json(a.main_facts),
|
secure_json(a.main_facts),
|
||||||
secure_json(a.tag_facts),
|
secure_json(a.tag_facts),
|
||||||
secure_json(a.release_plan) if a.release_plan else None,
|
|
||||||
)
|
)
|
||||||
atomic_new(a.output, receipt)
|
atomic_new(a.output, receipt)
|
||||||
print(json.dumps(receipt, sort_keys=True))
|
print(json.dumps(receipt, sort_keys=True))
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
@@ -24,15 +23,10 @@ try:
|
|||||||
from authoritative_git import AuthoritativeGitError, run_authoritative_git
|
from authoritative_git import AuthoritativeGitError, run_authoritative_git
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
from .authoritative_git import AuthoritativeGitError, run_authoritative_git
|
from .authoritative_git import AuthoritativeGitError, run_authoritative_git
|
||||||
try:
|
|
||||||
from release_version import RELEASE_VERSION_RE, validate_release_version
|
|
||||||
except ModuleNotFoundError:
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
|
||||||
from release_version import RELEASE_VERSION_RE, validate_release_version
|
|
||||||
|
|
||||||
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
VERSION_RE = RELEASE_VERSION_RE
|
VERSION_RE = re.compile(r"^V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$")
|
||||||
DEPLOYMENT_EVIDENCE_RE = re.compile(r"^(?:sha256:[0-9a-f]{64}|git:[0-9a-f]{40})$")
|
DEPLOYMENT_EVIDENCE_RE = re.compile(r"^(?:sha256:[0-9a-f]{64}|git:[0-9a-f]{40})$")
|
||||||
FINGERPRINT_FIELDS = (
|
FINGERPRINT_FIELDS = (
|
||||||
"version",
|
"version",
|
||||||
@@ -89,14 +83,9 @@ class PublicationManualOwnerReviewRequired(
|
|||||||
|
|
||||||
|
|
||||||
def validate_version(version: str) -> str:
|
def validate_version(version: str) -> str:
|
||||||
return validate_release_version(version)
|
value = str(version or "")
|
||||||
|
if not VERSION_RE.fullmatch(value):
|
||||||
|
raise ValueError("invalid release version")
|
||||||
def validate_gitea_api_url(api_url: str) -> str:
|
|
||||||
value = str(api_url or "").strip().rstrip("/")
|
|
||||||
parsed = urllib.parse.urlsplit(value)
|
|
||||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
||||||
raise ValueError("Gitea API URL must be HTTP(S)")
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
@@ -422,11 +411,13 @@ class GiteaTagClient:
|
|||||||
timeout: float = 15,
|
timeout: float = 15,
|
||||||
token_line: int = 1,
|
token_line: int = 1,
|
||||||
):
|
):
|
||||||
self.api_url = validate_gitea_api_url(api_url)
|
self.api_url = api_url.rstrip("/")
|
||||||
self.repository = repository.strip("/")
|
self.repository = repository.strip("/")
|
||||||
self.token_file = token_file
|
self.token_file = token_file
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.token_line = token_line
|
self.token_line = token_line
|
||||||
|
if not self.api_url.startswith(("https://", "http://")):
|
||||||
|
raise ValueError("Gitea API URL must be HTTP(S)")
|
||||||
if self.repository.count("/") != 1:
|
if self.repository.count("/") != 1:
|
||||||
raise ValueError("Gitea repository must be owner/name")
|
raise ValueError("Gitea repository must be owner/name")
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
"""Governed release orchestration with a trusted-plan V2R2C2 path.
|
"""Governed V2R2C1 production release orchestration.
|
||||||
|
|
||||||
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
|
This module is deliberately scoped to the Owner-approved V2R2C1 binding. It
|
||||||
uses the existing TagPublisher and metadata adapters; it does not introduce a
|
uses the existing TagPublisher and metadata adapters; it does not introduce a
|
||||||
@@ -23,8 +20,6 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Mapping
|
from typing import Any, Mapping
|
||||||
|
|
||||||
from trusted_release_execution_plan import PlanError, TrustedReleasePlan
|
|
||||||
|
|
||||||
from gitea_tag_publication import (
|
from gitea_tag_publication import (
|
||||||
GiteaTagClient,
|
GiteaTagClient,
|
||||||
GitTagBackend,
|
GitTagBackend,
|
||||||
@@ -411,14 +406,6 @@ class GovernedProductionReleaseExecutor:
|
|||||||
config.remote_name,
|
config.remote_name,
|
||||||
state_root=self.root / "tag-publication",
|
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:
|
def _request(self, request: Mapping[str, Any]) -> PublicationRequest:
|
||||||
binding = self.binding
|
binding = self.binding
|
||||||
@@ -559,139 +546,7 @@ def _health(url: str, binding: ReleaseBinding, artifact: Mapping[str, Any]) -> d
|
|||||||
return {"status": "ACTIVE_VERIFIED" if exact else "CONFLICT", "http_status": status, **body}
|
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)
|
return GovernedProductionReleaseExecutor(config)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ from gitea_tag_publication import (
|
|||||||
TagPublisher,
|
TagPublisher,
|
||||||
)
|
)
|
||||||
|
|
||||||
from release_version import validate_release_version
|
|
||||||
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||||
|
|
||||||
@@ -46,10 +44,8 @@ def load_request() -> dict[str, Any]:
|
|||||||
raise ValueError("request_object_required")
|
raise ValueError("request_object_required")
|
||||||
if value.get("schema") != "aios.production-executor-request.v1":
|
if value.get("schema") != "aios.production-executor-request.v1":
|
||||||
raise ValueError("request_schema_invalid")
|
raise ValueError("request_schema_invalid")
|
||||||
try:
|
if not re.fullmatch(r"V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?", str(value.get("version", ""))):
|
||||||
validate_release_version(value.get("version", ""))
|
raise ValueError("version_invalid")
|
||||||
except ValueError as exc:
|
|
||||||
raise ValueError("version_invalid") from exc
|
|
||||||
for name in ("target_oid", "evidence_closure_oid"):
|
for name in ("target_oid", "evidence_closure_oid"):
|
||||||
if not OID_RE.fullmatch(str(value.get(name, ""))):
|
if not OID_RE.fullmatch(str(value.get(name, ""))):
|
||||||
raise ValueError(f"{name}_invalid")
|
raise ValueError(f"{name}_invalid")
|
||||||
|
|||||||
@@ -0,0 +1,456 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import datetime as dt
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable, Mapping
|
||||||
|
|
||||||
|
UTC = dt.timezone.utc
|
||||||
|
_ALLOWED_CONFIDENTIALITY = frozenset({"public", "tenant", "project", "private", "run"})
|
||||||
|
_PRIVILEGED = frozenset({"*", "admin", "system.admin", "message.admin", "task.admin"})
|
||||||
|
|
||||||
|
|
||||||
|
class HandoffError(RuntimeError):
|
||||||
|
code = "HANDOFF_ERROR"
|
||||||
|
|
||||||
|
|
||||||
|
class MessageScopeMismatch(HandoffError):
|
||||||
|
code = "MESSAGE_SCOPE_MISMATCH_DENY"
|
||||||
|
|
||||||
|
|
||||||
|
class ConfidentialityScopeDenied(HandoffError):
|
||||||
|
code = "MESSAGE_CONFIDENTIALITY_SCOPE_ENFORCE"
|
||||||
|
|
||||||
|
|
||||||
|
class MessageExpired(HandoffError):
|
||||||
|
code = "MESSAGE_EXPIRED"
|
||||||
|
|
||||||
|
|
||||||
|
class SequenceGap(HandoffError):
|
||||||
|
code = "MESSAGE_SEQUENCE_GAP_DETECT"
|
||||||
|
|
||||||
|
|
||||||
|
class SequenceConflict(HandoffError):
|
||||||
|
code = "MESSAGE_SEQUENCE_CONFLICT"
|
||||||
|
|
||||||
|
|
||||||
|
class IdempotencyConflict(HandoffError):
|
||||||
|
code = "MESSAGE_IDEMPOTENCY_CONFLICT"
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> dt.datetime:
|
||||||
|
return dt.datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value: dt.datetime | str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) if isinstance(value, str) else value
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=UTC)
|
||||||
|
return parsed.astimezone(UTC).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical(value: Any) -> str:
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def _sha(value: Any) -> str:
|
||||||
|
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value: Any) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _tuple_scope(value: Iterable[str] | str | None) -> tuple[str, ...]:
|
||||||
|
if value is None:
|
||||||
|
return ()
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = value.split(",")
|
||||||
|
return tuple(sorted({str(item).strip() for item in value if str(item).strip()}))
|
||||||
|
|
||||||
|
|
||||||
|
_SECRET_KEY = re.compile(r"(?i)(?:token|secret|password|private[_-]?key|api[_-]?key)")
|
||||||
|
_SECRET_VALUE = re.compile(r"(?i)bearer\s+[A-Za-z0-9._~+/-]{8,}|(?:token|secret|password|api[_-]?key)\s*[:=]\s*\S+")
|
||||||
|
_SAFE_REFERENCE = re.compile(r"^(?:systemd-credential|vault|secret-manager):[A-Za-z0-9][A-Za-z0-9._:/#-]{0,255}$")
|
||||||
|
|
||||||
|
|
||||||
|
def assert_payload_secret_safe(value: Any, *, key: str = "") -> None:
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
for child_key, child in value.items():
|
||||||
|
assert_payload_secret_safe(child, key=str(child_key))
|
||||||
|
return
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
for child in value:
|
||||||
|
assert_payload_secret_safe(child, key=key)
|
||||||
|
return
|
||||||
|
if isinstance(value, (bytes, bytearray)):
|
||||||
|
raise ValueError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||||
|
if isinstance(value, str):
|
||||||
|
if _SECRET_VALUE.search(value):
|
||||||
|
raise ValueError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||||
|
if _SECRET_KEY.search(key) and value and _SAFE_REFERENCE.fullmatch(value) is None:
|
||||||
|
raise ValueError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class DeliveryContext:
|
||||||
|
tenant_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
project_id: str | None = None
|
||||||
|
conversation_id: str | None = None
|
||||||
|
run_id: str | None = None
|
||||||
|
capabilities: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_actor(cls, actor: Any) -> "DeliveryContext":
|
||||||
|
def get(name: str) -> Any:
|
||||||
|
if isinstance(actor, Mapping):
|
||||||
|
return actor.get(name)
|
||||||
|
return getattr(actor, name, None)
|
||||||
|
return cls(
|
||||||
|
tenant_id=_clean(get("tenant_id")),
|
||||||
|
user_id=_clean(get("user_id")),
|
||||||
|
project_id=_clean(get("project_id")),
|
||||||
|
conversation_id=_clean(get("conversation_id")),
|
||||||
|
run_id=_clean(get("run_id")),
|
||||||
|
capabilities=_tuple_scope(get("capabilities") or get("capability_scope")),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def privileged(self) -> bool:
|
||||||
|
return bool(set(self.capabilities) & _PRIVILEGED)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class MessageEnvelope:
|
||||||
|
source: str
|
||||||
|
target: str
|
||||||
|
message_type: str
|
||||||
|
payload: Mapping[str, Any]
|
||||||
|
sequence: int
|
||||||
|
idempotency_key: str
|
||||||
|
tenant_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
project_id: str | None = None
|
||||||
|
conversation_id: str | None = None
|
||||||
|
task_id: str | None = None
|
||||||
|
run_id: str | None = None
|
||||||
|
correlation_id: str | None = None
|
||||||
|
causation_id: str | None = None
|
||||||
|
capability_scope: tuple[str, ...] = ()
|
||||||
|
confidentiality_scope: str = "private"
|
||||||
|
expires_at: str | None = None
|
||||||
|
created_at: str = dataclasses.field(default_factory=lambda: _iso(_utcnow()) or "")
|
||||||
|
envelope_id: str = ""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
assert_payload_secret_safe(self.payload)
|
||||||
|
for field in ("source", "target", "message_type", "idempotency_key", "tenant_id", "user_id",
|
||||||
|
"project_id", "conversation_id", "task_id", "run_id", "correlation_id", "causation_id"):
|
||||||
|
object.__setattr__(self, field, _clean(getattr(self, field)) if field not in {
|
||||||
|
"source", "target", "message_type", "idempotency_key"
|
||||||
|
} else (_clean(getattr(self, field)) or ""))
|
||||||
|
object.__setattr__(self, "capability_scope", _tuple_scope(self.capability_scope))
|
||||||
|
object.__setattr__(self, "confidentiality_scope", (_clean(self.confidentiality_scope) or "private").lower())
|
||||||
|
object.__setattr__(self, "expires_at", _iso(self.expires_at))
|
||||||
|
object.__setattr__(self, "created_at", _iso(self.created_at) or "")
|
||||||
|
if not self.source or not self.target or not self.message_type or not self.idempotency_key:
|
||||||
|
raise ValueError("MESSAGE_ENVELOPE_IDENTITY_REQUIRED")
|
||||||
|
if not isinstance(self.sequence, int) or self.sequence < 1:
|
||||||
|
raise ValueError("MESSAGE_SEQUENCE_MUST_BE_POSITIVE")
|
||||||
|
if self.confidentiality_scope not in _ALLOWED_CONFIDENTIALITY:
|
||||||
|
raise ValueError("MESSAGE_CONFIDENTIALITY_SCOPE_INVALID")
|
||||||
|
if self.confidentiality_scope != "public" and self.tenant_id is None:
|
||||||
|
raise ValueError("MESSAGE_TENANT_REQUIRED")
|
||||||
|
if self.confidentiality_scope in {"project", "private", "run"} and self.project_id is None:
|
||||||
|
raise ValueError("MESSAGE_PROJECT_REQUIRED")
|
||||||
|
if self.confidentiality_scope in {"private", "run"} and self.user_id is None:
|
||||||
|
raise ValueError("MESSAGE_USER_REQUIRED")
|
||||||
|
if self.confidentiality_scope == "run" and self.run_id is None:
|
||||||
|
raise ValueError("MESSAGE_RUN_REQUIRED")
|
||||||
|
expected = stable_event_id(
|
||||||
|
source=self.source, target=self.target, message_type=self.message_type, payload=self.payload,
|
||||||
|
tenant_id=self.tenant_id, user_id=self.user_id, project_id=self.project_id,
|
||||||
|
conversation_id=self.conversation_id, task_id=self.task_id, run_id=self.run_id,
|
||||||
|
correlation_id=self.correlation_id, causation_id=self.causation_id,
|
||||||
|
sequence=self.sequence, idempotency_key=self.idempotency_key,
|
||||||
|
)
|
||||||
|
if self.envelope_id and self.envelope_id != expected:
|
||||||
|
raise ValueError("MESSAGE_ENVELOPE_ID_MISMATCH")
|
||||||
|
object.__setattr__(self, "envelope_id", expected)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def payload_sha256(self) -> str:
|
||||||
|
return _sha(self.payload)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stream_key(self) -> str:
|
||||||
|
return _sha({
|
||||||
|
"tenant_id": self.tenant_id, "source": self.source, "target": self.target,
|
||||||
|
"conversation_id": self.conversation_id, "task_id": self.task_id, "run_id": self.run_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
def is_expired(self, *, now: dt.datetime | None = None) -> bool:
|
||||||
|
if self.expires_at is None:
|
||||||
|
return False
|
||||||
|
return dt.datetime.fromisoformat(self.expires_at) <= (now or _utcnow()).astimezone(UTC)
|
||||||
|
|
||||||
|
def as_record(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"envelope_id": self.envelope_id, "stream_key": self.stream_key, "source": self.source,
|
||||||
|
"target": self.target, "message_type": self.message_type, "payload_json": _canonical(self.payload),
|
||||||
|
"payload_sha256": self.payload_sha256, "tenant_id": self.tenant_id, "user_id": self.user_id,
|
||||||
|
"project_id": self.project_id, "conversation_id": self.conversation_id, "task_id": self.task_id,
|
||||||
|
"run_id": self.run_id, "correlation_id": self.correlation_id,
|
||||||
|
"causation_id": self.causation_id, "sequence": self.sequence,
|
||||||
|
"capability_scope_json": _canonical(self.capability_scope),
|
||||||
|
"confidentiality_scope": self.confidentiality_scope, "idempotency_key": self.idempotency_key,
|
||||||
|
"expires_at": self.expires_at, "created_at": self.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def stable_event_id(*, source: Any, target: Any, message_type: Any, payload: Any,
|
||||||
|
tenant_id: Any = None, user_id: Any = None, project_id: Any = None,
|
||||||
|
conversation_id: Any = None, task_id: Any = None, run_id: Any = None,
|
||||||
|
correlation_id: Any = None, causation_id: Any = None,
|
||||||
|
sequence: Any = None, idempotency_key: Any = None) -> str:
|
||||||
|
assert_payload_secret_safe(payload)
|
||||||
|
material = {
|
||||||
|
"source": _clean(source), "target": _clean(target), "message_type": _clean(message_type),
|
||||||
|
"payload_sha256": _sha(payload), "tenant_id": _clean(tenant_id), "user_id": _clean(user_id),
|
||||||
|
"project_id": _clean(project_id), "conversation_id": _clean(conversation_id),
|
||||||
|
"task_id": _clean(task_id), "run_id": _clean(run_id), "causation_id": _clean(causation_id),
|
||||||
|
"sequence": sequence, "idempotency_key": _clean(idempotency_key),
|
||||||
|
}
|
||||||
|
# Preserve historical envelope IDs for legacy rows that predate correlation_id.
|
||||||
|
if _clean(correlation_id) is not None:
|
||||||
|
material["correlation_id"] = _clean(correlation_id)
|
||||||
|
return _sha(material)
|
||||||
|
|
||||||
|
|
||||||
|
def bind_envelope_scope(envelope: MessageEnvelope, actor: Any) -> MessageEnvelope:
|
||||||
|
ctx = DeliveryContext.from_actor(actor)
|
||||||
|
if ctx.privileged:
|
||||||
|
return envelope
|
||||||
|
if ctx.tenant_id is None:
|
||||||
|
if envelope.tenant_id is not None:
|
||||||
|
raise MessageScopeMismatch(MessageScopeMismatch.code)
|
||||||
|
return envelope
|
||||||
|
updates: dict[str, Any] = {}
|
||||||
|
for field in ("tenant_id", "user_id", "project_id", "conversation_id", "run_id"):
|
||||||
|
trusted = getattr(ctx, field)
|
||||||
|
requested = getattr(envelope, field)
|
||||||
|
if requested is not None and requested != trusted:
|
||||||
|
raise MessageScopeMismatch(f"{MessageScopeMismatch.code}:{field}")
|
||||||
|
if requested is None and trusted is not None:
|
||||||
|
updates[field] = trusted
|
||||||
|
if not updates:
|
||||||
|
return envelope
|
||||||
|
data = dataclasses.asdict(envelope)
|
||||||
|
data.update(updates)
|
||||||
|
data["envelope_id"] = ""
|
||||||
|
return MessageEnvelope(**data)
|
||||||
|
|
||||||
|
|
||||||
|
def authorize_delivery(actor: Any, envelope: MessageEnvelope) -> None:
|
||||||
|
ctx = DeliveryContext.from_actor(actor)
|
||||||
|
if ctx.privileged:
|
||||||
|
return
|
||||||
|
if not set(envelope.capability_scope).issubset(set(ctx.capabilities)):
|
||||||
|
raise ConfidentialityScopeDenied(f"{ConfidentialityScopeDenied.code}:capability")
|
||||||
|
if envelope.confidentiality_scope == "public":
|
||||||
|
return
|
||||||
|
if ctx.tenant_id != envelope.tenant_id:
|
||||||
|
raise MessageScopeMismatch(f"{MessageScopeMismatch.code}:tenant_id")
|
||||||
|
if envelope.confidentiality_scope in {"project", "private", "run"} and ctx.project_id != envelope.project_id:
|
||||||
|
raise ConfidentialityScopeDenied(f"{ConfidentialityScopeDenied.code}:project_id")
|
||||||
|
if envelope.confidentiality_scope in {"private", "run"} and ctx.user_id != envelope.user_id:
|
||||||
|
raise ConfidentialityScopeDenied(f"{ConfidentialityScopeDenied.code}:user_id")
|
||||||
|
if envelope.confidentiality_scope == "run" and ctx.run_id != envelope.run_id:
|
||||||
|
raise ConfidentialityScopeDenied(f"{ConfidentialityScopeDenied.code}:run_id")
|
||||||
|
|
||||||
|
|
||||||
|
_SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS message_handoff (
|
||||||
|
envelope_id TEXT PRIMARY KEY,
|
||||||
|
stream_key TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
message_type TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
payload_sha256 TEXT NOT NULL,
|
||||||
|
tenant_id TEXT,
|
||||||
|
user_id TEXT,
|
||||||
|
project_id TEXT,
|
||||||
|
conversation_id TEXT,
|
||||||
|
task_id TEXT,
|
||||||
|
run_id TEXT,
|
||||||
|
correlation_id TEXT,
|
||||||
|
causation_id TEXT,
|
||||||
|
sequence INTEGER NOT NULL CHECK(sequence >= 1),
|
||||||
|
capability_scope_json TEXT NOT NULL,
|
||||||
|
confidentiality_scope TEXT NOT NULL,
|
||||||
|
idempotency_key TEXT NOT NULL,
|
||||||
|
expires_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'PENDING',
|
||||||
|
delivered_at TEXT,
|
||||||
|
acked_at TEXT,
|
||||||
|
UNIQUE(stream_key, idempotency_key),
|
||||||
|
UNIQUE(stream_key, sequence)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_message_handoff_target_status
|
||||||
|
ON message_handoff(target, status, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_message_handoff_scope
|
||||||
|
ON message_handoff(tenant_id, project_id, user_id, conversation_id, run_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class DurableHandoffStore:
|
||||||
|
def __init__(self, path: str | Path) -> None:
|
||||||
|
self.path = Path(path)
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.executescript(_SCHEMA)
|
||||||
|
columns = {str(row[1]) for row in conn.execute("PRAGMA table_info(message_handoff)")}
|
||||||
|
if "correlation_id" not in columns:
|
||||||
|
conn.execute("ALTER TABLE message_handoff ADD COLUMN correlation_id TEXT")
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_message_handoff_lineage "
|
||||||
|
"ON message_handoff(correlation_id, causation_id)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(self.path, timeout=30.0)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
conn.execute("PRAGMA busy_timeout=30000")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _from_row(row: sqlite3.Row) -> MessageEnvelope:
|
||||||
|
return MessageEnvelope(
|
||||||
|
source=row["source"], target=row["target"], message_type=row["message_type"],
|
||||||
|
payload=json.loads(row["payload_json"]), tenant_id=row["tenant_id"], user_id=row["user_id"],
|
||||||
|
project_id=row["project_id"], conversation_id=row["conversation_id"], task_id=row["task_id"],
|
||||||
|
run_id=row["run_id"], correlation_id=row["correlation_id"],
|
||||||
|
causation_id=row["causation_id"], sequence=int(row["sequence"]),
|
||||||
|
capability_scope=tuple(json.loads(row["capability_scope_json"])),
|
||||||
|
confidentiality_scope=row["confidentiality_scope"], idempotency_key=row["idempotency_key"],
|
||||||
|
expires_at=row["expires_at"], created_at=row["created_at"], envelope_id=row["envelope_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def put(self, envelope: MessageEnvelope) -> tuple[str, MessageEnvelope]:
|
||||||
|
if envelope.is_expired():
|
||||||
|
raise MessageExpired(MessageExpired.code)
|
||||||
|
record = envelope.as_record()
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
replay = conn.execute(
|
||||||
|
"SELECT * FROM message_handoff WHERE stream_key=? AND idempotency_key=?",
|
||||||
|
(envelope.stream_key, envelope.idempotency_key),
|
||||||
|
).fetchone()
|
||||||
|
if replay is not None:
|
||||||
|
existing = self._from_row(replay)
|
||||||
|
if existing.envelope_id != envelope.envelope_id or existing.payload_sha256 != envelope.payload_sha256:
|
||||||
|
raise IdempotencyConflict(IdempotencyConflict.code)
|
||||||
|
conn.commit()
|
||||||
|
return "REPLAY", existing
|
||||||
|
seq = conn.execute(
|
||||||
|
"SELECT * FROM message_handoff WHERE stream_key=? AND sequence=?",
|
||||||
|
(envelope.stream_key, envelope.sequence),
|
||||||
|
).fetchone()
|
||||||
|
if seq is not None:
|
||||||
|
existing = self._from_row(seq)
|
||||||
|
if existing.envelope_id == envelope.envelope_id:
|
||||||
|
conn.commit()
|
||||||
|
return "REPLAY", existing
|
||||||
|
raise SequenceConflict(SequenceConflict.code)
|
||||||
|
maximum = int(conn.execute(
|
||||||
|
"SELECT COALESCE(MAX(sequence),0) FROM message_handoff WHERE stream_key=?",
|
||||||
|
(envelope.stream_key,),
|
||||||
|
).fetchone()[0])
|
||||||
|
expected = maximum + 1
|
||||||
|
if envelope.sequence != expected:
|
||||||
|
raise SequenceGap(f"{SequenceGap.code}:expected={expected}:actual={envelope.sequence}")
|
||||||
|
columns = tuple(record)
|
||||||
|
placeholders = ",".join("?" for _ in columns)
|
||||||
|
conn.execute(
|
||||||
|
f"INSERT INTO message_handoff ({','.join(columns)}) VALUES ({placeholders})",
|
||||||
|
tuple(record[column] for column in columns),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return "CREATED", envelope
|
||||||
|
|
||||||
|
def get(self, envelope_id: str, *, actor: Any) -> MessageEnvelope:
|
||||||
|
with self._connect() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM message_handoff WHERE envelope_id=?", (envelope_id,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise KeyError(envelope_id)
|
||||||
|
envelope = self._from_row(row)
|
||||||
|
if envelope.is_expired():
|
||||||
|
conn.execute("UPDATE message_handoff SET status='EXPIRED' WHERE envelope_id=?", (envelope_id,))
|
||||||
|
conn.commit()
|
||||||
|
raise MessageExpired(MessageExpired.code)
|
||||||
|
authorize_delivery(actor, envelope)
|
||||||
|
return envelope
|
||||||
|
|
||||||
|
def pending(self, *, target: str, actor: Any, limit: int = 100) -> list[MessageEnvelope]:
|
||||||
|
if not 1 <= limit <= 1000:
|
||||||
|
raise ValueError("MESSAGE_PENDING_LIMIT_INVALID")
|
||||||
|
visible: list[MessageEnvelope] = []
|
||||||
|
with self._connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM message_handoff WHERE target=? AND status='PENDING' ORDER BY created_at, sequence LIMIT ?",
|
||||||
|
(target, limit),
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
envelope = self._from_row(row)
|
||||||
|
if envelope.is_expired():
|
||||||
|
conn.execute("UPDATE message_handoff SET status='EXPIRED' WHERE envelope_id=?", (envelope.envelope_id,))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
authorize_delivery(actor, envelope)
|
||||||
|
except HandoffError:
|
||||||
|
continue
|
||||||
|
visible.append(envelope)
|
||||||
|
conn.commit()
|
||||||
|
return visible
|
||||||
|
|
||||||
|
def mark_delivered(self, envelope_id: str, *, actor: Any) -> MessageEnvelope:
|
||||||
|
envelope = self.get(envelope_id, actor=actor)
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE message_handoff SET status='DELIVERED', delivered_at=? "
|
||||||
|
"WHERE envelope_id=? AND status='PENDING'",
|
||||||
|
(_iso(_utcnow()), envelope_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return envelope
|
||||||
|
|
||||||
|
def acknowledge(self, envelope_id: str, *, actor: Any) -> MessageEnvelope:
|
||||||
|
envelope = self.get(envelope_id, actor=actor)
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE message_handoff SET status='ACKED', acked_at=? WHERE envelope_id=?",
|
||||||
|
(_iso(_utcnow()), envelope_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return envelope
|
||||||
|
|
||||||
|
|
||||||
|
class HandoffFabric:
|
||||||
|
def __init__(self, store: DurableHandoffStore) -> None:
|
||||||
|
self.store = store
|
||||||
|
|
||||||
|
def publish(self, envelope: MessageEnvelope, *, actor: Any | None = None) -> tuple[str, MessageEnvelope]:
|
||||||
|
return self.store.put(bind_envelope_scope(envelope, actor) if actor is not None else envelope)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from message_handoff import MessageEnvelope
|
||||||
|
|
||||||
|
|
||||||
|
def _get(value: Any, *names: str, default: Any = None) -> Any:
|
||||||
|
for name in names:
|
||||||
|
if isinstance(value, Mapping) and name in value:
|
||||||
|
return value[name]
|
||||||
|
if hasattr(value, name):
|
||||||
|
return getattr(value, name)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def agent_event_to_envelope(event: Any, *, target: str | None = None, sequence: int = 1,
|
||||||
|
idempotency_key: str | None = None, **scope: Any) -> MessageEnvelope:
|
||||||
|
payload = _get(event, "payload", "data", default={}) or {}
|
||||||
|
target = target or _get(event, "target", "recipient")
|
||||||
|
if not target:
|
||||||
|
raise ValueError("MESSAGE_TARGET_REQUIRED")
|
||||||
|
return MessageEnvelope(
|
||||||
|
source=str(_get(event, "source", "producer", "agent_id", default="agent-event")),
|
||||||
|
target=str(target),
|
||||||
|
message_type=str(_get(event, "event_type", "type", "message_type", default="agent.event")),
|
||||||
|
payload=dict(payload),
|
||||||
|
sequence=sequence,
|
||||||
|
idempotency_key=idempotency_key or str(_get(event, "id", "event_id", default="")) or f"agent-event:{sequence}",
|
||||||
|
causation_id=_get(event, "causation_id"),
|
||||||
|
**scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def taskplane_event_to_envelope(event: Any, *, target: str, sequence: int = 1,
|
||||||
|
idempotency_key: str | None = None, **scope: Any) -> MessageEnvelope:
|
||||||
|
data = _get(event, "data", "payload", default={}) or {}
|
||||||
|
event_id = str(_get(event, "id", "event_id", default=""))
|
||||||
|
return MessageEnvelope(
|
||||||
|
source=str(_get(event, "source", default="task-plane")),
|
||||||
|
target=target,
|
||||||
|
message_type=str(_get(event, "type", "event_type", default="task.event")),
|
||||||
|
payload=dict(data),
|
||||||
|
sequence=sequence,
|
||||||
|
idempotency_key=idempotency_key or event_id or f"task-event:{sequence}",
|
||||||
|
causation_id=event_id or None,
|
||||||
|
**scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_message_to_envelope(message: Any, *, target: str | None = None, sequence: int = 1,
|
||||||
|
idempotency_key: str | None = None, **scope: Any) -> MessageEnvelope:
|
||||||
|
payload = _get(message, "payload", "data", "body", "content", default={})
|
||||||
|
if not isinstance(payload, Mapping):
|
||||||
|
payload = {"value": payload}
|
||||||
|
target = target or _get(message, "target", "recipient", "receiver")
|
||||||
|
if not target:
|
||||||
|
raise ValueError("MESSAGE_TARGET_REQUIRED")
|
||||||
|
source = _get(message, "source", "sender", "agent_id", default="legacy-message-bus")
|
||||||
|
kind = _get(message, "message_type", "type", "kind", default="legacy.message")
|
||||||
|
identifier = _get(message, "id", "message_id", "event_id")
|
||||||
|
return MessageEnvelope(
|
||||||
|
source=str(source), target=str(target), message_type=str(kind), payload=dict(payload),
|
||||||
|
sequence=sequence,
|
||||||
|
idempotency_key=idempotency_key or (str(identifier) if identifier else f"legacy:{sequence}"),
|
||||||
|
causation_id=str(identifier) if identifier else None,
|
||||||
|
**scope,
|
||||||
|
)
|
||||||
@@ -27,8 +27,6 @@ import urllib.request
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Mapping
|
from typing import Any, Mapping
|
||||||
|
|
||||||
from release_version import validate_release_version
|
|
||||||
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||||
PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||||
@@ -88,10 +86,8 @@ def load_request() -> dict[str, Any]:
|
|||||||
raise ValueError("request_schema_invalid")
|
raise ValueError("request_schema_invalid")
|
||||||
if value.get("repository_identity") != "aios/aios":
|
if value.get("repository_identity") != "aios/aios":
|
||||||
raise ValueError("repository_identity_invalid")
|
raise ValueError("repository_identity_invalid")
|
||||||
try:
|
if not re.fullmatch(r"V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?", str(value.get("version", ""))):
|
||||||
validate_release_version(value.get("version", ""))
|
raise ValueError("version_invalid")
|
||||||
except ValueError as exc:
|
|
||||||
raise ValueError("version_invalid") from exc
|
|
||||||
for name in ("target_oid", "evidence_closure_oid"):
|
for name in ("target_oid", "evidence_closure_oid"):
|
||||||
if not OID_RE.fullmatch(str(value.get(name, ""))):
|
if not OID_RE.fullmatch(str(value.get(name, ""))):
|
||||||
raise ValueError(f"{name}_invalid")
|
raise ValueError(f"{name}_invalid")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from dataclasses import asdict, dataclass, field
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .search_gateway_orchestrator import SearchGatewayOrchestrator
|
from .search_gateway_orchestrator import SearchGatewayOrchestrator
|
||||||
|
from .trace_tool_credential_governance import GovernanceActor, TraceContext
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -43,6 +44,9 @@ class SearchGatewayAgentAdapter:
|
|||||||
domain_profiles: list[str] | None = None,
|
domain_profiles: list[str] | None = None,
|
||||||
user_scope: str = "public",
|
user_scope: str = "public",
|
||||||
correlation_id: str | None = None,
|
correlation_id: str | None = None,
|
||||||
|
*,
|
||||||
|
actor: GovernanceActor | dict[str, Any] | None = None,
|
||||||
|
trace: TraceContext | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
payload = {
|
payload = {
|
||||||
"query": query,
|
"query": query,
|
||||||
@@ -59,7 +63,11 @@ class SearchGatewayAgentAdapter:
|
|||||||
"session_profile": context.session_profile,
|
"session_profile": context.session_profile,
|
||||||
"correlation_id": correlation_id,
|
"correlation_id": correlation_id,
|
||||||
}
|
}
|
||||||
|
if actor is None and trace is None:
|
||||||
|
# Preserve adapter compatibility; the governed orchestrator itself
|
||||||
|
# still fails closed when trusted context is absent.
|
||||||
return self.orchestrator.execute(payload)
|
return self.orchestrator.execute(payload)
|
||||||
|
return self.orchestrator.execute(payload, actor=actor, trace=trace)
|
||||||
|
|
||||||
|
|
||||||
class OpenClawSearchClient(SearchGatewayAgentAdapter):
|
class OpenClawSearchClient(SearchGatewayAgentAdapter):
|
||||||
|
|||||||
@@ -13,10 +13,18 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from .search_profile_resolver import DomainQueryProfileRegistry, SearchProfile, SearchProfileResolver
|
from .search_profile_resolver import DomainQueryProfileRegistry, SearchProfile, SearchProfileResolver
|
||||||
|
from .trace_tool_credential_governance import (
|
||||||
|
GovernanceActor,
|
||||||
|
GovernanceError,
|
||||||
|
GovernanceStore,
|
||||||
|
TraceContext,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> str:
|
def utc_now() -> str:
|
||||||
@@ -156,13 +164,25 @@ class SearchPolicyGuard:
|
|||||||
|
|
||||||
|
|
||||||
class SearchGateway:
|
class SearchGateway:
|
||||||
def __init__(self, providers: list[SearchProvider] | None = None):
|
def __init__(self, providers: list[SearchProvider] | None = None, *, governance_store: GovernanceStore | None = None):
|
||||||
self.providers = providers or []
|
self.providers = providers or []
|
||||||
|
self.governance_store = governance_store
|
||||||
self.profile_resolver = SearchProfileResolver()
|
self.profile_resolver = SearchProfileResolver()
|
||||||
self.planner = QueryPlanner()
|
self.planner = QueryPlanner()
|
||||||
self.guard = SearchPolicyGuard()
|
self.guard = SearchPolicyGuard()
|
||||||
|
|
||||||
def search(self, request: SearchRequest) -> SearchResponse:
|
def search(self, request: SearchRequest, *, actor: GovernanceActor | dict[str, Any] | None = None, trace: TraceContext | None = None) -> SearchResponse:
|
||||||
|
trusted_actor: GovernanceActor | None = None
|
||||||
|
if self.governance_store is not None:
|
||||||
|
if actor is None or trace is None:
|
||||||
|
raise GovernanceError("TOOL_MCP_AUTHZ_DENY")
|
||||||
|
trusted_actor = GovernanceActor.from_value(actor)
|
||||||
|
if request.requester != trusted_actor.identity:
|
||||||
|
raise GovernanceError("TOOL_MCP_AUTHZ_DENY", field="requester")
|
||||||
|
if request.correlation_id is not None and request.correlation_id not in {
|
||||||
|
trace.trace_id, trace.correlation_id
|
||||||
|
}:
|
||||||
|
raise GovernanceError("TOOL_SCOPE_PROPAGATION", field="correlation_id")
|
||||||
request_id = request.correlation_id or f"search_{uuid4().hex[:12]}"
|
request_id = request.correlation_id or f"search_{uuid4().hex[:12]}"
|
||||||
profile = self.profile_resolver.resolve(request.group_profile, request.user_profile, request.session_profile)
|
profile = self.profile_resolver.resolve(request.group_profile, request.user_profile, request.session_profile)
|
||||||
expanded_queries = self.planner.expand(request, profile)
|
expanded_queries = self.planner.expand(request, profile)
|
||||||
@@ -184,14 +204,60 @@ class SearchGateway:
|
|||||||
attempts: list[SearchProviderAttempt] = []
|
attempts: list[SearchProviderAttempt] = []
|
||||||
providers = self._ordered_providers(profile)
|
providers = self._ordered_providers(profile)
|
||||||
for provider in providers:
|
for provider in providers:
|
||||||
|
if request.authorized_sources and provider.name not in request.authorized_sources:
|
||||||
|
attempts.append(SearchProviderAttempt(provider=provider.name, status="denied", reason="SOURCE_AUTHZ_DENY"))
|
||||||
|
continue
|
||||||
|
if request.user_scope != "public" and provider.name not in request.authorized_sources:
|
||||||
|
attempts.append(SearchProviderAttempt(provider=provider.name, status="denied", reason="SOURCE_AUTHZ_REQUIRED"))
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
|
if self.governance_store is not None:
|
||||||
|
assert trusted_actor is not None and trace is not None
|
||||||
|
input_digest = hashlib.sha256(
|
||||||
|
json.dumps(
|
||||||
|
{"query": request.query, "intent": request.intent, "provider": provider.name},
|
||||||
|
sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
invocation_key = hashlib.sha256(
|
||||||
|
f"{trace.trace_id}:{trace.span_id}:{provider.name}:{input_digest}".encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
authorization = self.governance_store.authorize_tool(
|
||||||
|
actor=trusted_actor,
|
||||||
|
tool_name=f"search.{provider.name}",
|
||||||
|
trace=trace,
|
||||||
|
requested_scope=trace.scope,
|
||||||
|
input_digest=input_digest,
|
||||||
|
invocation_key=invocation_key,
|
||||||
|
)
|
||||||
|
if authorization.get("idempotent"):
|
||||||
|
raise GovernanceError("TOOL_INVOCATION_REPLAY")
|
||||||
provider_evidence, attempt = provider.search(request, expanded_queries)
|
provider_evidence, attempt = provider.search(request, expanded_queries)
|
||||||
|
if self.governance_store is not None:
|
||||||
|
result_digest = hashlib.sha256(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"evidence": [item.to_dict() for item in provider_evidence],
|
||||||
|
"attempt": attempt.to_dict(),
|
||||||
|
},
|
||||||
|
sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
self.governance_store.record_tool_receipt(
|
||||||
|
invocation_key=invocation_key,
|
||||||
|
result_digest=result_digest,
|
||||||
|
actor=trusted_actor,
|
||||||
|
trace=trace,
|
||||||
|
)
|
||||||
evidence.extend(provider_evidence)
|
evidence.extend(provider_evidence)
|
||||||
attempts.append(attempt)
|
attempts.append(attempt)
|
||||||
if provider_evidence:
|
if provider_evidence:
|
||||||
break
|
break
|
||||||
except Exception as exc:
|
except GovernanceError:
|
||||||
attempts.append(SearchProviderAttempt(provider=getattr(provider, "name", "unknown"), status="failed", reason=str(exc)))
|
raise
|
||||||
|
except Exception:
|
||||||
|
# Provider exceptions may contain URLs, tokens, or local paths.
|
||||||
|
attempts.append(SearchProviderAttempt(provider=getattr(provider, "name", "unknown"), status="failed", reason="PROVIDER_FAILURE"))
|
||||||
|
|
||||||
if evidence:
|
if evidence:
|
||||||
return SearchResponse(
|
return SearchResponse(
|
||||||
|
|||||||
@@ -7,27 +7,29 @@ try:
|
|||||||
except Exception:
|
except Exception:
|
||||||
APIRouter = None
|
APIRouter = None
|
||||||
|
|
||||||
from .search_gateway import SearchGateway, SearchRequest
|
from .search_gateway import SearchRequest
|
||||||
|
from .search_gateway_orchestrator import SearchGatewayOrchestrator
|
||||||
|
from .trace_tool_credential_governance import GovernanceActor, TraceContext
|
||||||
from .search_gateway_dashboard import SearchGatewayDashboard
|
from .search_gateway_dashboard import SearchGatewayDashboard
|
||||||
|
|
||||||
|
|
||||||
gateway = SearchGateway()
|
orchestrator = SearchGatewayOrchestrator()
|
||||||
dashboard = SearchGatewayDashboard()
|
dashboard = SearchGatewayDashboard()
|
||||||
|
|
||||||
|
|
||||||
def search_gateway_request(payload: dict[str, Any]) -> dict[str, Any]:
|
def search_gateway_request(payload: dict[str, Any], *, actor: GovernanceActor | dict[str, Any] | None = None, trace: TraceContext | None = None) -> dict[str, Any]:
|
||||||
request = SearchRequest(**payload)
|
return orchestrator.execute(payload, actor=actor, trace=trace)
|
||||||
return gateway.search(request).to_dict()
|
|
||||||
|
|
||||||
|
|
||||||
def get_search_gateway_status() -> dict[str, Any]:
|
def get_search_gateway_status() -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"module": "search-gateway",
|
"module": "search-gateway",
|
||||||
"status": "ready",
|
"status": "ready",
|
||||||
"providers_configured": len(gateway.providers),
|
"providers_configured": len(orchestrator.gateway.providers),
|
||||||
"policy_guard": "enabled",
|
"policy_guard": "enabled",
|
||||||
"profile_customization": "enabled",
|
"profile_customization": "enabled",
|
||||||
"fallback_required_when_provider_unavailable": True,
|
"fallback_required_when_provider_unavailable": True,
|
||||||
|
"governance": "required",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,24 +3,49 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
import os
|
||||||
|
|
||||||
from .search_audit_store import SearchAuditStore
|
from .search_audit_store import SearchAuditStore
|
||||||
from .search_event_bridge import SearchEventBridge
|
from .search_event_bridge import SearchEventBridge
|
||||||
from .search_gateway import SearchGateway, SearchRequest
|
from .search_gateway import SearchGateway, SearchRequest
|
||||||
from .search_provider_registry import SearchProviderRegistry
|
from .search_provider_registry import SearchProviderRegistry
|
||||||
|
from .trace_tool_credential_governance import (
|
||||||
|
GovernanceActor,
|
||||||
|
GovernanceScope,
|
||||||
|
GovernanceStore,
|
||||||
|
ToolDescriptor,
|
||||||
|
TraceContext,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SearchGatewayOrchestrator:
|
class SearchGatewayOrchestrator:
|
||||||
def __init__(self):
|
def __init__(self, *, governance_store: GovernanceStore | None = None):
|
||||||
self.registry = SearchProviderRegistry()
|
self.registry = SearchProviderRegistry()
|
||||||
self.audit = SearchAuditStore()
|
self.audit = SearchAuditStore()
|
||||||
self.events = SearchEventBridge()
|
self.events = SearchEventBridge()
|
||||||
self.gateway = SearchGateway(self.registry.ordered())
|
self.governance = governance_store or GovernanceStore(
|
||||||
|
os.environ.get("AIOS_SEARCH_GOVERNANCE_DB", "runtime/search-governance.sqlite3")
|
||||||
|
)
|
||||||
|
registrar = GovernanceActor(
|
||||||
|
"search-runtime-bootstrap", GovernanceScope("system"), ("system.admin",)
|
||||||
|
)
|
||||||
|
for provider in self.registry.ordered():
|
||||||
|
self.governance.register_tool(
|
||||||
|
ToolDescriptor(
|
||||||
|
f"search.{provider.name}", "tool", GovernanceScope("global"), ("tool.search",)
|
||||||
|
),
|
||||||
|
registrar,
|
||||||
|
)
|
||||||
|
self.gateway = SearchGateway(self.registry.ordered(), governance_store=self.governance)
|
||||||
|
|
||||||
def execute(self, payload: dict[str, Any]) -> dict[str, Any]:
|
def execute(self, payload: dict[str, Any], *, actor: GovernanceActor | dict[str, Any] | None = None, trace: TraceContext | None = None) -> dict[str, Any]:
|
||||||
|
if actor is None or trace is None:
|
||||||
|
from .trace_tool_credential_governance import GovernanceError
|
||||||
|
raise GovernanceError("TOOL_MCP_AUTHZ_DENY")
|
||||||
|
self.governance.record_trace(trace, actor)
|
||||||
request = SearchRequest(**payload)
|
request = SearchRequest(**payload)
|
||||||
self.events.search_started(request.to_dict())
|
self.events.search_started(request.to_dict())
|
||||||
response = self.gateway.search(request)
|
response = self.gateway.search(request, actor=actor, trace=trace)
|
||||||
result = response.to_dict()
|
result = response.to_dict()
|
||||||
for attempt in result.get("attempts", []):
|
for attempt in result.get("attempts", []):
|
||||||
self.events.provider_attempted(result["request_id"], attempt)
|
self.events.provider_attempted(result["request_id"], attempt)
|
||||||
@@ -35,6 +60,7 @@ class SearchGatewayOrchestrator:
|
|||||||
"registry": self.registry.status(),
|
"registry": self.registry.status(),
|
||||||
"audit": "enabled",
|
"audit": "enabled",
|
||||||
"events": "enabled",
|
"events": "enabled",
|
||||||
|
"governance": "required",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,12 +22,11 @@ from enum import Enum
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
from release_version import validate_release_version
|
|
||||||
|
|
||||||
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||||
CANDIDATE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{2,127}$")
|
CANDIDATE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{2,127}$")
|
||||||
|
VERSION_RE = re.compile(r"^V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$")
|
||||||
SECRET_PATTERNS = (
|
SECRET_PATTERNS = (
|
||||||
re.compile(r"(?i)(authorization\s*[:=]\s*)(?:bearer|token)\s+\S+"),
|
re.compile(r"(?i)(authorization\s*[:=]\s*)(?:bearer|token)\s+\S+"),
|
||||||
re.compile(r"(?i)((?:access_)?token\s*[:=]\s*)\S+"),
|
re.compile(r"(?i)((?:access_)?token\s*[:=]\s*)\S+"),
|
||||||
@@ -770,10 +769,8 @@ class TagApprovalEnvelope:
|
|||||||
def validate(self) -> None:
|
def validate(self) -> None:
|
||||||
if self.action != "production_tag":
|
if self.action != "production_tag":
|
||||||
raise ApprovalError("approval does not authorize production tag")
|
raise ApprovalError("approval does not authorize production tag")
|
||||||
try:
|
if not VERSION_RE.fullmatch(self.version):
|
||||||
validate_release_version(self.version)
|
raise ApprovalError("invalid release version")
|
||||||
except ValueError as exc:
|
|
||||||
raise ApprovalError("invalid release version") from exc
|
|
||||||
_validate_oid("tag_oid", self.tag_oid)
|
_validate_oid("tag_oid", self.tag_oid)
|
||||||
_validate_oid("target_oid", self.target_oid)
|
_validate_oid("target_oid", self.target_oid)
|
||||||
if _parse_utc(self.expires_at) <= datetime.now(timezone.utc):
|
if _parse_utc(self.expires_at) <= datetime.now(timezone.utc):
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -41,8 +40,6 @@ from silent_release_controller import (
|
|||||||
|
|
||||||
IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{2,255}$")
|
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}$")
|
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(
|
ALLOWED_CAPABILITIES = frozenset(
|
||||||
{
|
{
|
||||||
"runtime_switch",
|
"runtime_switch",
|
||||||
@@ -65,10 +62,6 @@ class CapabilityAuthorizationError(RuntimeError):
|
|||||||
"""The signed plan cannot authorize the requested operation."""
|
"""The signed plan cannot authorize the requested operation."""
|
||||||
|
|
||||||
|
|
||||||
class TrustedCandidateBuilderUnavailable(RuntimeError):
|
|
||||||
"""The purpose-bound builder dependency cannot currently serve a build."""
|
|
||||||
|
|
||||||
|
|
||||||
class StaleApprovalError(CapabilityAuthorizationError):
|
class StaleApprovalError(CapabilityAuthorizationError):
|
||||||
"""Live release evidence no longer matches the signed approval."""
|
"""Live release evidence no longer matches the signed approval."""
|
||||||
|
|
||||||
@@ -229,10 +222,6 @@ JOURNAL_GENESIS_HASH = "0" * 64
|
|||||||
|
|
||||||
|
|
||||||
def _fsync_directory(path: Path) -> None:
|
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)
|
descriptor = os.open(Path(path), os.O_RDONLY)
|
||||||
try:
|
try:
|
||||||
os.fsync(descriptor)
|
os.fsync(descriptor)
|
||||||
@@ -726,14 +715,6 @@ class BuilderAttestation:
|
|||||||
canary_evidence_sha256: str
|
canary_evidence_sha256: str
|
||||||
issued_at: str
|
issued_at: str
|
||||||
signature: 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:
|
def __post_init__(self) -> None:
|
||||||
_validate_identifier(
|
_validate_identifier(
|
||||||
@@ -750,35 +731,7 @@ class BuilderAttestation:
|
|||||||
"canary_evidence_sha256",
|
"canary_evidence_sha256",
|
||||||
):
|
):
|
||||||
_validate_sha(name, getattr(self, name))
|
_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)
|
_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
|
@property
|
||||||
def canonical_payload(self) -> dict[str, Any]:
|
def canonical_payload(self) -> dict[str, Any]:
|
||||||
@@ -801,290 +754,20 @@ class BuilderAttestation:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(
|
def from_dict(cls, payload: Mapping[str, Any]) -> "BuilderAttestation":
|
||||||
cls,
|
|
||||||
payload: Mapping[str, Any],
|
|
||||||
*,
|
|
||||||
require_fingerprint: bool = False,
|
|
||||||
) -> "BuilderAttestation":
|
|
||||||
values = dict(payload)
|
values = dict(payload)
|
||||||
supplied_fingerprint = values.pop("fingerprint", None)
|
values.pop("fingerprint", None)
|
||||||
# Older local records did not spell out the domain. They are only
|
return cls(**values)
|
||||||
# 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:
|
class BuilderAttestationVerifier:
|
||||||
"""Public-key-only verifier; no builder private material is accepted."""
|
"""Public-key-only verifier; no builder private material is accepted."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, public_keys: Mapping[str, bytes]):
|
||||||
self,
|
self.public_keys = {
|
||||||
public_keys: BuilderAttestationKeyRing | Mapping[str, bytes],
|
_validate_identifier("builder_key_id", key_id): bytes(value)
|
||||||
):
|
for key_id, value in public_keys.items()
|
||||||
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(
|
def verify(
|
||||||
self,
|
self,
|
||||||
@@ -1110,54 +793,22 @@ class BuilderAttestationVerifier:
|
|||||||
raise CapabilityAuthorizationError(
|
raise CapabilityAuthorizationError(
|
||||||
"builder attestation does not bind candidate evidence"
|
"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:
|
try:
|
||||||
signature = base64.b64decode(
|
signature = base64.b64decode(
|
||||||
attestation.signature, validate=True
|
attestation.signature, validate=True
|
||||||
)
|
)
|
||||||
key_record = self.keyring.verify(
|
Ed25519PublicKey.from_public_bytes(key).verify(
|
||||||
attestation.builder_key_id,
|
signature, attestation.canonical_bytes()
|
||||||
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:
|
except (InvalidSignature, ValueError, TypeError) as exc:
|
||||||
raise CapabilityAuthorizationError(
|
raise CapabilityAuthorizationError(
|
||||||
"builder attestation signature is invalid"
|
"builder attestation signature is invalid"
|
||||||
) from exc
|
) 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
|
return attestation
|
||||||
|
|
||||||
|
|
||||||
@@ -3530,12 +3181,9 @@ class SilentReleaseActionService:
|
|||||||
builder_attestation_verifier: BuilderAttestationVerifier | None = None,
|
builder_attestation_verifier: BuilderAttestationVerifier | None = None,
|
||||||
trusted_policy: TrustedReleasePolicy | None = None,
|
trusted_policy: TrustedReleasePolicy | None = None,
|
||||||
orchestrator: ReleaseOrchestrator | None = None,
|
orchestrator: ReleaseOrchestrator | None = None,
|
||||||
builder_readiness_probe: Callable[[], Mapping[str, Any]] | None = None,
|
|
||||||
allow_legacy_candidate_input: bool = False,
|
allow_legacy_candidate_input: bool = False,
|
||||||
expected_repository: str = "",
|
expected_repository: str = "",
|
||||||
policy_sha256: str = "",
|
policy_sha256: str = "",
|
||||||
max_builder_attempts: int = 3,
|
|
||||||
builder_attempt_timeout_seconds: int = 180,
|
|
||||||
):
|
):
|
||||||
self.root = Path(root)
|
self.root = Path(root)
|
||||||
self.execution_enabled = bool(execution_enabled)
|
self.execution_enabled = bool(execution_enabled)
|
||||||
@@ -3546,28 +3194,11 @@ class SilentReleaseActionService:
|
|||||||
self.builder_attestation_verifier = builder_attestation_verifier
|
self.builder_attestation_verifier = builder_attestation_verifier
|
||||||
self.trusted_policy = trusted_policy
|
self.trusted_policy = trusted_policy
|
||||||
self.orchestrator = orchestrator
|
self.orchestrator = orchestrator
|
||||||
self.builder_readiness_probe = builder_readiness_probe
|
|
||||||
self.allow_legacy_candidate_input = bool(
|
self.allow_legacy_candidate_input = bool(
|
||||||
allow_legacy_candidate_input
|
allow_legacy_candidate_input
|
||||||
)
|
)
|
||||||
self.expected_repository = expected_repository
|
self.expected_repository = expected_repository
|
||||||
self.policy_sha256 = policy_sha256
|
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.candidates = ReleaseCandidateStore(
|
||||||
self.root / "candidates"
|
self.root / "candidates"
|
||||||
)
|
)
|
||||||
@@ -3604,26 +3235,10 @@ class SilentReleaseActionService:
|
|||||||
+ ", ".join(missing)
|
+ ", ".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(
|
def create_release_intent(
|
||||||
self, payload: Mapping[str, Any]
|
self, payload: Mapping[str, Any]
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
allowed = {"event_id", "source_commit", "version"}
|
allowed = {"event_id", "source_commit"}
|
||||||
unknown = set(payload).difference(allowed)
|
unknown = set(payload).difference(allowed)
|
||||||
if unknown:
|
if unknown:
|
||||||
raise CapabilityAuthorizationError(
|
raise CapabilityAuthorizationError(
|
||||||
@@ -3635,17 +3250,12 @@ class SilentReleaseActionService:
|
|||||||
source_commit = _validate_oid(
|
source_commit = _validate_oid(
|
||||||
"source_commit", str(payload.get("source_commit") or "")
|
"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(
|
intent_id = "intent-" + hashlib.sha256(
|
||||||
f"{event_id}\0{source_commit}".encode("utf-8")
|
f"{event_id}\0{source_commit}".encode("utf-8")
|
||||||
).hexdigest()[:24]
|
).hexdigest()[:24]
|
||||||
path = _safe_child(self.intents_root, intent_id)
|
path = _safe_child(self.intents_root, intent_id)
|
||||||
was_existing = path.exists()
|
|
||||||
record = {
|
record = {
|
||||||
"created_at": utc_now(),
|
"created_at": utc_now(),
|
||||||
"attempt_count": 0,
|
|
||||||
"event_id": event_id,
|
"event_id": event_id,
|
||||||
"intent_id": intent_id,
|
"intent_id": intent_id,
|
||||||
"source_commit": source_commit,
|
"source_commit": source_commit,
|
||||||
@@ -3655,153 +3265,42 @@ class SilentReleaseActionService:
|
|||||||
else "BUILDER_UNAVAILABLE"
|
else "BUILDER_UNAVAILABLE"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
if version:
|
if path.exists():
|
||||||
record["version"] = version
|
|
||||||
if was_existing:
|
|
||||||
existing = json.loads(path.read_text(encoding="utf-8"))
|
existing = json.loads(path.read_text(encoding="utf-8"))
|
||||||
if (
|
if (
|
||||||
existing.get("event_id") != event_id
|
existing.get("event_id") != event_id
|
||||||
or existing.get("source_commit") != source_commit
|
or existing.get("source_commit") != source_commit
|
||||||
or str(existing.get("version") or "") != version
|
|
||||||
):
|
):
|
||||||
raise SagaConflict(
|
raise SagaConflict(
|
||||||
"release intent id is bound to different evidence"
|
"release intent id is bound to different evidence"
|
||||||
)
|
)
|
||||||
if existing.get("status") == "BUILDING":
|
return {
|
||||||
try:
|
**_sanitize(existing),
|
||||||
started_at = _parse_utc(
|
"idempotent": True,
|
||||||
str(existing.get("attempt_started_at") or "")
|
"execution_enabled": self.execution_enabled,
|
||||||
)
|
"shadow_mode": not self.execution_enabled,
|
||||||
elapsed = (
|
"side_effects_executed": False,
|
||||||
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)
|
_durable_json(path, record)
|
||||||
return self._intent_response(record, idempotent=was_existing)
|
result = dict(record)
|
||||||
|
if self.candidate_builder is not None:
|
||||||
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)
|
|
||||||
try:
|
|
||||||
candidate = self.create_release_candidate(
|
candidate = self.create_release_candidate(
|
||||||
{
|
{
|
||||||
"event_id": event_id,
|
"event_id": event_id,
|
||||||
"intent_id": intent_id,
|
"intent_id": intent_id,
|
||||||
"source_commit": source_commit,
|
"source_commit": source_commit,
|
||||||
**({"version": version} if version else {}),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except TrustedCandidateBuilderUnavailable:
|
|
||||||
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 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(
|
result.update(
|
||||||
{
|
{
|
||||||
"candidate_id": candidate["candidate_id"],
|
"candidate_id": candidate["candidate_id"],
|
||||||
"retryable": False,
|
|
||||||
"status": candidate["saga_state"],
|
"status": candidate["saga_state"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
_durable_json(path, result)
|
_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 {
|
return {
|
||||||
**_sanitize(dict(record)),
|
**_sanitize(result),
|
||||||
"idempotent": idempotent,
|
"idempotent": False,
|
||||||
"execution_enabled": self.execution_enabled,
|
"execution_enabled": self.execution_enabled,
|
||||||
"shadow_mode": not self.execution_enabled,
|
"shadow_mode": not self.execution_enabled,
|
||||||
"side_effects_executed": False,
|
"side_effects_executed": False,
|
||||||
@@ -3822,7 +3321,6 @@ class SilentReleaseActionService:
|
|||||||
self, payload: Mapping[str, Any]
|
self, payload: Mapping[str, Any]
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if self.candidate_builder is not None:
|
if self.candidate_builder is not None:
|
||||||
values = dict(payload)
|
|
||||||
forbidden_evidence = {
|
forbidden_evidence = {
|
||||||
"artifact_sha256",
|
"artifact_sha256",
|
||||||
"canary",
|
"canary",
|
||||||
@@ -3835,69 +3333,13 @@ class SilentReleaseActionService:
|
|||||||
"tests",
|
"tests",
|
||||||
"tree_oid",
|
"tree_oid",
|
||||||
}
|
}
|
||||||
if forbidden_evidence.intersection(values):
|
if forbidden_evidence.intersection(payload):
|
||||||
raise CapabilityAuthorizationError(
|
raise CapabilityAuthorizationError(
|
||||||
"candidate evidence must come from the trusted builder"
|
"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(
|
bundle = self.candidate_builder.build(dict(payload)).validate(
|
||||||
self.builder_attestation_verifier
|
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:
|
if self.trusted_policy is None:
|
||||||
raise CapabilityAuthorizationError(
|
raise CapabilityAuthorizationError(
|
||||||
"trusted release policy is not configured"
|
"trusted release policy is not configured"
|
||||||
@@ -4015,19 +3457,6 @@ class SilentReleaseActionService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def get_release_saga(self, candidate_id: str) -> dict[str, Any]:
|
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 {
|
return {
|
||||||
**self.sagas.load(candidate_id).to_dict(),
|
**self.sagas.load(candidate_id).to_dict(),
|
||||||
"execution_enabled": self.execution_enabled,
|
"execution_enabled": self.execution_enabled,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from .models import (
|
|||||||
)
|
)
|
||||||
from .policy import PolicyEngine
|
from .policy import PolicyEngine
|
||||||
from .store import TaskPlaneStore
|
from .store import TaskPlaneStore
|
||||||
|
from .scope import bind_task_scope
|
||||||
from .worker_registry import WorkerRegistry
|
from .worker_registry import WorkerRegistry
|
||||||
|
|
||||||
|
|
||||||
@@ -68,28 +69,54 @@ class TaskPlaneService:
|
|||||||
self.delegation = DelegationEngine(store)
|
self.delegation = DelegationEngine(store)
|
||||||
self.store.probe_attestation_verifier = self.policy.probe_attestation_verifier
|
self.store.probe_attestation_verifier = self.policy.probe_attestation_verifier
|
||||||
|
|
||||||
def create_task(self, body: dict[str, Any], idempotency_key: str | None = None) -> dict[str, Any]:
|
def _authorized_task(self, task_id: str, actor: str, action: str) -> TaskRecord:
|
||||||
|
task = self.store.get_task(task_id, self.policy.actor_context(actor))
|
||||||
|
self.policy.assert_allowed(action, actor, task=task)
|
||||||
|
return task
|
||||||
|
|
||||||
|
def create_task(
|
||||||
|
self,
|
||||||
|
body: dict[str, Any],
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
*,
|
||||||
|
actor_identity: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
if body.get("parent_task_id"):
|
if body.get("parent_task_id"):
|
||||||
raise ForbiddenError("child tasks must use the delegate endpoint")
|
raise ForbiddenError("child tasks must use the delegate endpoint")
|
||||||
issuer = body.get("issuer", {})
|
issuer = body.get("issuer", {})
|
||||||
actor = issuer.get("id", "anon") if isinstance(issuer, dict) else "anon"
|
declared_actor = issuer.get("id", "anon") if isinstance(issuer, dict) else "anon"
|
||||||
|
actor = actor_identity or declared_actor
|
||||||
self.policy.assert_allowed("create", actor)
|
self.policy.assert_allowed("create", actor)
|
||||||
try:
|
try:
|
||||||
plan = TaskPlan.from_dict(body)
|
plan = TaskPlan.from_dict(body)
|
||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
raise ValidationError("invalid task plan") from exc
|
raise ValidationError("invalid task plan") from exc
|
||||||
|
if actor_identity is not None:
|
||||||
|
plan = bind_task_scope(
|
||||||
|
plan, self.policy.actor_context(actor_identity), public_create=True
|
||||||
|
)
|
||||||
task = self.store.create_task_with_event(plan, idempotency_key=idempotency_key)
|
task = self.store.create_task_with_event(plan, idempotency_key=idempotency_key)
|
||||||
return task.to_dict()
|
return task.to_dict()
|
||||||
|
|
||||||
def list_tasks(self, actor: str | None = None) -> list[dict[str, Any]]:
|
def list_tasks(self, actor: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
context = None
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
self.policy.assert_allowed("read", actor)
|
self.policy.assert_allowed("read", actor)
|
||||||
return [task.to_dict(include_task=False) for task in self.store.list_tasks()]
|
context = self.policy.actor_context(actor)
|
||||||
|
return [
|
||||||
|
task.to_dict(include_task=False)
|
||||||
|
for task in self.store.list_tasks(context)
|
||||||
|
]
|
||||||
|
|
||||||
def get_task(self, task_id: str, actor: str | None = None) -> dict[str, Any]:
|
def get_task(self, task_id: str, actor: str | None = None) -> dict[str, Any]:
|
||||||
|
context = None
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
self.policy.assert_allowed("read", actor, task=self.store.get_task(task_id))
|
self.policy.assert_allowed("read", actor)
|
||||||
return self.store.get_task(task_id).to_dict()
|
context = self.policy.actor_context(actor)
|
||||||
|
task = self.store.get_task(task_id, context)
|
||||||
|
if actor is not None:
|
||||||
|
self.policy.assert_allowed("read", actor, task=task)
|
||||||
|
return task.to_dict()
|
||||||
|
|
||||||
def get_worker(self, worker_id: str, actor: str | None = None) -> dict[str, Any]:
|
def get_worker(self, worker_id: str, actor: str | None = None) -> dict[str, Any]:
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
@@ -163,9 +190,8 @@ class TaskPlaneService:
|
|||||||
if not worker_id:
|
if not worker_id:
|
||||||
raise ValidationError("worker_id required")
|
raise ValidationError("worker_id required")
|
||||||
|
|
||||||
task = self.store.get_task(task_id)
|
task = self._authorized_task(task_id, actor, "claim")
|
||||||
self.policy.assert_readonly_worker_binding(actor, str(worker_id))
|
self.policy.assert_readonly_worker_binding(actor, str(worker_id))
|
||||||
self.policy.assert_allowed("claim", actor, task=task)
|
|
||||||
lease_seconds = _bounded_int(
|
lease_seconds = _bounded_int(
|
||||||
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
||||||
)
|
)
|
||||||
@@ -184,10 +210,9 @@ class TaskPlaneService:
|
|||||||
if not claim_id:
|
if not claim_id:
|
||||||
raise ValidationError("claim_id required")
|
raise ValidationError("claim_id required")
|
||||||
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
||||||
task = self.store.get_task(task_id)
|
task = self._authorized_task(task_id, actor, "heartbeat")
|
||||||
claim = self.store.get_claim(claim_id)
|
claim = self.store.get_claim(claim_id)
|
||||||
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
||||||
self.policy.assert_allowed("heartbeat", actor, task=task)
|
|
||||||
if claim.worker_identity != actor:
|
if claim.worker_identity != actor:
|
||||||
raise ForbiddenError("claim identity mismatch")
|
raise ForbiddenError("claim identity mismatch")
|
||||||
lease_seconds = _bounded_int(
|
lease_seconds = _bounded_int(
|
||||||
@@ -206,10 +231,9 @@ class TaskPlaneService:
|
|||||||
message = body.get("message")
|
message = body.get("message")
|
||||||
if progress is None:
|
if progress is None:
|
||||||
raise ValidationError("progress required")
|
raise ValidationError("progress required")
|
||||||
task = self.store.get_task(task_id)
|
task = self._authorized_task(task_id, actor, "progress")
|
||||||
claim = self.store.get_claim(claim_id)
|
claim = self.store.get_claim(claim_id)
|
||||||
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
||||||
self.policy.assert_allowed("progress", actor, task=task)
|
|
||||||
if task.task_plan.data_classification == "credential-secret":
|
if task.task_plan.data_classification == "credential-secret":
|
||||||
if message not in {None, ""} or contains_secret_text(stage):
|
if message not in {None, ""} or contains_secret_text(stage):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
@@ -234,7 +258,7 @@ class TaskPlaneService:
|
|||||||
if not claim_id:
|
if not claim_id:
|
||||||
raise ValidationError("claim_id required")
|
raise ValidationError("claim_id required")
|
||||||
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
||||||
task = self.store.get_task(task_id)
|
task = self._authorized_task(task_id, actor, "result")
|
||||||
|
|
||||||
status = body.get("status", "COMPLETED")
|
status = body.get("status", "COMPLETED")
|
||||||
outputs = body.get("outputs", [])
|
outputs = body.get("outputs", [])
|
||||||
@@ -247,7 +271,6 @@ class TaskPlaneService:
|
|||||||
raise ValidationError("evidence items must be objects")
|
raise ValidationError("evidence items must be objects")
|
||||||
claim = self.store.get_claim(claim_id)
|
claim = self.store.get_claim(claim_id)
|
||||||
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
||||||
self.policy.assert_allowed("result", actor, task=task)
|
|
||||||
if claim.worker_identity != actor:
|
if claim.worker_identity != actor:
|
||||||
raise ForbiddenError("claim identity mismatch")
|
raise ForbiddenError("claim identity mismatch")
|
||||||
if task.task_plan.data_classification == "credential-secret":
|
if task.task_plan.data_classification == "credential-secret":
|
||||||
@@ -326,8 +349,7 @@ class TaskPlaneService:
|
|||||||
reason = body.get("reason", "failed")
|
reason = body.get("reason", "failed")
|
||||||
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
||||||
self.policy.assert_allowed("fail", actor)
|
self.policy.assert_allowed("fail", actor)
|
||||||
task = self.store.get_task(task_id)
|
task = self._authorized_task(task_id, actor, "fail")
|
||||||
self.policy.assert_allowed("fail", actor, task=task)
|
|
||||||
claim = self.store.get_claim(claim_id)
|
claim = self.store.get_claim(claim_id)
|
||||||
if claim.worker_identity != actor:
|
if claim.worker_identity != actor:
|
||||||
raise ForbiddenError("claim identity mismatch")
|
raise ForbiddenError("claim identity mismatch")
|
||||||
@@ -339,8 +361,7 @@ class TaskPlaneService:
|
|||||||
actor = body.get("actor_identity") or body.get("worker_identity") or "owner"
|
actor = body.get("actor_identity") or body.get("worker_identity") or "owner"
|
||||||
self.policy.assert_allowed("cancel", actor)
|
self.policy.assert_allowed("cancel", actor)
|
||||||
reason = body.get("reason", "cancelled")
|
reason = body.get("reason", "cancelled")
|
||||||
task = self.store.get_task(task_id)
|
task = self._authorized_task(task_id, actor, "cancel")
|
||||||
self.policy.assert_allowed("cancel", actor, task=task)
|
|
||||||
updated = self.store.cancel_task_with_event(
|
updated = self.store.cancel_task_with_event(
|
||||||
task_id, task.revision, reason=reason
|
task_id, task.revision, reason=reason
|
||||||
)
|
)
|
||||||
@@ -349,8 +370,7 @@ class TaskPlaneService:
|
|||||||
def delegate(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
def delegate(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||||
actor = body.get("actor_identity") or "owner"
|
actor = body.get("actor_identity") or "owner"
|
||||||
self.policy.assert_allowed("delegate", actor)
|
self.policy.assert_allowed("delegate", actor)
|
||||||
parent = self.store.get_task(task_id)
|
parent = self._authorized_task(task_id, actor, "delegate")
|
||||||
self.policy.assert_allowed("delegate", actor, task=parent)
|
|
||||||
if "task" in body:
|
if "task" in body:
|
||||||
try:
|
try:
|
||||||
child_plan = TaskPlan.from_dict(body["task"])
|
child_plan = TaskPlan.from_dict(body["task"])
|
||||||
@@ -364,24 +384,30 @@ class TaskPlaneService:
|
|||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
raise ValidationError("invalid child task plan") from exc
|
raise ValidationError("invalid child task plan") from exc
|
||||||
child_plan.parent_task_id = task_id
|
child_plan.parent_task_id = task_id
|
||||||
|
child_plan = bind_task_scope(
|
||||||
|
child_plan, self.policy.actor_context(actor), public_create=False
|
||||||
|
)
|
||||||
child = self.delegation.delegate(task_id, child_plan, caller=actor)
|
child = self.delegation.delegate(task_id, child_plan, caller=actor)
|
||||||
return child.to_dict()
|
return child.to_dict()
|
||||||
|
|
||||||
def get_events(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
|
def get_events(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
self.policy.assert_allowed("audit", actor, task=self.store.get_task(task_id))
|
self._authorized_task(task_id, actor, "audit")
|
||||||
|
else:
|
||||||
self.store.get_task(task_id)
|
self.store.get_task(task_id)
|
||||||
return self.store.list_events(task_id)
|
return self.store.list_events(task_id)
|
||||||
|
|
||||||
def get_evidence(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
|
def get_evidence(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
self.policy.assert_allowed("audit", actor, task=self.store.get_task(task_id))
|
self._authorized_task(task_id, actor, "audit")
|
||||||
|
else:
|
||||||
self.store.get_task(task_id)
|
self.store.get_task(task_id)
|
||||||
return self.evidence.list(task_id)
|
return self.evidence.list(task_id)
|
||||||
|
|
||||||
def get_children(self, task_id: str, actor: str | None = None) -> list[str]:
|
def get_children(self, task_id: str, actor: str | None = None) -> list[str]:
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
self.policy.assert_allowed("read", actor, task=self.store.get_task(task_id))
|
self._authorized_task(task_id, actor, "read")
|
||||||
|
else:
|
||||||
self.store.get_task(task_id)
|
self.store.get_task(task_id)
|
||||||
return self.store.list_children(task_id)
|
return self.store.list_children(task_id)
|
||||||
|
|
||||||
@@ -506,7 +532,11 @@ class TaskPlaneRequestHandler(BaseHTTPRequestHandler):
|
|||||||
):
|
):
|
||||||
raise ForbiddenError("issuer identity mismatch")
|
raise ForbiddenError("issuer identity mismatch")
|
||||||
body["issuer"] = {"type": "authenticated", "id": identity}
|
body["issuer"] = {"type": "authenticated", "id": identity}
|
||||||
return 201, self.service.create_task(body or {}, headers.get("idempotency-key"))
|
return 201, self.service.create_task(
|
||||||
|
body or {},
|
||||||
|
headers.get("idempotency-key"),
|
||||||
|
actor_identity=identity,
|
||||||
|
)
|
||||||
if body.get("actor_identity") not in {None, identity}:
|
if body.get("actor_identity") not in {None, identity}:
|
||||||
raise ForbiddenError("actor identity mismatch")
|
raise ForbiddenError("actor identity mismatch")
|
||||||
body["actor_identity"] = identity
|
body["actor_identity"] = identity
|
||||||
|
|||||||
@@ -106,3 +106,8 @@ class DurableOutboxDispatcher:
|
|||||||
receipt=str(receipt) if receipt else None,
|
receipt=str(receipt) if receipt else None,
|
||||||
error=None if receipt else f"{adapter.name} receipt unavailable",
|
error=None if receipt else f"{adapter.name} receipt unavailable",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# R4.2 P0-B compatibility adapter.
|
||||||
|
def to_handoff_envelope(event, *, target, sequence=1, idempotency_key=None, **scope):
|
||||||
|
from message_handoff_adapters import taskplane_event_to_envelope
|
||||||
|
return taskplane_event_to_envelope(event, target=target, sequence=sequence, idempotency_key=idempotency_key, **scope)
|
||||||
|
|||||||
@@ -0,0 +1,911 @@
|
|||||||
|
"""Durable multi-agent fleet scheduling for TaskPlane.
|
||||||
|
|
||||||
|
The fleet layer decides *which* eligible worker should receive *which* queued task.
|
||||||
|
It never replaces TaskPlane claim/lease ownership: callers must still perform the
|
||||||
|
authoritative TaskPlane claim CAS after a reservation is returned.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any, Iterable, Mapping, Sequence
|
||||||
|
|
||||||
|
DEFAULT_RESERVATION_SECONDS = 30
|
||||||
|
DEFAULT_CIRCUIT_THRESHOLD = 3
|
||||||
|
DEFAULT_CIRCUIT_SECONDS = 30
|
||||||
|
MAX_CIRCUIT_SECONDS = 15 * 60
|
||||||
|
MAX_SLOTS = 128
|
||||||
|
|
||||||
|
|
||||||
|
class FleetError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FleetConflict(FleetError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical(value: object) -> bytes:
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _sha(value: object) -> str:
|
||||||
|
return hashlib.sha256(_canonical(value)).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: object, default: str = "") -> str:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return str(value).strip() or default
|
||||||
|
|
||||||
|
|
||||||
|
def _int(value: object, default: int, minimum: int, maximum: int) -> int:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
if isinstance(value, bool):
|
||||||
|
raise FleetError("FLEET_INTEGER_INVALID")
|
||||||
|
try:
|
||||||
|
result = int(value)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise FleetError("FLEET_INTEGER_INVALID") from exc
|
||||||
|
if result < minimum or result > maximum:
|
||||||
|
raise FleetError("FLEET_INTEGER_OUT_OF_RANGE")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _float(value: object, default: float, minimum: float, maximum: float) -> float:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
result = float(value)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise FleetError("FLEET_NUMBER_INVALID") from exc
|
||||||
|
if not math.isfinite(result) or result < minimum or result > maximum:
|
||||||
|
raise FleetError("FLEET_NUMBER_OUT_OF_RANGE")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(value: object) -> Mapping[str, Any]:
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return value
|
||||||
|
if hasattr(value, "to_dict"):
|
||||||
|
data = value.to_dict()
|
||||||
|
if isinstance(data, Mapping):
|
||||||
|
return data
|
||||||
|
if hasattr(value, "__dict__"):
|
||||||
|
return vars(value)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _attr(value: object, name: str, default: Any = None) -> Any:
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return value.get(name, default)
|
||||||
|
return getattr(value, name, default)
|
||||||
|
|
||||||
|
|
||||||
|
def _task_plan(task: object) -> object:
|
||||||
|
return _attr(task, "task_plan", _attr(task, "plan", task))
|
||||||
|
|
||||||
|
|
||||||
|
def _task_id(task: object) -> str:
|
||||||
|
return _text(_attr(task, "task_id", _attr(_task_plan(task), "task_id", "")))
|
||||||
|
|
||||||
|
|
||||||
|
def _task_status(task: object) -> str:
|
||||||
|
return _text(_attr(task, "status", "")).upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _task_revision(task: object) -> int:
|
||||||
|
return _int(_attr(task, "revision", 0), 0, 0, 2**31 - 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _task_created_at(task: object) -> float:
|
||||||
|
value = _attr(task, "created_at", _attr(task, "updated_at", 0.0))
|
||||||
|
try:
|
||||||
|
return float(value or 0.0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _task_required_capabilities(task: object) -> frozenset[str]:
|
||||||
|
plan = _task_plan(task)
|
||||||
|
value = _attr(plan, "required_capabilities", ())
|
||||||
|
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||||
|
return frozenset()
|
||||||
|
return frozenset(_text(item) for item in value if _text(item))
|
||||||
|
|
||||||
|
|
||||||
|
def _task_metadata(task: object) -> Mapping[str, Any]:
|
||||||
|
plan = _task_plan(task)
|
||||||
|
value = _attr(plan, "metadata", {})
|
||||||
|
return value if isinstance(value, Mapping) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _task_scope_key(task: object) -> str:
|
||||||
|
plan = _task_plan(task)
|
||||||
|
scope_type = _text(_attr(plan, "scope_type", "legacy"), "legacy")
|
||||||
|
tenant = _text(_attr(plan, "tenant_id", ""))
|
||||||
|
project = _text(_attr(plan, "project_id", ""))
|
||||||
|
user = _text(_attr(plan, "user_id", ""))
|
||||||
|
if scope_type == "tenant":
|
||||||
|
return "tenant:" + tenant + "|project:" + (project or "*")
|
||||||
|
if scope_type == "system":
|
||||||
|
return "system"
|
||||||
|
return "legacy:" + (user or "*")
|
||||||
|
|
||||||
|
|
||||||
|
def _worker_id(worker: object) -> str:
|
||||||
|
return _text(_attr(worker, "worker_id", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _worker_capabilities(worker: object) -> frozenset[str]:
|
||||||
|
value = _attr(worker, "capabilities", ())
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
value = value.keys()
|
||||||
|
if not isinstance(value, Iterable) or isinstance(value, (str, bytes)):
|
||||||
|
return frozenset()
|
||||||
|
return frozenset(_text(item) for item in value if _text(item))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FleetWorkerPolicy:
|
||||||
|
worker_id: str
|
||||||
|
max_slots: int = 1
|
||||||
|
failure_domain: str = "default"
|
||||||
|
weight: float = 1.0
|
||||||
|
labels: Mapping[str, str] | None = None
|
||||||
|
revision: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FleetDispatchDecision:
|
||||||
|
reservation_id: str
|
||||||
|
task_id: str
|
||||||
|
worker_id: str
|
||||||
|
task_revision: int
|
||||||
|
scope_key: str
|
||||||
|
failure_domain: str
|
||||||
|
reserved_until: float
|
||||||
|
score: tuple[float, float, float, str]
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"reservation_id": self.reservation_id,
|
||||||
|
"task_id": self.task_id,
|
||||||
|
"worker_id": self.worker_id,
|
||||||
|
"task_revision": self.task_revision,
|
||||||
|
"scope_key": self.scope_key,
|
||||||
|
"failure_domain": self.failure_domain,
|
||||||
|
"reserved_until": self.reserved_until,
|
||||||
|
"score": list(self.score),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FleetStateStore:
|
||||||
|
"""Small durable fleet journal colocated in the existing TaskPlane SQLite database."""
|
||||||
|
|
||||||
|
def __init__(self, connection: sqlite3.Connection, lock: threading.RLock | None = None) -> None:
|
||||||
|
self.conn = connection
|
||||||
|
self.lock = lock or threading.RLock()
|
||||||
|
self._savepoint_counter = 0
|
||||||
|
self._ensure_schema()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _transaction(self):
|
||||||
|
with self.lock:
|
||||||
|
self._savepoint_counter += 1
|
||||||
|
name = f"fleet_{threading.get_ident()}_{self._savepoint_counter}"
|
||||||
|
self.conn.execute(f"SAVEPOINT {name}")
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
except Exception:
|
||||||
|
self.conn.execute(f"ROLLBACK TO {name}")
|
||||||
|
self.conn.execute(f"RELEASE {name}")
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
self.conn.execute(f"RELEASE {name}")
|
||||||
|
|
||||||
|
def _ensure_schema(self) -> None:
|
||||||
|
with self.lock:
|
||||||
|
self.conn.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS fleet_worker_capacity (
|
||||||
|
worker_id TEXT PRIMARY KEY,
|
||||||
|
max_slots INTEGER NOT NULL,
|
||||||
|
failure_domain TEXT NOT NULL,
|
||||||
|
weight REAL NOT NULL,
|
||||||
|
labels_json TEXT NOT NULL,
|
||||||
|
revision INTEGER NOT NULL,
|
||||||
|
updated_at REAL NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS fleet_scope_state (
|
||||||
|
scope_key TEXT PRIMARY KEY,
|
||||||
|
virtual_finish REAL NOT NULL,
|
||||||
|
dispatch_count INTEGER NOT NULL,
|
||||||
|
last_dispatched_at REAL,
|
||||||
|
revision INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS fleet_dispatches (
|
||||||
|
reservation_id TEXT PRIMARY KEY,
|
||||||
|
task_id TEXT NOT NULL UNIQUE,
|
||||||
|
worker_id TEXT NOT NULL,
|
||||||
|
task_revision INTEGER NOT NULL,
|
||||||
|
scope_key TEXT NOT NULL,
|
||||||
|
failure_domain TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
reserved_until REAL NOT NULL,
|
||||||
|
claim_id TEXT,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
updated_at REAL NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_fleet_dispatch_worker_state
|
||||||
|
ON fleet_dispatches(worker_id, state, reserved_until);
|
||||||
|
CREATE TABLE IF NOT EXISTS fleet_worker_failures (
|
||||||
|
worker_id TEXT PRIMARY KEY,
|
||||||
|
consecutive_failures INTEGER NOT NULL,
|
||||||
|
circuit_open_until REAL,
|
||||||
|
last_failure_class TEXT,
|
||||||
|
revision INTEGER NOT NULL,
|
||||||
|
updated_at REAL NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS fleet_task_worker_failures (
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
worker_id TEXT NOT NULL,
|
||||||
|
failure_count INTEGER NOT NULL,
|
||||||
|
blocked_until REAL,
|
||||||
|
last_failure_class TEXT,
|
||||||
|
revision INTEGER NOT NULL,
|
||||||
|
updated_at REAL NOT NULL,
|
||||||
|
PRIMARY KEY(task_id, worker_id)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def configure_worker(
|
||||||
|
self,
|
||||||
|
worker_id: str,
|
||||||
|
*,
|
||||||
|
max_slots: int = 1,
|
||||||
|
failure_domain: str = "default",
|
||||||
|
weight: float = 1.0,
|
||||||
|
labels: Mapping[str, str] | None = None,
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
now: float | None = None,
|
||||||
|
) -> FleetWorkerPolicy:
|
||||||
|
worker_id = _text(worker_id)
|
||||||
|
if not worker_id:
|
||||||
|
raise FleetError("FLEET_WORKER_ID_REQUIRED")
|
||||||
|
max_slots = _int(max_slots, 1, 1, MAX_SLOTS)
|
||||||
|
weight = _float(weight, 1.0, 0.05, 100.0)
|
||||||
|
failure_domain = _text(failure_domain, "default")
|
||||||
|
normalized_labels = {
|
||||||
|
_text(k): _text(v)
|
||||||
|
for k, v in dict(labels or {}).items()
|
||||||
|
if _text(k) and _text(v)
|
||||||
|
}
|
||||||
|
now = float(time.time() if now is None else now)
|
||||||
|
with self._transaction():
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT revision FROM fleet_worker_capacity WHERE worker_id=?",
|
||||||
|
(worker_id,),
|
||||||
|
).fetchone()
|
||||||
|
current = int(row[0]) if row else 0
|
||||||
|
if expected_revision is not None and current != int(expected_revision):
|
||||||
|
raise FleetConflict("FLEET_WORKER_POLICY_CAS_MISMATCH")
|
||||||
|
revision = current + 1
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO fleet_worker_capacity(
|
||||||
|
worker_id,max_slots,failure_domain,weight,labels_json,revision,updated_at
|
||||||
|
) VALUES (?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(worker_id) DO UPDATE SET
|
||||||
|
max_slots=excluded.max_slots,
|
||||||
|
failure_domain=excluded.failure_domain,
|
||||||
|
weight=excluded.weight,
|
||||||
|
labels_json=excluded.labels_json,
|
||||||
|
revision=excluded.revision,
|
||||||
|
updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
worker_id,
|
||||||
|
max_slots,
|
||||||
|
failure_domain,
|
||||||
|
weight,
|
||||||
|
json.dumps(normalized_labels, sort_keys=True, separators=(",", ":")),
|
||||||
|
revision,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return FleetWorkerPolicy(
|
||||||
|
worker_id, max_slots, failure_domain, weight, normalized_labels, revision
|
||||||
|
)
|
||||||
|
|
||||||
|
def worker_policy(self, worker_id: str) -> FleetWorkerPolicy:
|
||||||
|
worker_id = _text(worker_id)
|
||||||
|
with self.lock:
|
||||||
|
row = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT worker_id,max_slots,failure_domain,weight,labels_json,revision
|
||||||
|
FROM fleet_worker_capacity WHERE worker_id=?
|
||||||
|
""",
|
||||||
|
(worker_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return FleetWorkerPolicy(worker_id=worker_id)
|
||||||
|
return FleetWorkerPolicy(
|
||||||
|
worker_id=str(row[0]),
|
||||||
|
max_slots=int(row[1]),
|
||||||
|
failure_domain=str(row[2]),
|
||||||
|
weight=float(row[3]),
|
||||||
|
labels=json.loads(row[4] or "{}"),
|
||||||
|
revision=int(row[5]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _cleanup_expired(self, now: float) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE fleet_dispatches
|
||||||
|
SET state='EXPIRED', updated_at=?
|
||||||
|
WHERE state='RESERVED' AND reserved_until<=?
|
||||||
|
""",
|
||||||
|
(now, now),
|
||||||
|
)
|
||||||
|
|
||||||
|
def reserved_slots(self, worker_id: str, now: float) -> int:
|
||||||
|
with self.lock:
|
||||||
|
self._cleanup_expired(now)
|
||||||
|
row = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM fleet_dispatches
|
||||||
|
WHERE worker_id=? AND state='RESERVED' AND reserved_until>?
|
||||||
|
""",
|
||||||
|
(worker_id, now),
|
||||||
|
).fetchone()
|
||||||
|
return int(row[0] if row else 0)
|
||||||
|
|
||||||
|
def scope_virtual_finish(self, scope_key: str) -> float:
|
||||||
|
with self.lock:
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT virtual_finish FROM fleet_scope_state WHERE scope_key=?",
|
||||||
|
(scope_key,),
|
||||||
|
).fetchone()
|
||||||
|
return float(row[0]) if row else 0.0
|
||||||
|
|
||||||
|
def reserve(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
task_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
task_revision: int,
|
||||||
|
scope_key: str,
|
||||||
|
failure_domain: str,
|
||||||
|
score: tuple[float, float, float, str],
|
||||||
|
reservation_seconds: int,
|
||||||
|
now: float,
|
||||||
|
scope_weight: float,
|
||||||
|
) -> FleetDispatchDecision:
|
||||||
|
reservation_seconds = _int(
|
||||||
|
reservation_seconds, DEFAULT_RESERVATION_SECONDS, 1, 300
|
||||||
|
)
|
||||||
|
reservation_id = "fleet-" + _sha(
|
||||||
|
{
|
||||||
|
"task_id": task_id,
|
||||||
|
"worker_id": worker_id,
|
||||||
|
"task_revision": task_revision,
|
||||||
|
"scope_key": scope_key,
|
||||||
|
}
|
||||||
|
)[:32]
|
||||||
|
reserved_until = now + reservation_seconds
|
||||||
|
with self._transaction():
|
||||||
|
self._cleanup_expired(now)
|
||||||
|
existing = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT reservation_id,worker_id,task_revision,scope_key,failure_domain,
|
||||||
|
reserved_until,state
|
||||||
|
FROM fleet_dispatches WHERE task_id=?
|
||||||
|
""",
|
||||||
|
(task_id,),
|
||||||
|
).fetchone()
|
||||||
|
# Capacity is rechecked inside the same durable reservation transaction.
|
||||||
|
# This closes the cross-thread/process race between eligibility scoring and reserve.
|
||||||
|
policy_row = self.conn.execute(
|
||||||
|
"SELECT max_slots FROM fleet_worker_capacity WHERE worker_id=?",
|
||||||
|
(worker_id,),
|
||||||
|
).fetchone()
|
||||||
|
max_slots = int(policy_row[0]) if policy_row else 1
|
||||||
|
if existing and str(existing[6]) in {"RESERVED", "CLAIMED"} and float(existing[5]) > now:
|
||||||
|
if str(existing[1]) != worker_id or int(existing[2]) != task_revision:
|
||||||
|
raise FleetConflict("FLEET_TASK_ALREADY_RESERVED")
|
||||||
|
return FleetDispatchDecision(
|
||||||
|
str(existing[0]), task_id, worker_id, task_revision, str(existing[3]),
|
||||||
|
str(existing[4]), float(existing[5]), score
|
||||||
|
)
|
||||||
|
|
||||||
|
claim_columns = {
|
||||||
|
str(row[1])
|
||||||
|
for row in self.conn.execute("PRAGMA table_info(claims)").fetchall()
|
||||||
|
}
|
||||||
|
active_claims = 0
|
||||||
|
if {"worker_id", "status"}.issubset(claim_columns):
|
||||||
|
sql = "SELECT COUNT(*) FROM claims WHERE worker_id=? AND status='ACTIVE'"
|
||||||
|
params: list[Any] = [worker_id]
|
||||||
|
if "lease_expires_at" in claim_columns:
|
||||||
|
sql += " AND lease_expires_at>?"
|
||||||
|
params.append(now)
|
||||||
|
row = self.conn.execute(sql, tuple(params)).fetchone()
|
||||||
|
active_claims = int(row[0] if row else 0)
|
||||||
|
row = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM fleet_dispatches
|
||||||
|
WHERE worker_id=? AND task_id<>?
|
||||||
|
AND state='RESERVED' AND reserved_until>?
|
||||||
|
""",
|
||||||
|
(worker_id, task_id, now),
|
||||||
|
).fetchone()
|
||||||
|
active_reservations = int(row[0] if row else 0)
|
||||||
|
if active_claims + active_reservations >= max_slots:
|
||||||
|
raise FleetConflict("FLEET_WORKER_CAPACITY_EXHAUSTED")
|
||||||
|
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO fleet_dispatches(
|
||||||
|
reservation_id,task_id,worker_id,task_revision,scope_key,failure_domain,
|
||||||
|
state,reserved_until,claim_id,created_at,updated_at
|
||||||
|
) VALUES (?,?,?,?,?,?,'RESERVED',?,NULL,?,?)
|
||||||
|
ON CONFLICT(task_id) DO UPDATE SET
|
||||||
|
reservation_id=excluded.reservation_id,
|
||||||
|
worker_id=excluded.worker_id,
|
||||||
|
task_revision=excluded.task_revision,
|
||||||
|
scope_key=excluded.scope_key,
|
||||||
|
failure_domain=excluded.failure_domain,
|
||||||
|
state='RESERVED',
|
||||||
|
reserved_until=excluded.reserved_until,
|
||||||
|
claim_id=NULL,
|
||||||
|
updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
reservation_id, task_id, worker_id, task_revision, scope_key,
|
||||||
|
failure_domain, reserved_until, now, now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT virtual_finish,dispatch_count,revision FROM fleet_scope_state WHERE scope_key=?",
|
||||||
|
(scope_key,),
|
||||||
|
).fetchone()
|
||||||
|
current_finish = float(row[0]) if row else 0.0
|
||||||
|
dispatch_count = int(row[1]) if row else 0
|
||||||
|
revision = int(row[2]) if row else 0
|
||||||
|
next_finish = max(current_finish, 0.0) + 1.0 / max(scope_weight, 0.05)
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO fleet_scope_state(
|
||||||
|
scope_key,virtual_finish,dispatch_count,last_dispatched_at,revision
|
||||||
|
) VALUES (?,?,?,?,?)
|
||||||
|
ON CONFLICT(scope_key) DO UPDATE SET
|
||||||
|
virtual_finish=excluded.virtual_finish,
|
||||||
|
dispatch_count=excluded.dispatch_count,
|
||||||
|
last_dispatched_at=excluded.last_dispatched_at,
|
||||||
|
revision=excluded.revision
|
||||||
|
""",
|
||||||
|
(scope_key, next_finish, dispatch_count + 1, now, revision + 1),
|
||||||
|
)
|
||||||
|
return FleetDispatchDecision(
|
||||||
|
reservation_id, task_id, worker_id, task_revision, scope_key,
|
||||||
|
failure_domain, reserved_until, score
|
||||||
|
)
|
||||||
|
|
||||||
|
def bind_claim(self, reservation_id: str, claim_id: str, *, now: float | None = None) -> None:
|
||||||
|
now = float(time.time() if now is None else now)
|
||||||
|
with self._transaction():
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT state FROM fleet_dispatches WHERE reservation_id=?",
|
||||||
|
(reservation_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise FleetError("FLEET_RESERVATION_NOT_FOUND")
|
||||||
|
if str(row[0]) not in {"RESERVED", "CLAIMED"}:
|
||||||
|
raise FleetConflict("FLEET_RESERVATION_NOT_ACTIVE")
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE fleet_dispatches
|
||||||
|
SET state='CLAIMED', claim_id=?, updated_at=?
|
||||||
|
WHERE reservation_id=?
|
||||||
|
""",
|
||||||
|
(claim_id, now, reservation_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def release(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
*,
|
||||||
|
state: str = "RELEASED",
|
||||||
|
now: float | None = None,
|
||||||
|
) -> None:
|
||||||
|
if state not in {"RELEASED", "COMPLETED", "FAILED", "EXPIRED"}:
|
||||||
|
raise FleetError("FLEET_RESERVATION_STATE_INVALID")
|
||||||
|
now = float(time.time() if now is None else now)
|
||||||
|
with self._transaction():
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE fleet_dispatches SET state=?,updated_at=? WHERE task_id=?",
|
||||||
|
(state, now, task_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def worker_circuit_open(self, worker_id: str, now: float) -> bool:
|
||||||
|
with self.lock:
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT circuit_open_until FROM fleet_worker_failures WHERE worker_id=?",
|
||||||
|
(worker_id,),
|
||||||
|
).fetchone()
|
||||||
|
return bool(row and row[0] is not None and float(row[0]) > now)
|
||||||
|
|
||||||
|
def task_worker_blocked(self, task_id: str, worker_id: str, now: float) -> bool:
|
||||||
|
with self.lock:
|
||||||
|
row = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT blocked_until FROM fleet_task_worker_failures
|
||||||
|
WHERE task_id=? AND worker_id=?
|
||||||
|
""",
|
||||||
|
(task_id, worker_id),
|
||||||
|
).fetchone()
|
||||||
|
return bool(row and row[0] is not None and float(row[0]) > now)
|
||||||
|
|
||||||
|
def record_worker_failure(
|
||||||
|
self,
|
||||||
|
worker_id: str,
|
||||||
|
failure_class: str,
|
||||||
|
*,
|
||||||
|
threshold: int = DEFAULT_CIRCUIT_THRESHOLD,
|
||||||
|
base_circuit_seconds: int = DEFAULT_CIRCUIT_SECONDS,
|
||||||
|
now: float | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
now = float(time.time() if now is None else now)
|
||||||
|
threshold = _int(threshold, DEFAULT_CIRCUIT_THRESHOLD, 1, 20)
|
||||||
|
base_circuit_seconds = _int(base_circuit_seconds, DEFAULT_CIRCUIT_SECONDS, 1, 3600)
|
||||||
|
with self._transaction():
|
||||||
|
row = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT consecutive_failures,revision FROM fleet_worker_failures
|
||||||
|
WHERE worker_id=?
|
||||||
|
""",
|
||||||
|
(worker_id,),
|
||||||
|
).fetchone()
|
||||||
|
failures = (int(row[0]) if row else 0) + 1
|
||||||
|
revision = (int(row[1]) if row else 0) + 1
|
||||||
|
open_until = None
|
||||||
|
if failures >= threshold:
|
||||||
|
exponent = min(failures - threshold, 8)
|
||||||
|
open_until = now + min(
|
||||||
|
MAX_CIRCUIT_SECONDS, base_circuit_seconds * (2**exponent)
|
||||||
|
)
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO fleet_worker_failures(
|
||||||
|
worker_id,consecutive_failures,circuit_open_until,last_failure_class,
|
||||||
|
revision,updated_at
|
||||||
|
) VALUES (?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(worker_id) DO UPDATE SET
|
||||||
|
consecutive_failures=excluded.consecutive_failures,
|
||||||
|
circuit_open_until=excluded.circuit_open_until,
|
||||||
|
last_failure_class=excluded.last_failure_class,
|
||||||
|
revision=excluded.revision,
|
||||||
|
updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
(worker_id, failures, open_until, _text(failure_class, "unknown"), revision, now),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"worker_id": worker_id,
|
||||||
|
"consecutive_failures": failures,
|
||||||
|
"circuit_open_until": open_until,
|
||||||
|
"revision": revision,
|
||||||
|
}
|
||||||
|
|
||||||
|
def record_worker_success(self, worker_id: str, *, now: float | None = None) -> None:
|
||||||
|
now = float(time.time() if now is None else now)
|
||||||
|
with self._transaction():
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO fleet_worker_failures(
|
||||||
|
worker_id,consecutive_failures,circuit_open_until,last_failure_class,
|
||||||
|
revision,updated_at
|
||||||
|
) VALUES (?,0,NULL,NULL,1,?)
|
||||||
|
ON CONFLICT(worker_id) DO UPDATE SET
|
||||||
|
consecutive_failures=0,
|
||||||
|
circuit_open_until=NULL,
|
||||||
|
last_failure_class=NULL,
|
||||||
|
revision=revision+1,
|
||||||
|
updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
(worker_id, now),
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_task_worker_failure(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
failure_class: str,
|
||||||
|
*,
|
||||||
|
quarantine_seconds: int = 60,
|
||||||
|
now: float | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
now = float(time.time() if now is None else now)
|
||||||
|
quarantine_seconds = _int(quarantine_seconds, 60, 1, 3600)
|
||||||
|
with self._transaction():
|
||||||
|
row = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT failure_count,revision FROM fleet_task_worker_failures
|
||||||
|
WHERE task_id=? AND worker_id=?
|
||||||
|
""",
|
||||||
|
(task_id, worker_id),
|
||||||
|
).fetchone()
|
||||||
|
count = (int(row[0]) if row else 0) + 1
|
||||||
|
revision = (int(row[1]) if row else 0) + 1
|
||||||
|
blocked_until = now + quarantine_seconds
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO fleet_task_worker_failures(
|
||||||
|
task_id,worker_id,failure_count,blocked_until,last_failure_class,
|
||||||
|
revision,updated_at
|
||||||
|
) VALUES (?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(task_id,worker_id) DO UPDATE SET
|
||||||
|
failure_count=excluded.failure_count,
|
||||||
|
blocked_until=excluded.blocked_until,
|
||||||
|
last_failure_class=excluded.last_failure_class,
|
||||||
|
revision=excluded.revision,
|
||||||
|
updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
task_id, worker_id, count, blocked_until,
|
||||||
|
_text(failure_class, "unknown"), revision, now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"task_id": task_id,
|
||||||
|
"worker_id": worker_id,
|
||||||
|
"failure_count": count,
|
||||||
|
"blocked_until": blocked_until,
|
||||||
|
"revision": revision,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FleetScheduler:
|
||||||
|
"""Deterministic capacity/fairness scheduler over existing TaskPlane records."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: Any,
|
||||||
|
workers: Any,
|
||||||
|
*,
|
||||||
|
now_fn: Any = time.time,
|
||||||
|
reservation_seconds: int = DEFAULT_RESERVATION_SECONDS,
|
||||||
|
) -> None:
|
||||||
|
self.store = store
|
||||||
|
self.workers = workers
|
||||||
|
self.now_fn = now_fn
|
||||||
|
self.reservation_seconds = _int(
|
||||||
|
reservation_seconds, DEFAULT_RESERVATION_SECONDS, 1, 300
|
||||||
|
)
|
||||||
|
connection = getattr(store, "conn", None)
|
||||||
|
if not isinstance(connection, sqlite3.Connection):
|
||||||
|
raise FleetError("FLEET_TASKPLANE_SQLITE_CONNECTION_REQUIRED")
|
||||||
|
lock = getattr(store, "_lock", None)
|
||||||
|
self.state = FleetStateStore(connection, lock)
|
||||||
|
|
||||||
|
def configure_worker(self, worker_id: str, **kwargs: Any) -> FleetWorkerPolicy:
|
||||||
|
return self.state.configure_worker(worker_id, **kwargs)
|
||||||
|
|
||||||
|
def _online(self, worker_id: str, now: float) -> bool:
|
||||||
|
method = getattr(self.workers, "is_online", None)
|
||||||
|
if not callable(method):
|
||||||
|
raise FleetError("FLEET_WORKER_REGISTRY_ONLINE_CHECK_REQUIRED")
|
||||||
|
try:
|
||||||
|
return bool(method(worker_id, now=now))
|
||||||
|
except TypeError:
|
||||||
|
return bool(method(worker_id))
|
||||||
|
|
||||||
|
def _worker_record(self, worker_id: str) -> object:
|
||||||
|
getter = getattr(self.store, "get_worker", None)
|
||||||
|
if not callable(getter):
|
||||||
|
raise FleetError("FLEET_WORKER_LOOKUP_REQUIRED")
|
||||||
|
return getter(worker_id)
|
||||||
|
|
||||||
|
def _active_claims(self, worker_id: str, now: float) -> int:
|
||||||
|
# The TaskPlane store owns one SQLite connection in the current runtime.
|
||||||
|
# Fleet reads must share its lock just like fleet writes do, otherwise two
|
||||||
|
# concurrent pullers can issue overlapping operations on the same handle.
|
||||||
|
with self.state.lock:
|
||||||
|
columns = {
|
||||||
|
str(row[1])
|
||||||
|
for row in self.store.conn.execute("PRAGMA table_info(claims)").fetchall()
|
||||||
|
}
|
||||||
|
required = {"worker_id", "status"}
|
||||||
|
if not required.issubset(columns):
|
||||||
|
raise FleetError("FLEET_CLAIMS_SCHEMA_UNSUPPORTED")
|
||||||
|
sql = "SELECT COUNT(*) FROM claims WHERE worker_id=? AND status='ACTIVE'"
|
||||||
|
params: list[Any] = [worker_id]
|
||||||
|
if "lease_expires_at" in columns:
|
||||||
|
sql += " AND lease_expires_at>?"
|
||||||
|
params.append(now)
|
||||||
|
row = self.store.conn.execute(sql, tuple(params)).fetchone()
|
||||||
|
return int(row[0] if row else 0)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _labels_match(policy: FleetWorkerPolicy, task: object) -> bool:
|
||||||
|
metadata = _task_metadata(task)
|
||||||
|
affinity = metadata.get("fleet_affinity_labels") or {}
|
||||||
|
if affinity and not isinstance(affinity, Mapping):
|
||||||
|
return False
|
||||||
|
labels = dict(policy.labels or {})
|
||||||
|
for key, value in dict(affinity).items():
|
||||||
|
if labels.get(_text(key)) != _text(value):
|
||||||
|
return False
|
||||||
|
avoid = metadata.get("fleet_avoid_failure_domains") or []
|
||||||
|
if isinstance(avoid, Sequence) and not isinstance(avoid, (str, bytes)):
|
||||||
|
if policy.failure_domain in {_text(item) for item in avoid}:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _eligible_worker(self, worker_id: str, task: object, now: float) -> tuple[bool, int, FleetWorkerPolicy]:
|
||||||
|
if not self._online(worker_id, now):
|
||||||
|
return False, 0, self.state.worker_policy(worker_id)
|
||||||
|
worker = self._worker_record(worker_id)
|
||||||
|
policy = self.state.worker_policy(worker_id)
|
||||||
|
if self.state.worker_circuit_open(worker_id, now):
|
||||||
|
return False, 0, policy
|
||||||
|
if self.state.task_worker_blocked(_task_id(task), worker_id, now):
|
||||||
|
return False, 0, policy
|
||||||
|
if not self._labels_match(policy, task):
|
||||||
|
return False, 0, policy
|
||||||
|
required = _task_required_capabilities(task)
|
||||||
|
capabilities = _worker_capabilities(worker)
|
||||||
|
if required and not required.issubset(capabilities):
|
||||||
|
return False, 0, policy
|
||||||
|
active = self._active_claims(worker_id, now)
|
||||||
|
reserved = self.state.reserved_slots(worker_id, now)
|
||||||
|
load = active + reserved
|
||||||
|
if load >= policy.max_slots:
|
||||||
|
return False, load, policy
|
||||||
|
return True, load, policy
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _task_priority(task: object, now: float) -> float:
|
||||||
|
metadata = _task_metadata(task)
|
||||||
|
base = _int(metadata.get("fleet_priority"), 0, -100, 100)
|
||||||
|
created = _task_created_at(task)
|
||||||
|
waited = max(0.0, now - created) if created > 0 else 0.0
|
||||||
|
aging = min(100.0, waited / 60.0)
|
||||||
|
return float(base) + aging
|
||||||
|
|
||||||
|
def _task_score(self, task: object, now: float) -> tuple[float, float, float, str]:
|
||||||
|
scope = _task_scope_key(task)
|
||||||
|
virtual_finish = self.state.scope_virtual_finish(scope)
|
||||||
|
priority = self._task_priority(task, now)
|
||||||
|
return (virtual_finish, -priority, _task_created_at(task), _task_id(task))
|
||||||
|
|
||||||
|
def _tasks(self, actor_context: Any = None) -> list[object]:
|
||||||
|
lister = getattr(self.store, "list_tasks", None)
|
||||||
|
if not callable(lister):
|
||||||
|
raise FleetError("FLEET_TASK_LIST_REQUIRED")
|
||||||
|
try:
|
||||||
|
tasks = lister(actor_context)
|
||||||
|
except TypeError:
|
||||||
|
tasks = lister()
|
||||||
|
return [
|
||||||
|
task for task in tasks
|
||||||
|
if _task_status(task) in {"CREATED", "QUEUED"}
|
||||||
|
and _task_id(task)
|
||||||
|
]
|
||||||
|
|
||||||
|
def rank_for_worker(
|
||||||
|
self,
|
||||||
|
worker_id: str,
|
||||||
|
*,
|
||||||
|
actor_context: Any = None,
|
||||||
|
now: float | None = None,
|
||||||
|
) -> list[object]:
|
||||||
|
"""Return fleet-ranked tasks without reserving or claiming anything."""
|
||||||
|
now = float(self.now_fn() if now is None else now)
|
||||||
|
worker_id = _text(worker_id)
|
||||||
|
if not worker_id:
|
||||||
|
return []
|
||||||
|
ranked: list[object] = []
|
||||||
|
for task in self._tasks(actor_context):
|
||||||
|
eligible, _, _ = self._eligible_worker(worker_id, task, now)
|
||||||
|
if eligible:
|
||||||
|
ranked.append(task)
|
||||||
|
return sorted(ranked, key=lambda task: self._task_score(task, now))
|
||||||
|
|
||||||
|
def reserve_for_worker(
|
||||||
|
self,
|
||||||
|
worker_id: str,
|
||||||
|
task: object,
|
||||||
|
*,
|
||||||
|
now: float | None = None,
|
||||||
|
) -> FleetDispatchDecision:
|
||||||
|
"""Reserve exactly one already-ranked task, rechecking eligibility and capacity."""
|
||||||
|
now = float(self.now_fn() if now is None else now)
|
||||||
|
worker_id = _text(worker_id)
|
||||||
|
eligible, _, policy = self._eligible_worker(worker_id, task, now)
|
||||||
|
if not eligible:
|
||||||
|
raise FleetConflict("FLEET_WORKER_NOT_ELIGIBLE")
|
||||||
|
metadata = _task_metadata(task)
|
||||||
|
scope_weight = _float(metadata.get("fleet_scope_weight"), 1.0, 0.05, 100.0)
|
||||||
|
return self.state.reserve(
|
||||||
|
task_id=_task_id(task),
|
||||||
|
worker_id=worker_id,
|
||||||
|
task_revision=_task_revision(task),
|
||||||
|
scope_key=_task_scope_key(task),
|
||||||
|
failure_domain=policy.failure_domain,
|
||||||
|
score=self._task_score(task, now),
|
||||||
|
reservation_seconds=self.reservation_seconds,
|
||||||
|
now=now,
|
||||||
|
scope_weight=scope_weight,
|
||||||
|
)
|
||||||
|
|
||||||
|
def select(
|
||||||
|
self,
|
||||||
|
worker_ids: Sequence[str],
|
||||||
|
*,
|
||||||
|
actor_context: Any = None,
|
||||||
|
limit: int = 1,
|
||||||
|
now: float | None = None,
|
||||||
|
) -> list[FleetDispatchDecision]:
|
||||||
|
now = float(self.now_fn() if now is None else now)
|
||||||
|
limit = _int(limit, 1, 1, 100)
|
||||||
|
worker_ids = tuple(dict.fromkeys(_text(item) for item in worker_ids if _text(item)))
|
||||||
|
tasks = sorted(self._tasks(actor_context), key=lambda task: self._task_score(task, now))
|
||||||
|
decisions: list[FleetDispatchDecision] = []
|
||||||
|
local_load: dict[str, int] = {}
|
||||||
|
for task in tasks:
|
||||||
|
if len(decisions) >= limit:
|
||||||
|
break
|
||||||
|
candidates: list[tuple[int, float, str, FleetWorkerPolicy]] = []
|
||||||
|
for worker_id in worker_ids:
|
||||||
|
eligible, load, policy = self._eligible_worker(worker_id, task, now)
|
||||||
|
if not eligible:
|
||||||
|
continue
|
||||||
|
effective_load = local_load.get(worker_id, load)
|
||||||
|
if effective_load >= policy.max_slots:
|
||||||
|
continue
|
||||||
|
candidates.append(
|
||||||
|
(
|
||||||
|
effective_load,
|
||||||
|
-policy.weight,
|
||||||
|
worker_id,
|
||||||
|
policy,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not candidates:
|
||||||
|
continue
|
||||||
|
candidates.sort(key=lambda item: (item[0], item[1], item[2]))
|
||||||
|
_, _, worker_id, policy = candidates[0]
|
||||||
|
score = self._task_score(task, now)
|
||||||
|
metadata = _task_metadata(task)
|
||||||
|
scope_weight = _float(metadata.get("fleet_scope_weight"), 1.0, 0.05, 100.0)
|
||||||
|
try:
|
||||||
|
decision = self.state.reserve(
|
||||||
|
task_id=_task_id(task),
|
||||||
|
worker_id=worker_id,
|
||||||
|
task_revision=_task_revision(task),
|
||||||
|
scope_key=_task_scope_key(task),
|
||||||
|
failure_domain=policy.failure_domain,
|
||||||
|
score=score,
|
||||||
|
reservation_seconds=self.reservation_seconds,
|
||||||
|
now=now,
|
||||||
|
scope_weight=scope_weight,
|
||||||
|
)
|
||||||
|
except FleetConflict:
|
||||||
|
continue
|
||||||
|
local_load[worker_id] = local_load.get(
|
||||||
|
worker_id,
|
||||||
|
self._active_claims(worker_id, now)
|
||||||
|
+ self.state.reserved_slots(worker_id, now)
|
||||||
|
- 1,
|
||||||
|
) + 1
|
||||||
|
decisions.append(decision)
|
||||||
|
return decisions
|
||||||
@@ -13,6 +13,8 @@ from dataclasses import dataclass, field
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from .scope import normalize_task_scope
|
||||||
|
|
||||||
|
|
||||||
SENSITIVE_KEYS = {
|
SENSITIVE_KEYS = {
|
||||||
"secret",
|
"secret",
|
||||||
@@ -66,6 +68,12 @@ TASK_PLAN_KEYS = {
|
|||||||
"allowed_input_paths",
|
"allowed_input_paths",
|
||||||
"allowed_output_paths",
|
"allowed_output_paths",
|
||||||
"output_policy",
|
"output_policy",
|
||||||
|
"scope_type",
|
||||||
|
"tenant_id",
|
||||||
|
"user_id",
|
||||||
|
"project_id",
|
||||||
|
"conversation_id",
|
||||||
|
"run_id",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -237,6 +245,12 @@ class TaskPlan:
|
|||||||
schema_version: int = 1
|
schema_version: int = 1
|
||||||
task_id: str | None = None
|
task_id: str | None = None
|
||||||
task_type: str = "code.change"
|
task_type: str = "code.change"
|
||||||
|
scope_type: str = "legacy"
|
||||||
|
tenant_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
project_id: str | None = None
|
||||||
|
conversation_id: str | None = None
|
||||||
|
run_id: str | None = None
|
||||||
issuer: dict[str, Any] = field(default_factory=dict)
|
issuer: dict[str, Any] = field(default_factory=dict)
|
||||||
intent: str | None = None
|
intent: str | None = None
|
||||||
required_capabilities: list[str] = field(default_factory=list)
|
required_capabilities: list[str] = field(default_factory=list)
|
||||||
@@ -311,10 +325,17 @@ class TaskPlan:
|
|||||||
raise ValueError("credential-secret requires metadata-only logging")
|
raise ValueError("credential-secret requires metadata-only logging")
|
||||||
if output_policy != "hash-count-status-only":
|
if output_policy != "hash-count-status-only":
|
||||||
raise ValueError("credential-secret requires restricted output")
|
raise ValueError("credential-secret requires restricted output")
|
||||||
|
scope = normalize_task_scope(data)
|
||||||
return cls(
|
return cls(
|
||||||
schema_version=schema_version,
|
schema_version=schema_version,
|
||||||
task_id=validate_identifier(data["task_id"], "task_id"),
|
task_id=validate_identifier(data["task_id"], "task_id"),
|
||||||
task_type=validate_type(data.get("task_type", "code.change"), "task_type"),
|
task_type=validate_type(data.get("task_type", "code.change"), "task_type"),
|
||||||
|
scope_type=scope.scope_type,
|
||||||
|
tenant_id=scope.tenant_id,
|
||||||
|
user_id=scope.user_id,
|
||||||
|
project_id=scope.project_id,
|
||||||
|
conversation_id=scope.conversation_id,
|
||||||
|
run_id=scope.run_id,
|
||||||
issuer=issuer,
|
issuer=issuer,
|
||||||
intent=data.get("intent"),
|
intent=data.get("intent"),
|
||||||
required_capabilities=_normalize_list(data.get("required_capabilities", [])),
|
required_capabilities=_normalize_list(data.get("required_capabilities", [])),
|
||||||
@@ -348,6 +369,12 @@ class TaskPlan:
|
|||||||
"schema_version": self.schema_version,
|
"schema_version": self.schema_version,
|
||||||
"task_id": self.task_id,
|
"task_id": self.task_id,
|
||||||
"task_type": self.task_type,
|
"task_type": self.task_type,
|
||||||
|
"scope_type": self.scope_type,
|
||||||
|
"tenant_id": self.tenant_id,
|
||||||
|
"user_id": self.user_id,
|
||||||
|
"project_id": self.project_id,
|
||||||
|
"conversation_id": self.conversation_id,
|
||||||
|
"run_id": self.run_id,
|
||||||
"issuer": self.issuer,
|
"issuer": self.issuer,
|
||||||
"intent": self.intent,
|
"intent": self.intent,
|
||||||
"required_capabilities": list(self.required_capabilities),
|
"required_capabilities": list(self.required_capabilities),
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable
|
from typing import Any, Callable, Mapping
|
||||||
|
|
||||||
from .errors import ForbiddenError, LockedError
|
from .errors import ForbiddenError, LockedError
|
||||||
from .models import ProbeEvidence, ResultEnvelope, TaskRecord
|
from .models import ProbeEvidence, ResultEnvelope, TaskRecord
|
||||||
|
from .scope import TaskActorContext, actor_context as build_actor_context
|
||||||
|
|
||||||
|
|
||||||
RISK_RANK = {"low": 1, "medium": 2, "high": 3}
|
RISK_RANK = {"low": 1, "medium": 2, "high": 3}
|
||||||
@@ -93,6 +94,10 @@ class PolicyEngine:
|
|||||||
result_signature_verifier: Callable[[ResultEnvelope], bool] | None = None,
|
result_signature_verifier: Callable[[ResultEnvelope], bool] | None = None,
|
||||||
probe_attestation_verifier: Callable[[ProbeEvidence], bool] | None = None,
|
probe_attestation_verifier: Callable[[ProbeEvidence], bool] | None = None,
|
||||||
identity_capabilities: dict[str, set[str]] | None = None,
|
identity_capabilities: dict[str, set[str]] | None = None,
|
||||||
|
identity_actor_contexts: Mapping[
|
||||||
|
str, TaskActorContext | Mapping[str, Any]
|
||||||
|
]
|
||||||
|
| None = None,
|
||||||
admin_identities: set[str] | None = None,
|
admin_identities: set[str] | None = None,
|
||||||
readonly_worker_execution_enabled: bool = False,
|
readonly_worker_execution_enabled: bool = False,
|
||||||
readonly_worker_profiles: list[ReadonlyWorkerProfile] | None = None,
|
readonly_worker_profiles: list[ReadonlyWorkerProfile] | None = None,
|
||||||
@@ -109,12 +114,26 @@ class PolicyEngine:
|
|||||||
identity: set(capabilities)
|
identity: set(capabilities)
|
||||||
for identity, capabilities in (identity_capabilities or {}).items()
|
for identity, capabilities in (identity_capabilities or {}).items()
|
||||||
}
|
}
|
||||||
|
self.identity_actor_contexts = dict(identity_actor_contexts or {})
|
||||||
self.admin_identities = set(admin_identities or ())
|
self.admin_identities = set(admin_identities or ())
|
||||||
self.readonly_worker_execution_enabled = readonly_worker_execution_enabled
|
self.readonly_worker_execution_enabled = readonly_worker_execution_enabled
|
||||||
self.readonly_worker_profiles = {
|
self.readonly_worker_profiles = {
|
||||||
profile.identity: profile for profile in (readonly_worker_profiles or [])
|
profile.identity: profile for profile in (readonly_worker_profiles or [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def actor_context(self, identity: str) -> TaskActorContext:
|
||||||
|
configured = self.identity_actor_contexts.get(
|
||||||
|
identity, self.identity_actor_contexts.get("*")
|
||||||
|
)
|
||||||
|
capabilities = set(
|
||||||
|
self.identity_capabilities.get(
|
||||||
|
identity, self.identity_capabilities.get("*", set())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if identity in self.admin_identities:
|
||||||
|
capabilities.add("task.admin")
|
||||||
|
return build_actor_context(identity, configured, capabilities)
|
||||||
|
|
||||||
def _readonly_worker_allows(
|
def _readonly_worker_allows(
|
||||||
self, action: str, actor: str, task: TaskRecord | None
|
self, action: str, actor: str, task: TaskRecord | None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|||||||
@@ -74,3 +74,203 @@ class TaskScheduler:
|
|||||||
operation_digest=task.task_plan.operation_digest(),
|
operation_digest=task.task_plan.operation_digest(),
|
||||||
)
|
)
|
||||||
return claim
|
return claim
|
||||||
|
|
||||||
|
# R4.2 P2 durable multi-agent fleet scheduling public surface.
|
||||||
|
from .fleet import (
|
||||||
|
FleetConflict,
|
||||||
|
FleetDispatchDecision,
|
||||||
|
FleetError,
|
||||||
|
FleetScheduler,
|
||||||
|
FleetStateStore,
|
||||||
|
FleetWorkerPolicy,
|
||||||
|
)
|
||||||
|
# R4.2 P2-B fleet-aware pull scheduling.
|
||||||
|
#
|
||||||
|
# The original TaskScheduler remains the security oracle and claim-CAS implementation.
|
||||||
|
# Fleet ordering only controls the order presented to that oracle.
|
||||||
|
import inspect as _fleet_inspect
|
||||||
|
import threading as _fleet_threading
|
||||||
|
from collections.abc import Mapping as _FleetMapping
|
||||||
|
|
||||||
|
from .worker_registry import WorkerRegistry as _FleetWorkerRegistry
|
||||||
|
from .errors import NotFoundError as _FleetNotFoundError
|
||||||
|
|
||||||
|
_LegacyTaskScheduler = TaskScheduler
|
||||||
|
_LegacyScheduleParameters = _fleet_inspect.signature(
|
||||||
|
_LegacyTaskScheduler.schedule_for_worker
|
||||||
|
).parameters
|
||||||
|
_LegacyNextParameters = _fleet_inspect.signature(
|
||||||
|
_LegacyTaskScheduler.next_queued_task
|
||||||
|
).parameters
|
||||||
|
|
||||||
|
|
||||||
|
class _FleetOrderedStoreProxy:
|
||||||
|
def __init__(self, delegate, tasks):
|
||||||
|
self._delegate = delegate
|
||||||
|
self._tasks = tuple(tasks)
|
||||||
|
|
||||||
|
def list_tasks(self, *args, **kwargs):
|
||||||
|
return list(self._tasks)
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
return getattr(self._delegate, name)
|
||||||
|
|
||||||
|
|
||||||
|
def _fleet_claim_field(claim, name, default=None):
|
||||||
|
if isinstance(claim, _FleetMapping):
|
||||||
|
return claim.get(name, default)
|
||||||
|
return getattr(claim, name, default)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskScheduler(_LegacyTaskScheduler):
|
||||||
|
"""Fleet-integrated pull scheduler preserving legacy security and claim semantics."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
store = getattr(self, "store", args[0] if args else None)
|
||||||
|
if store is None:
|
||||||
|
raise FleetError("FLEET_SCHEDULER_STORE_REQUIRED")
|
||||||
|
registry = getattr(self, "workers", None)
|
||||||
|
if registry is None:
|
||||||
|
registry = _FleetWorkerRegistry(store)
|
||||||
|
self.fleet_scheduler = FleetScheduler(store, registry)
|
||||||
|
self._fleet_local = _fleet_threading.local()
|
||||||
|
|
||||||
|
def _fleet_legacy_next(self, worker_id, tasks, *args, **kwargs):
|
||||||
|
proxy = _FleetOrderedStoreProxy(self.store, tasks)
|
||||||
|
legacy = _LegacyTaskScheduler(proxy)
|
||||||
|
legacy_kwargs = dict(kwargs)
|
||||||
|
if "actor_context" not in _LegacyNextParameters:
|
||||||
|
legacy_kwargs.pop("actor_context", None)
|
||||||
|
return legacy.next_queued_task(worker_id, *args, **legacy_kwargs)
|
||||||
|
|
||||||
|
def next_queued_task(self, worker_id, *args, **kwargs):
|
||||||
|
actor_context = kwargs.get(
|
||||||
|
"actor_context", getattr(self._fleet_local, "actor_context", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Preserve legacy fail-closed worker checks before fleet ordering.
|
||||||
|
probe_kwargs = dict(kwargs)
|
||||||
|
if "actor_context" not in _LegacyNextParameters:
|
||||||
|
probe_kwargs.pop("actor_context", None)
|
||||||
|
legacy_probe = _LegacyTaskScheduler(self.store).next_queued_task(
|
||||||
|
worker_id, *args, **probe_kwargs
|
||||||
|
)
|
||||||
|
if legacy_probe is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ranked = self.fleet_scheduler.rank_for_worker(
|
||||||
|
worker_id, actor_context=actor_context
|
||||||
|
)
|
||||||
|
# Preserve FleetScheduler fairness order. The legacy scheduler is used
|
||||||
|
# only as a fail-closed security oracle: present one fleet-ranked task
|
||||||
|
# at a time so its internal sort cannot reorder across scopes.
|
||||||
|
# Keep the legacy priority dimension ahead of Fleet fairness for
|
||||||
|
# compatibility with existing TaskPlane scheduling contracts; the
|
||||||
|
# Fleet order remains authoritative among equal-priority tasks.
|
||||||
|
def _legacy_priority(task):
|
||||||
|
plan = getattr(task, "task_plan", task)
|
||||||
|
metadata = getattr(plan, "metadata", {}) or {}
|
||||||
|
if not isinstance(metadata, _FleetMapping):
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return int(metadata.get("priority", 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
ranked = sorted(ranked, key=lambda task: -_legacy_priority(task))
|
||||||
|
for ranked_candidate in ranked:
|
||||||
|
try:
|
||||||
|
candidate = self._fleet_legacy_next(
|
||||||
|
worker_id, [ranked_candidate], *args, **kwargs
|
||||||
|
)
|
||||||
|
except _FleetNotFoundError:
|
||||||
|
continue
|
||||||
|
if candidate is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ranked_id = getattr(
|
||||||
|
ranked_candidate,
|
||||||
|
"task_id",
|
||||||
|
getattr(getattr(ranked_candidate, "task_plan", None), "task_id", None),
|
||||||
|
)
|
||||||
|
candidate_id = getattr(
|
||||||
|
candidate,
|
||||||
|
"task_id",
|
||||||
|
getattr(getattr(candidate, "task_plan", None), "task_id", None),
|
||||||
|
)
|
||||||
|
if candidate_id != ranked_id:
|
||||||
|
raise FleetConflict("FLEET_LEGACY_ORACLE_ORDER_VIOLATION")
|
||||||
|
|
||||||
|
try:
|
||||||
|
decision = self.fleet_scheduler.reserve_for_worker(
|
||||||
|
worker_id, candidate
|
||||||
|
)
|
||||||
|
except FleetConflict:
|
||||||
|
continue
|
||||||
|
self._fleet_local.pending = decision
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
def schedule_for_worker(self, worker_id, *args, **kwargs):
|
||||||
|
actor_context = kwargs.get("actor_context")
|
||||||
|
legacy_kwargs = dict(kwargs)
|
||||||
|
if "actor_context" not in _LegacyScheduleParameters:
|
||||||
|
legacy_kwargs.pop("actor_context", None)
|
||||||
|
self._fleet_local.actor_context = actor_context
|
||||||
|
self._fleet_local.pending = None
|
||||||
|
try:
|
||||||
|
claim = _LegacyTaskScheduler.schedule_for_worker(
|
||||||
|
self, worker_id, *args, **legacy_kwargs
|
||||||
|
)
|
||||||
|
decision = getattr(self._fleet_local, "pending", None)
|
||||||
|
if claim is None:
|
||||||
|
if decision is not None:
|
||||||
|
self.fleet_scheduler.state.release(
|
||||||
|
decision.task_id, state="RELEASED"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if decision is not None:
|
||||||
|
claim_id = _fleet_claim_field(claim, "claim_id")
|
||||||
|
claim_task_id = _fleet_claim_field(claim, "task_id", decision.task_id)
|
||||||
|
if not claim_id:
|
||||||
|
self.fleet_scheduler.state.release(
|
||||||
|
decision.task_id, state="RELEASED"
|
||||||
|
)
|
||||||
|
raise FleetError("FLEET_CLAIM_ID_REQUIRED")
|
||||||
|
if claim_task_id != decision.task_id:
|
||||||
|
self.fleet_scheduler.state.release(
|
||||||
|
decision.task_id, state="RELEASED"
|
||||||
|
)
|
||||||
|
raise FleetConflict("FLEET_CLAIM_TASK_MISMATCH")
|
||||||
|
self.fleet_scheduler.state.bind_claim(
|
||||||
|
decision.reservation_id, str(claim_id)
|
||||||
|
)
|
||||||
|
return claim
|
||||||
|
except Exception:
|
||||||
|
decision = getattr(self._fleet_local, "pending", None)
|
||||||
|
if decision is not None:
|
||||||
|
self.fleet_scheduler.state.release(
|
||||||
|
decision.task_id, state="RELEASED"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self._fleet_local.pending = None
|
||||||
|
self._fleet_local.actor_context = None
|
||||||
|
|
||||||
|
def record_worker_failure(self, worker_id, failure_class, **kwargs):
|
||||||
|
return self.fleet_scheduler.state.record_worker_failure(
|
||||||
|
worker_id, failure_class, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_worker_success(self, worker_id, **kwargs):
|
||||||
|
return self.fleet_scheduler.state.record_worker_success(
|
||||||
|
worker_id, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
def quarantine_task_worker(
|
||||||
|
self, task_id, worker_id, failure_class, **kwargs
|
||||||
|
):
|
||||||
|
return self.fleet_scheduler.state.record_task_worker_failure(
|
||||||
|
task_id, worker_id, failure_class, **kwargs
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Trusted TaskPlane scope model and SQL authorization helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from .errors import ForbiddenError, ValidationError
|
||||||
|
|
||||||
|
SCOPE_FIELDS = ("tenant_id", "user_id", "project_id", "conversation_id", "run_id")
|
||||||
|
PRIVILEGED_CAPABILITIES = frozenset(
|
||||||
|
{"*", "admin", "system.admin", "task.admin", "task.read.all"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _value(value: Any, field: str) -> Any:
|
||||||
|
if hasattr(value, "task_plan"):
|
||||||
|
value = value.task_plan
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return value.get(field)
|
||||||
|
return getattr(value, field, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: Any) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
result = str(value).strip()
|
||||||
|
return result or None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TaskScope:
|
||||||
|
scope_type: str
|
||||||
|
tenant_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
project_id: str | None = None
|
||||||
|
conversation_id: str | None = None
|
||||||
|
run_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TaskActorContext:
|
||||||
|
identity: str
|
||||||
|
scope_type: str = "legacy"
|
||||||
|
tenant_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
project_id: str | None = None
|
||||||
|
conversation_id: str | None = None
|
||||||
|
run_id: str | None = None
|
||||||
|
capabilities: frozenset[str] = frozenset()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_task_scope(value: Any) -> TaskScope:
|
||||||
|
fields = {field: _text(_value(value, field)) for field in SCOPE_FIELDS}
|
||||||
|
declared = _text(_value(value, "scope_type"))
|
||||||
|
if declared is None:
|
||||||
|
declared = "tenant" if fields["tenant_id"] else "legacy"
|
||||||
|
if declared == "tenant":
|
||||||
|
if fields["tenant_id"] is None:
|
||||||
|
raise ValidationError(
|
||||||
|
"tenant scope requires tenant_id", code="TASK_SCOPE_TENANT_ID_REQUIRED"
|
||||||
|
)
|
||||||
|
elif declared == "legacy":
|
||||||
|
if any(fields.values()):
|
||||||
|
raise ValidationError(
|
||||||
|
"legacy scope cannot contain scoped ownership",
|
||||||
|
code="TASK_SCOPE_LEGACY_WITH_SCOPED_FIELDS",
|
||||||
|
)
|
||||||
|
elif declared == "system":
|
||||||
|
if any(fields[field] for field in ("tenant_id", "user_id", "project_id", "conversation_id")):
|
||||||
|
raise ValidationError(
|
||||||
|
"system scope cannot contain tenant ownership",
|
||||||
|
code="TASK_SCOPE_SYSTEM_WITH_TENANT_FIELDS",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValidationError("invalid task scope type", code="TASK_SCOPE_TYPE_INVALID")
|
||||||
|
return TaskScope(declared, **fields)
|
||||||
|
|
||||||
|
|
||||||
|
def actor_is_privileged(actor: TaskActorContext) -> bool:
|
||||||
|
return bool(actor.capabilities & PRIVILEGED_CAPABILITIES)
|
||||||
|
|
||||||
|
|
||||||
|
def actor_context(
|
||||||
|
identity: str,
|
||||||
|
configured: TaskActorContext | Mapping[str, Any] | None,
|
||||||
|
capabilities: set[str] | frozenset[str],
|
||||||
|
) -> TaskActorContext:
|
||||||
|
if isinstance(configured, TaskActorContext):
|
||||||
|
context = configured
|
||||||
|
else:
|
||||||
|
data = dict(configured or {})
|
||||||
|
data.pop("identity", None)
|
||||||
|
data.pop("capabilities", None)
|
||||||
|
context = TaskActorContext(identity=str(identity), **data)
|
||||||
|
scope = normalize_task_scope(context)
|
||||||
|
return replace(
|
||||||
|
context,
|
||||||
|
identity=str(identity),
|
||||||
|
scope_type=scope.scope_type,
|
||||||
|
tenant_id=scope.tenant_id,
|
||||||
|
user_id=scope.user_id,
|
||||||
|
project_id=scope.project_id,
|
||||||
|
conversation_id=scope.conversation_id,
|
||||||
|
run_id=scope.run_id,
|
||||||
|
capabilities=frozenset(str(item) for item in capabilities),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def bind_task_scope(plan: Any, actor: TaskActorContext, *, public_create: bool) -> Any:
|
||||||
|
requested = normalize_task_scope(plan)
|
||||||
|
if actor_is_privileged(actor):
|
||||||
|
trusted = normalize_task_scope(actor)
|
||||||
|
if requested.scope_type == "legacy" and trusted.scope_type != "legacy":
|
||||||
|
plan.scope_type = trusted.scope_type
|
||||||
|
for field in SCOPE_FIELDS:
|
||||||
|
setattr(plan, field, getattr(actor, field))
|
||||||
|
return plan
|
||||||
|
trusted = normalize_task_scope(actor)
|
||||||
|
if public_create and trusted.scope_type == "legacy":
|
||||||
|
raise ForbiddenError(
|
||||||
|
"authenticated identity has no tenant scope",
|
||||||
|
code="TASK_ACTOR_SCOPE_UNAVAILABLE",
|
||||||
|
)
|
||||||
|
if trusted.scope_type != "tenant":
|
||||||
|
if requested.scope_type == "legacy":
|
||||||
|
plan.scope_type = trusted.scope_type
|
||||||
|
for field in SCOPE_FIELDS:
|
||||||
|
setattr(plan, field, getattr(actor, field))
|
||||||
|
return plan
|
||||||
|
if requested.scope_type != trusted.scope_type:
|
||||||
|
raise ForbiddenError("task scope mismatch", code="TASK_SCOPE_MISMATCH_DENY")
|
||||||
|
return plan
|
||||||
|
if requested.scope_type not in {"legacy", "tenant"}:
|
||||||
|
raise ForbiddenError("task scope mismatch", code="TASK_SCOPE_MISMATCH_DENY")
|
||||||
|
updates: dict[str, Any] = {"scope_type": "tenant"}
|
||||||
|
for field in SCOPE_FIELDS:
|
||||||
|
declared = getattr(requested, field)
|
||||||
|
authoritative = getattr(actor, field)
|
||||||
|
if declared is not None and declared != authoritative:
|
||||||
|
raise ForbiddenError(
|
||||||
|
"task scope mismatch",
|
||||||
|
code="TASK_SCOPE_MISMATCH_DENY",
|
||||||
|
details={"field": field},
|
||||||
|
)
|
||||||
|
updates[field] = authoritative
|
||||||
|
for field, item in updates.items():
|
||||||
|
setattr(plan, field, item)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def actor_scope_sql(actor: TaskActorContext | None) -> tuple[str, tuple[Any, ...]]:
|
||||||
|
if actor is None or actor_is_privileged(actor):
|
||||||
|
return "", ()
|
||||||
|
scope = normalize_task_scope(actor)
|
||||||
|
if scope.scope_type == "legacy":
|
||||||
|
return "scope_type = 'legacy'", ()
|
||||||
|
if scope.scope_type == "system":
|
||||||
|
return "scope_type = 'system'", ()
|
||||||
|
clauses = ["scope_type = 'tenant'", "tenant_id = ?"]
|
||||||
|
params: list[Any] = [scope.tenant_id]
|
||||||
|
for field in ("user_id", "project_id", "conversation_id", "run_id"):
|
||||||
|
value = getattr(scope, field)
|
||||||
|
if value is None:
|
||||||
|
clauses.append(f"{field} IS NULL")
|
||||||
|
else:
|
||||||
|
clauses.append(f"({field} IS NULL OR {field} = ?)")
|
||||||
|
params.append(value)
|
||||||
|
return " AND ".join(clauses), tuple(params)
|
||||||
|
|
||||||
|
|
||||||
|
def task_scope_values(value: Any) -> tuple[str | None, ...]:
|
||||||
|
scope = normalize_task_scope(value)
|
||||||
|
return (
|
||||||
|
scope.scope_type,
|
||||||
|
scope.tenant_id,
|
||||||
|
scope.user_id,
|
||||||
|
scope.project_id,
|
||||||
|
scope.conversation_id,
|
||||||
|
scope.run_id,
|
||||||
|
)
|
||||||
@@ -32,6 +32,7 @@ from .models import (
|
|||||||
redact_value,
|
redact_value,
|
||||||
validate_identifier,
|
validate_identifier,
|
||||||
)
|
)
|
||||||
|
from .scope import TaskActorContext, actor_scope_sql, task_scope_values
|
||||||
|
|
||||||
|
|
||||||
EventEnvelope = tuple[str, str, str, str, str, float]
|
EventEnvelope = tuple[str, str, str, str, str, float]
|
||||||
@@ -134,6 +135,12 @@ class TaskPlaneStore:
|
|||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
task_json TEXT NOT NULL,
|
task_json TEXT NOT NULL,
|
||||||
task_type TEXT NOT NULL,
|
task_type TEXT NOT NULL,
|
||||||
|
scope_type TEXT NOT NULL DEFAULT 'legacy',
|
||||||
|
tenant_id TEXT,
|
||||||
|
user_id TEXT,
|
||||||
|
project_id TEXT,
|
||||||
|
conversation_id TEXT,
|
||||||
|
run_id TEXT,
|
||||||
root_task_id TEXT NOT NULL,
|
root_task_id TEXT NOT NULL,
|
||||||
parent_task_id TEXT,
|
parent_task_id TEXT,
|
||||||
correlation_id TEXT,
|
correlation_id TEXT,
|
||||||
@@ -272,6 +279,21 @@ class TaskPlaneStore:
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
self._ensure_columns(
|
||||||
|
"tasks",
|
||||||
|
{
|
||||||
|
"scope_type": "TEXT NOT NULL DEFAULT 'legacy'",
|
||||||
|
"tenant_id": "TEXT",
|
||||||
|
"user_id": "TEXT",
|
||||||
|
"project_id": "TEXT",
|
||||||
|
"conversation_id": "TEXT",
|
||||||
|
"run_id": "TEXT",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE tasks SET scope_type='legacy' "
|
||||||
|
"WHERE scope_type IS NULL OR TRIM(scope_type)=''"
|
||||||
|
)
|
||||||
self._ensure_columns(
|
self._ensure_columns(
|
||||||
"workers",
|
"workers",
|
||||||
{
|
{
|
||||||
@@ -299,6 +321,14 @@ class TaskPlaneStore:
|
|||||||
)
|
)
|
||||||
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_tasks_status ON tasks(status)")
|
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_tasks_status ON tasks(status)")
|
||||||
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_tasks_parent ON tasks(parent_task_id)")
|
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_tasks_parent ON tasks(parent_task_id)")
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_tasks_scope_tenant_project "
|
||||||
|
"ON tasks(scope_type, tenant_id, project_id)"
|
||||||
|
)
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_tasks_scope_owner "
|
||||||
|
"ON tasks(scope_type, tenant_id, user_id, conversation_id, run_id)"
|
||||||
|
)
|
||||||
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_workers_status ON workers(status)")
|
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_workers_status ON workers(status)")
|
||||||
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_claims_task ON claims(task_id)")
|
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_claims_task ON claims(task_id)")
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
@@ -347,6 +377,42 @@ class TaskPlaneStore:
|
|||||||
raise NotFoundError("task")
|
raise NotFoundError("task")
|
||||||
task_json = json.loads(row["task_json"])
|
task_json = json.loads(row["task_json"])
|
||||||
task_plan = TaskPlan.from_dict(task_json)
|
task_plan = TaskPlan.from_dict(task_json)
|
||||||
|
stored_scope = tuple(
|
||||||
|
row[field]
|
||||||
|
for field in (
|
||||||
|
"scope_type",
|
||||||
|
"tenant_id",
|
||||||
|
"user_id",
|
||||||
|
"project_id",
|
||||||
|
"conversation_id",
|
||||||
|
"run_id",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
json_has_scope = any(
|
||||||
|
field in task_json
|
||||||
|
for field in (
|
||||||
|
"scope_type",
|
||||||
|
"tenant_id",
|
||||||
|
"user_id",
|
||||||
|
"project_id",
|
||||||
|
"conversation_id",
|
||||||
|
"run_id",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if json_has_scope and stored_scope != task_scope_values(task_plan):
|
||||||
|
raise ConflictError(
|
||||||
|
"task scope persistence mismatch",
|
||||||
|
code="TASK_SCOPE_PERSISTENCE_MISMATCH",
|
||||||
|
)
|
||||||
|
if not json_has_scope:
|
||||||
|
(
|
||||||
|
task_plan.scope_type,
|
||||||
|
task_plan.tenant_id,
|
||||||
|
task_plan.user_id,
|
||||||
|
task_plan.project_id,
|
||||||
|
task_plan.conversation_id,
|
||||||
|
task_plan.run_id,
|
||||||
|
) = stored_scope
|
||||||
task_plan.parent_task_id = row["parent_task_id"]
|
task_plan.parent_task_id = row["parent_task_id"]
|
||||||
task_plan.correlation_id = row["correlation_id"]
|
task_plan.correlation_id = row["correlation_id"]
|
||||||
return TaskRecord(
|
return TaskRecord(
|
||||||
@@ -389,6 +455,7 @@ class TaskPlaneStore:
|
|||||||
task_id = task_plan.task_id or f"task-{uuid.uuid4()}"
|
task_id = task_plan.task_id or f"task-{uuid.uuid4()}"
|
||||||
task_plan.task_id = task_id
|
task_plan.task_id = task_id
|
||||||
task_json = redact_value(task_plan.to_dict(redact=False))
|
task_json = redact_value(task_plan.to_dict(redact=False))
|
||||||
|
scope_values = task_scope_values(task_plan)
|
||||||
now = _now()
|
now = _now()
|
||||||
with self._transaction():
|
with self._transaction():
|
||||||
if idempotency_key:
|
if idempotency_key:
|
||||||
@@ -397,7 +464,11 @@ class TaskPlaneStore:
|
|||||||
(idempotency_key,),
|
(idempotency_key,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if existing:
|
if existing:
|
||||||
if _canonical(json.loads(existing["task_json"])) != _canonical(task_json):
|
existing_plan = TaskPlan.from_dict(
|
||||||
|
json.loads(existing["task_json"])
|
||||||
|
)
|
||||||
|
existing_json = redact_value(existing_plan.to_dict(redact=False))
|
||||||
|
if _canonical(existing_json) != _canonical(task_json):
|
||||||
raise ConflictError("idempotency key payload mismatch")
|
raise ConflictError("idempotency key payload mismatch")
|
||||||
return self.get_task(existing["task_id"])
|
return self.get_task(existing["task_id"])
|
||||||
root_task_id = task_id
|
root_task_id = task_id
|
||||||
@@ -412,14 +483,18 @@ class TaskPlaneStore:
|
|||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO tasks
|
INSERT INTO tasks
|
||||||
(task_id, revision, status, task_json, task_type, root_task_id, parent_task_id, correlation_id, idempotency_key, created_at, updated_at, failure_reason)
|
(task_id, revision, status, task_json, task_type,
|
||||||
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
scope_type, tenant_id, user_id, project_id, conversation_id, run_id,
|
||||||
|
root_task_id, parent_task_id, correlation_id, idempotency_key,
|
||||||
|
created_at, updated_at, failure_reason)
|
||||||
|
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
task_id,
|
task_id,
|
||||||
"CREATED",
|
"CREATED",
|
||||||
json.dumps(task_json, ensure_ascii=False),
|
json.dumps(task_json, ensure_ascii=False),
|
||||||
task_plan.task_type,
|
task_plan.task_type,
|
||||||
|
*scope_values,
|
||||||
root_task_id,
|
root_task_id,
|
||||||
task_plan.parent_task_id,
|
task_plan.parent_task_id,
|
||||||
task_plan.correlation_id,
|
task_plan.correlation_id,
|
||||||
@@ -451,21 +526,30 @@ class TaskPlaneStore:
|
|||||||
)
|
)
|
||||||
return self.get_task(task.task_id)
|
return self.get_task(task.task_id)
|
||||||
|
|
||||||
def get_task(self, task_id: str) -> TaskRecord:
|
def get_task(
|
||||||
|
self, task_id: str, actor_context: TaskActorContext | None = None
|
||||||
|
) -> TaskRecord:
|
||||||
task_id = _validated_id(task_id, "task_id")
|
task_id = _validated_id(task_id, "task_id")
|
||||||
|
predicate, scope_params = actor_scope_sql(actor_context)
|
||||||
|
sql = "SELECT * FROM tasks WHERE task_id=?"
|
||||||
|
if predicate:
|
||||||
|
sql += f" AND ({predicate})"
|
||||||
with self._lock:
|
with self._lock:
|
||||||
row = self.conn.execute(
|
row = self.conn.execute(sql, (task_id, *scope_params)).fetchone()
|
||||||
"SELECT * FROM tasks WHERE task_id=?", (task_id,)
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
if row is None:
|
||||||
raise NotFoundError(f"task {task_id} not found")
|
raise NotFoundError(f"task {task_id} not found")
|
||||||
return self._row_to_task(row)
|
return self._row_to_task(row)
|
||||||
|
|
||||||
def list_tasks(self) -> list[TaskRecord]:
|
def list_tasks(
|
||||||
|
self, actor_context: TaskActorContext | None = None
|
||||||
|
) -> list[TaskRecord]:
|
||||||
|
predicate, scope_params = actor_scope_sql(actor_context)
|
||||||
|
sql = "SELECT * FROM tasks"
|
||||||
|
if predicate:
|
||||||
|
sql += f" WHERE ({predicate})"
|
||||||
|
sql += " ORDER BY created_at DESC"
|
||||||
with self._lock:
|
with self._lock:
|
||||||
rows = self.conn.execute(
|
rows = self.conn.execute(sql, scope_params).fetchall()
|
||||||
"SELECT * FROM tasks ORDER BY created_at DESC"
|
|
||||||
).fetchall()
|
|
||||||
return [self._row_to_task(row) for row in rows]
|
return [self._row_to_task(row) for row in rows]
|
||||||
|
|
||||||
def get_task_budget(self, task_id: str) -> Budget:
|
def get_task_budget(self, task_id: str) -> Budget:
|
||||||
|
|||||||
@@ -0,0 +1,759 @@
|
|||||||
|
"""Scope-bound governance for traces, Tool/MCP calls, and credentials.
|
||||||
|
|
||||||
|
This module deliberately stores references and audit metadata only. Secret
|
||||||
|
material remains in the configured credential provider boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
SCOPE_FIELDS = ("tenant_id", "user_id", "project_id", "conversation_id", "run_id")
|
||||||
|
PRIVILEGED_CAPABILITIES = frozenset({"*", "admin", "system.admin", "governance.admin"})
|
||||||
|
REFERENCE_RE = re.compile(r"^(systemd-credential|vault|secret-manager):[A-Za-z0-9][A-Za-z0-9._:/#-]{0,255}$")
|
||||||
|
IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,179}$")
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceError(RuntimeError):
|
||||||
|
def __init__(self, code: str, *, field: str | None = None) -> None:
|
||||||
|
super().__init__(code if field is None else f"{code}:{field}")
|
||||||
|
self.code = code
|
||||||
|
self.field = field
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value: Any) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_tuple(value: Iterable[str] | str | None) -> tuple[str, ...]:
|
||||||
|
if value is None:
|
||||||
|
return ()
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = value.split(",")
|
||||||
|
return tuple(sorted({_clean(item) for item in value if _clean(item)}))
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical(value: Any) -> str:
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def _sha(value: Any) -> str:
|
||||||
|
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _identifier(value: Any, code: str) -> str:
|
||||||
|
text = _clean(value)
|
||||||
|
if text is None or IDENTIFIER_RE.fullmatch(text) is None:
|
||||||
|
raise GovernanceError(code)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GovernanceScope:
|
||||||
|
"""Explicit resource scope; system/global resources never get fake tenants."""
|
||||||
|
|
||||||
|
scope_type: str
|
||||||
|
tenant_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
project_id: str | None = None
|
||||||
|
conversation_id: str | None = None
|
||||||
|
run_id: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(self, "scope_type", (_clean(self.scope_type) or "").lower())
|
||||||
|
for name in SCOPE_FIELDS:
|
||||||
|
object.__setattr__(self, name, _clean(getattr(self, name)))
|
||||||
|
if self.scope_type == "tenant":
|
||||||
|
if self.tenant_id is None:
|
||||||
|
raise GovernanceError("GOVERNANCE_TENANT_ID_REQUIRED")
|
||||||
|
elif self.scope_type in {"system", "global"}:
|
||||||
|
if any(getattr(self, name) is not None for name in SCOPE_FIELDS):
|
||||||
|
raise GovernanceError("GOVERNANCE_NON_TENANT_SCOPE_INVALID")
|
||||||
|
elif self.scope_type == "legacy":
|
||||||
|
if any(getattr(self, name) is not None for name in SCOPE_FIELDS):
|
||||||
|
raise GovernanceError("GOVERNANCE_LEGACY_SCOPE_INVALID")
|
||||||
|
else:
|
||||||
|
raise GovernanceError("GOVERNANCE_SCOPE_TYPE_INVALID")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_value(cls, value: Any) -> "GovernanceScope":
|
||||||
|
def get(name: str) -> Any:
|
||||||
|
return value.get(name) if isinstance(value, Mapping) else getattr(value, name, None)
|
||||||
|
|
||||||
|
declared = _clean(get("scope_type"))
|
||||||
|
tenant_id = _clean(get("tenant_id"))
|
||||||
|
return cls(
|
||||||
|
scope_type=declared or ("tenant" if tenant_id else "legacy"),
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=get("user_id"),
|
||||||
|
project_id=get("project_id"),
|
||||||
|
conversation_id=get("conversation_id"),
|
||||||
|
run_id=get("run_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GovernanceActor:
|
||||||
|
identity: str
|
||||||
|
scope: GovernanceScope
|
||||||
|
capabilities: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(self, "identity", _identifier(self.identity, "GOVERNANCE_ACTOR_INVALID"))
|
||||||
|
object.__setattr__(self, "capabilities", _scope_tuple(self.capabilities))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_value(cls, value: Any) -> "GovernanceActor":
|
||||||
|
if isinstance(value, cls):
|
||||||
|
return value
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise GovernanceError("GOVERNANCE_ACTOR_INVALID")
|
||||||
|
return cls(
|
||||||
|
identity=value.get("identity") or value.get("actor_id") or value.get("user_id"),
|
||||||
|
scope=GovernanceScope.from_value(value),
|
||||||
|
capabilities=_scope_tuple(value.get("capabilities") or value.get("capability_scope")),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def privileged(self) -> bool:
|
||||||
|
return bool(set(self.capabilities) & PRIVILEGED_CAPABILITIES)
|
||||||
|
|
||||||
|
|
||||||
|
def authorize_scope(actor: GovernanceActor | Mapping[str, Any], resource: GovernanceScope | Mapping[str, Any]) -> None:
|
||||||
|
actor = GovernanceActor.from_value(actor)
|
||||||
|
resource = resource if isinstance(resource, GovernanceScope) else GovernanceScope.from_value(resource)
|
||||||
|
if actor.privileged:
|
||||||
|
return
|
||||||
|
if resource.scope_type in {"system", "global"}:
|
||||||
|
raise GovernanceError("GOVERNANCE_SYSTEM_SCOPE_AUTHZ_DENY")
|
||||||
|
if resource.scope_type == "legacy":
|
||||||
|
if actor.scope.scope_type != "legacy":
|
||||||
|
raise GovernanceError("GOVERNANCE_LEGACY_SCOPE_AUTHZ_DENY")
|
||||||
|
return
|
||||||
|
if actor.scope.scope_type != "tenant":
|
||||||
|
raise GovernanceError("GOVERNANCE_SCOPE_MISMATCH_DENY", field="scope_type")
|
||||||
|
for name in SCOPE_FIELDS:
|
||||||
|
expected = getattr(resource, name)
|
||||||
|
actual = getattr(actor.scope, name)
|
||||||
|
if expected is not None and expected != actual:
|
||||||
|
raise GovernanceError("GOVERNANCE_SCOPE_MISMATCH_DENY", field=name)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TraceContext:
|
||||||
|
trace_id: str
|
||||||
|
span_id: str
|
||||||
|
scope: GovernanceScope
|
||||||
|
parent_span_id: str | None = None
|
||||||
|
correlation_id: str | None = None
|
||||||
|
causation_id: str | None = None
|
||||||
|
task_id: str | None = None
|
||||||
|
claim_id: str | None = None
|
||||||
|
message_id: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(self, "trace_id", _identifier(self.trace_id, "TRACE_ID_INVALID"))
|
||||||
|
object.__setattr__(self, "span_id", _identifier(self.span_id, "TRACE_SPAN_ID_INVALID"))
|
||||||
|
for name in ("parent_span_id", "correlation_id", "causation_id", "task_id", "claim_id", "message_id"):
|
||||||
|
value = _clean(getattr(self, name))
|
||||||
|
if value is not None and IDENTIFIER_RE.fullmatch(value) is None:
|
||||||
|
raise GovernanceError("TRACE_LINEAGE_IDENTIFIER_INVALID", field=name)
|
||||||
|
object.__setattr__(self, name, value)
|
||||||
|
|
||||||
|
def child(self, *, span_id: str, actor: GovernanceActor | Mapping[str, Any], **lineage: Any) -> "TraceContext":
|
||||||
|
authorize_scope(actor, self.scope)
|
||||||
|
for name in ("trace_id", "task_id", "claim_id", "message_id", "correlation_id"):
|
||||||
|
supplied = _clean(lineage.get(name))
|
||||||
|
expected = getattr(self, name)
|
||||||
|
if supplied is not None and expected is not None and supplied != expected:
|
||||||
|
raise GovernanceError("TRACE_LINEAGE_CONTINUITY", field=name)
|
||||||
|
requested_scope = lineage.pop("scope", self.scope)
|
||||||
|
requested_scope = requested_scope if isinstance(requested_scope, GovernanceScope) else GovernanceScope.from_value(requested_scope)
|
||||||
|
if requested_scope != self.scope:
|
||||||
|
raise GovernanceError("TRACE_SCOPE_MISMATCH_DENY")
|
||||||
|
return TraceContext(
|
||||||
|
trace_id=self.trace_id,
|
||||||
|
span_id=span_id,
|
||||||
|
parent_span_id=self.span_id,
|
||||||
|
correlation_id=self.correlation_id,
|
||||||
|
causation_id=_clean(lineage.get("causation_id")) or self.span_id,
|
||||||
|
task_id=self.task_id,
|
||||||
|
claim_id=self.claim_id,
|
||||||
|
message_id=self.message_id,
|
||||||
|
scope=self.scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"trace_id": self.trace_id,
|
||||||
|
"span_id": self.span_id,
|
||||||
|
"parent_span_id": self.parent_span_id,
|
||||||
|
"correlation_id": self.correlation_id,
|
||||||
|
"causation_id": self.causation_id,
|
||||||
|
"task_id": self.task_id,
|
||||||
|
"claim_id": self.claim_id,
|
||||||
|
"message_id": self.message_id,
|
||||||
|
**self.scope.as_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def bind_trace_lineage(
|
||||||
|
*,
|
||||||
|
actor: GovernanceActor | Mapping[str, Any],
|
||||||
|
task: Any,
|
||||||
|
message: Any,
|
||||||
|
trace_id: str,
|
||||||
|
span_id: str,
|
||||||
|
parent: TraceContext | None = None,
|
||||||
|
) -> TraceContext:
|
||||||
|
actor = GovernanceActor.from_value(actor)
|
||||||
|
task_scope = GovernanceScope.from_value(task)
|
||||||
|
message_scope = GovernanceScope.from_value(message)
|
||||||
|
if task_scope != message_scope:
|
||||||
|
raise GovernanceError("TRACE_SCOPE_MISMATCH_DENY")
|
||||||
|
authorize_scope(actor, task_scope)
|
||||||
|
|
||||||
|
def get(value: Any, name: str) -> Any:
|
||||||
|
return value.get(name) if isinstance(value, Mapping) else getattr(value, name, None)
|
||||||
|
|
||||||
|
task_id = _clean(get(task, "task_id"))
|
||||||
|
message_task = _clean(get(message, "task_id"))
|
||||||
|
if task_id and message_task and task_id != message_task:
|
||||||
|
raise GovernanceError("TRACE_LINEAGE_CONTINUITY", field="task_id")
|
||||||
|
context = TraceContext(
|
||||||
|
trace_id=trace_id,
|
||||||
|
span_id=span_id,
|
||||||
|
parent_span_id=parent.span_id if parent else None,
|
||||||
|
correlation_id=_clean(get(task, "correlation_id")) or _clean(get(message, "correlation_id")),
|
||||||
|
causation_id=_clean(get(message, "causation_id")) or (parent.span_id if parent else None),
|
||||||
|
task_id=task_id or message_task,
|
||||||
|
claim_id=_clean(get(task, "claim_id")) or _clean(get(message, "claim_id")),
|
||||||
|
message_id=_clean(get(message, "envelope_id")) or _clean(get(message, "id")),
|
||||||
|
scope=task_scope,
|
||||||
|
)
|
||||||
|
if parent is not None:
|
||||||
|
if parent.trace_id != context.trace_id or parent.scope != context.scope:
|
||||||
|
raise GovernanceError("TRACE_LINEAGE_CONTINUITY")
|
||||||
|
if parent.task_id and context.task_id and parent.task_id != context.task_id:
|
||||||
|
raise GovernanceError("TRACE_LINEAGE_CONTINUITY", field="task_id")
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ToolDescriptor:
|
||||||
|
name: str
|
||||||
|
kind: str
|
||||||
|
scope: GovernanceScope
|
||||||
|
required_capabilities: tuple[str, ...] = ()
|
||||||
|
credential_ref: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(self, "name", _identifier(self.name, "TOOL_NAME_INVALID"))
|
||||||
|
object.__setattr__(self, "kind", (_clean(self.kind) or "").lower())
|
||||||
|
if self.kind not in {"tool", "mcp"}:
|
||||||
|
raise GovernanceError("TOOL_KIND_INVALID")
|
||||||
|
object.__setattr__(self, "required_capabilities", _scope_tuple(self.required_capabilities))
|
||||||
|
if self.credential_ref is not None and REFERENCE_RE.fullmatch(self.credential_ref) is None:
|
||||||
|
raise GovernanceError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CredentialGrant:
|
||||||
|
grant_id: str
|
||||||
|
credential_ref: str
|
||||||
|
scope: GovernanceScope
|
||||||
|
operations: tuple[str, ...]
|
||||||
|
task_id: str | None = None
|
||||||
|
tool_name: str | None = None
|
||||||
|
expires_at: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(self, "grant_id", _identifier(self.grant_id, "CREDENTIAL_GRANT_ID_INVALID"))
|
||||||
|
if REFERENCE_RE.fullmatch(self.credential_ref or "") is None:
|
||||||
|
raise GovernanceError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||||
|
object.__setattr__(self, "operations", _scope_tuple(self.operations))
|
||||||
|
if not self.operations:
|
||||||
|
raise GovernanceError("CREDENTIAL_OPERATIONS_REQUIRED")
|
||||||
|
if self.expires_at is not None:
|
||||||
|
parsed = datetime.fromisoformat(self.expires_at.replace("Z", "+00:00"))
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise GovernanceError("CREDENTIAL_EXPIRY_INVALID")
|
||||||
|
|
||||||
|
def authorize(
|
||||||
|
self,
|
||||||
|
actor: GovernanceActor | Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
task_id: str | None = None,
|
||||||
|
tool_name: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> str:
|
||||||
|
try:
|
||||||
|
authorize_scope(actor, self.scope)
|
||||||
|
except GovernanceError as exc:
|
||||||
|
raise GovernanceError("CREDENTIAL_SCOPE_DENY", field=exc.field) from exc
|
||||||
|
if operation not in self.operations:
|
||||||
|
raise GovernanceError("CREDENTIAL_SCOPE_DENY", field="operation")
|
||||||
|
if self.task_id is not None and self.task_id != _clean(task_id):
|
||||||
|
raise GovernanceError("CREDENTIAL_SCOPE_DENY", field="task_id")
|
||||||
|
if self.tool_name is not None and self.tool_name != _clean(tool_name):
|
||||||
|
raise GovernanceError("CREDENTIAL_SCOPE_DENY", field="tool_name")
|
||||||
|
if self.expires_at is not None:
|
||||||
|
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||||
|
expiry = datetime.fromisoformat(self.expires_at.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||||||
|
if current >= expiry:
|
||||||
|
raise GovernanceError("CREDENTIAL_SCOPE_DENY", field="expires_at")
|
||||||
|
return self.credential_ref
|
||||||
|
|
||||||
|
def public(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"grant_id": self.grant_id,
|
||||||
|
"credential_ref": self.credential_ref,
|
||||||
|
"operations": list(self.operations),
|
||||||
|
"task_id": self.task_id,
|
||||||
|
"tool_name": self.tool_name,
|
||||||
|
"expires_at": self.expires_at,
|
||||||
|
**self.scope.as_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS governance_schema_metadata (
|
||||||
|
singleton INTEGER PRIMARY KEY CHECK(singleton=1),
|
||||||
|
schema_version INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS governed_tools (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
scope_json TEXT NOT NULL,
|
||||||
|
required_capabilities_json TEXT NOT NULL,
|
||||||
|
credential_ref TEXT,
|
||||||
|
descriptor_sha256 TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS governed_trace_spans (
|
||||||
|
trace_id TEXT NOT NULL,
|
||||||
|
span_id TEXT NOT NULL,
|
||||||
|
parent_span_id TEXT,
|
||||||
|
context_json TEXT NOT NULL,
|
||||||
|
context_sha256 TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(trace_id, span_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS governed_credential_grants (
|
||||||
|
grant_id TEXT PRIMARY KEY,
|
||||||
|
grant_json TEXT NOT NULL,
|
||||||
|
grant_sha256 TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS governed_tool_invocations (
|
||||||
|
invocation_key TEXT PRIMARY KEY,
|
||||||
|
request_sha256 TEXT NOT NULL,
|
||||||
|
tool_name TEXT NOT NULL,
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
claim_id TEXT NOT NULL,
|
||||||
|
trace_id TEXT NOT NULL,
|
||||||
|
span_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
receipt_sha256 TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS governance_audit_evidence (
|
||||||
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
evidence_id TEXT NOT NULL UNIQUE,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
actor_identity TEXT NOT NULL,
|
||||||
|
trace_id TEXT,
|
||||||
|
span_id TEXT,
|
||||||
|
resource_id TEXT NOT NULL,
|
||||||
|
outcome TEXT NOT NULL,
|
||||||
|
details_json TEXT NOT NULL,
|
||||||
|
previous_sha256 TEXT NOT NULL,
|
||||||
|
evidence_sha256 TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceStore:
|
||||||
|
def __init__(self, path: str | Path) -> None:
|
||||||
|
self.path = Path(path)
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self._connection() as connection:
|
||||||
|
has_metadata = connection.execute(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='governance_schema_metadata'"
|
||||||
|
).fetchone()
|
||||||
|
if has_metadata:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT schema_version FROM governance_schema_metadata WHERE singleton=1"
|
||||||
|
).fetchone()
|
||||||
|
if row is not None and int(row[0]) != 1:
|
||||||
|
raise GovernanceError("GOVERNANCE_SCHEMA_VERSION_UNSUPPORTED")
|
||||||
|
connection.executescript(_SCHEMA)
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT schema_version FROM governance_schema_metadata WHERE singleton=1"
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO governance_schema_metadata(singleton,schema_version) VALUES(1,1)"
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
connection = sqlite3.connect(self.path, timeout=30.0)
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
connection.execute("PRAGMA foreign_keys=ON")
|
||||||
|
connection.execute("PRAGMA busy_timeout=30000")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _connection(self):
|
||||||
|
connection = self._connect()
|
||||||
|
try:
|
||||||
|
yield connection
|
||||||
|
connection.commit()
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def _audit(
|
||||||
|
self,
|
||||||
|
connection: sqlite3.Connection,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
actor: GovernanceActor,
|
||||||
|
resource_id: str,
|
||||||
|
outcome: str,
|
||||||
|
trace: TraceContext | None,
|
||||||
|
details: Mapping[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
previous = connection.execute(
|
||||||
|
"SELECT evidence_sha256 FROM governance_audit_evidence ORDER BY sequence DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
previous_sha = str(previous[0]) if previous else ""
|
||||||
|
created_at = _now()
|
||||||
|
material = {
|
||||||
|
"action": action,
|
||||||
|
"actor_identity": actor.identity,
|
||||||
|
"trace_id": trace.trace_id if trace else None,
|
||||||
|
"span_id": trace.span_id if trace else None,
|
||||||
|
"resource_id": resource_id,
|
||||||
|
"outcome": outcome,
|
||||||
|
"details": dict(details),
|
||||||
|
"previous_sha256": previous_sha,
|
||||||
|
"created_at": created_at,
|
||||||
|
}
|
||||||
|
digest = _sha(material)
|
||||||
|
evidence_id = "gev-" + digest[:32]
|
||||||
|
connection.execute(
|
||||||
|
"""INSERT INTO governance_audit_evidence
|
||||||
|
(evidence_id,action,actor_identity,trace_id,span_id,resource_id,outcome,
|
||||||
|
details_json,previous_sha256,evidence_sha256,created_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
evidence_id, action, actor.identity, material["trace_id"], material["span_id"],
|
||||||
|
resource_id, outcome, _canonical(material["details"]), previous_sha, digest, created_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {"evidence_id": evidence_id, "evidence_sha256": digest, **material}
|
||||||
|
|
||||||
|
def record_trace(self, trace: TraceContext, actor: GovernanceActor | Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
actor = GovernanceActor.from_value(actor)
|
||||||
|
authorize_scope(actor, trace.scope)
|
||||||
|
context = trace.as_dict()
|
||||||
|
digest = _sha(context)
|
||||||
|
with self._connection() as connection:
|
||||||
|
existing = connection.execute(
|
||||||
|
"SELECT context_sha256 FROM governed_trace_spans WHERE trace_id=? AND span_id=?",
|
||||||
|
(trace.trace_id, trace.span_id),
|
||||||
|
).fetchone()
|
||||||
|
if existing is not None and existing[0] != digest:
|
||||||
|
raise GovernanceError("TRACE_LINEAGE_CONFLICT")
|
||||||
|
if existing is not None:
|
||||||
|
return {"context": context, "context_sha256": digest, "idempotent": True}
|
||||||
|
if trace.parent_span_id:
|
||||||
|
parent = connection.execute(
|
||||||
|
"SELECT context_json FROM governed_trace_spans WHERE trace_id=? AND span_id=?",
|
||||||
|
(trace.trace_id, trace.parent_span_id),
|
||||||
|
).fetchone()
|
||||||
|
if parent is None:
|
||||||
|
raise GovernanceError("TRACE_LINEAGE_CONTINUITY", field="parent_span_id")
|
||||||
|
parent_context = json.loads(parent[0])
|
||||||
|
if GovernanceScope.from_value(parent_context) != trace.scope:
|
||||||
|
raise GovernanceError("TRACE_SCOPE_MISMATCH_DENY")
|
||||||
|
connection.execute(
|
||||||
|
"INSERT OR IGNORE INTO governed_trace_spans VALUES (?,?,?,?,?,?)",
|
||||||
|
(trace.trace_id, trace.span_id, trace.parent_span_id, _canonical(context), digest, _now()),
|
||||||
|
)
|
||||||
|
evidence = self._audit(
|
||||||
|
connection, action="TRACE_RECORDED", actor=actor,
|
||||||
|
resource_id=f"{trace.trace_id}:{trace.span_id}", outcome="ALLOW", trace=trace,
|
||||||
|
details={"context_sha256": digest},
|
||||||
|
)
|
||||||
|
return {"context": context, "context_sha256": digest, "evidence": evidence}
|
||||||
|
|
||||||
|
def register_tool(self, descriptor: ToolDescriptor, actor: GovernanceActor | Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
actor = GovernanceActor.from_value(actor)
|
||||||
|
if not actor.privileged:
|
||||||
|
raise GovernanceError("TOOL_MCP_AUTHZ_DENY")
|
||||||
|
public = {
|
||||||
|
"name": descriptor.name,
|
||||||
|
"kind": descriptor.kind,
|
||||||
|
"required_capabilities": list(descriptor.required_capabilities),
|
||||||
|
"credential_ref": descriptor.credential_ref,
|
||||||
|
**descriptor.scope.as_dict(),
|
||||||
|
}
|
||||||
|
digest = _sha(public)
|
||||||
|
with self._connection() as connection:
|
||||||
|
existing = connection.execute(
|
||||||
|
"SELECT descriptor_sha256 FROM governed_tools WHERE name=?", (descriptor.name,)
|
||||||
|
).fetchone()
|
||||||
|
if existing is not None and existing[0] != digest:
|
||||||
|
raise GovernanceError("TOOL_DESCRIPTOR_CONFLICT")
|
||||||
|
if existing is not None:
|
||||||
|
return {"descriptor": public, "descriptor_sha256": digest, "idempotent": True}
|
||||||
|
connection.execute(
|
||||||
|
"INSERT OR IGNORE INTO governed_tools VALUES (?,?,?,?,?,?)",
|
||||||
|
(descriptor.name, descriptor.kind, _canonical(descriptor.scope.as_dict()),
|
||||||
|
_canonical(descriptor.required_capabilities), descriptor.credential_ref, digest),
|
||||||
|
)
|
||||||
|
evidence = self._audit(
|
||||||
|
connection, action="TOOL_REGISTERED", actor=actor, resource_id=descriptor.name,
|
||||||
|
outcome="ALLOW", trace=None, details={"descriptor_sha256": digest},
|
||||||
|
)
|
||||||
|
return {"descriptor": public, "descriptor_sha256": digest, "evidence": evidence}
|
||||||
|
|
||||||
|
def authorize_tool(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
actor: GovernanceActor | Mapping[str, Any],
|
||||||
|
tool_name: str,
|
||||||
|
trace: TraceContext,
|
||||||
|
requested_scope: GovernanceScope | Mapping[str, Any],
|
||||||
|
input_digest: str,
|
||||||
|
invocation_key: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
actor = GovernanceActor.from_value(actor)
|
||||||
|
invocation_key = _identifier(invocation_key, "TOOL_INVOCATION_KEY_INVALID")
|
||||||
|
if not re.fullmatch(r"[0-9a-f]{64}", input_digest):
|
||||||
|
raise GovernanceError("TOOL_INPUT_DIGEST_INVALID")
|
||||||
|
if trace.task_id is None or trace.claim_id is None:
|
||||||
|
raise GovernanceError("TOOL_MCP_AUTHZ_DENY", field="active_claim")
|
||||||
|
requested_scope = requested_scope if isinstance(requested_scope, GovernanceScope) else GovernanceScope.from_value(requested_scope)
|
||||||
|
if requested_scope != trace.scope:
|
||||||
|
raise GovernanceError("TOOL_SCOPE_PROPAGATION")
|
||||||
|
authorize_scope(actor, requested_scope)
|
||||||
|
with self._connection() as connection:
|
||||||
|
persisted_trace = connection.execute(
|
||||||
|
"SELECT context_sha256 FROM governed_trace_spans WHERE trace_id=? AND span_id=?",
|
||||||
|
(trace.trace_id, trace.span_id),
|
||||||
|
).fetchone()
|
||||||
|
if persisted_trace is None or persisted_trace[0] != _sha(trace.as_dict()):
|
||||||
|
raise GovernanceError("TOOL_MCP_AUTHZ_DENY", field="trace")
|
||||||
|
row = connection.execute("SELECT * FROM governed_tools WHERE name=?", (tool_name,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise GovernanceError("TOOL_MCP_AUTHZ_DENY")
|
||||||
|
descriptor_scope = GovernanceScope.from_value(json.loads(row["scope_json"]))
|
||||||
|
if descriptor_scope.scope_type == "tenant" and descriptor_scope != requested_scope:
|
||||||
|
raise GovernanceError("TOOL_MCP_AUTHZ_DENY")
|
||||||
|
required = set(json.loads(row["required_capabilities_json"]))
|
||||||
|
if not required.issubset(set(actor.capabilities)):
|
||||||
|
raise GovernanceError("TOOL_MCP_AUTHZ_DENY")
|
||||||
|
request_sha = _sha({
|
||||||
|
"tool_name": tool_name, "input_digest": input_digest,
|
||||||
|
"task_id": trace.task_id, "claim_id": trace.claim_id,
|
||||||
|
"trace_id": trace.trace_id, "span_id": trace.span_id,
|
||||||
|
"scope": requested_scope.as_dict(),
|
||||||
|
})
|
||||||
|
replay = connection.execute(
|
||||||
|
"SELECT * FROM governed_tool_invocations WHERE invocation_key=?",
|
||||||
|
(invocation_key,),
|
||||||
|
).fetchone()
|
||||||
|
if replay is not None:
|
||||||
|
if replay["request_sha256"] != request_sha:
|
||||||
|
raise GovernanceError("TOOL_INVOCATION_IDEMPOTENCY_CONFLICT")
|
||||||
|
if replay["status"] != "VERIFIED":
|
||||||
|
raise GovernanceError("TOOL_INVOCATION_RECONCILIATION_REQUIRED")
|
||||||
|
return {
|
||||||
|
"tool_name": tool_name, "trace_id": trace.trace_id,
|
||||||
|
"span_id": trace.span_id, "invocation_key": invocation_key,
|
||||||
|
"status": "VERIFIED", "receipt_sha256": replay["receipt_sha256"],
|
||||||
|
"idempotent": True,
|
||||||
|
}
|
||||||
|
created_at = _now()
|
||||||
|
connection.execute(
|
||||||
|
"""INSERT INTO governed_tool_invocations
|
||||||
|
(invocation_key,request_sha256,tool_name,task_id,claim_id,trace_id,span_id,
|
||||||
|
status,receipt_sha256,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(invocation_key, request_sha, tool_name, trace.task_id, trace.claim_id,
|
||||||
|
trace.trace_id, trace.span_id, "AUTHORIZED", None, created_at, created_at),
|
||||||
|
)
|
||||||
|
evidence = self._audit(
|
||||||
|
connection, action="TOOL_AUTHORIZED", actor=actor, resource_id=tool_name,
|
||||||
|
outcome="ALLOW", trace=trace,
|
||||||
|
details={"input_sha256": input_digest, "scope_sha256": _sha(requested_scope.as_dict()),
|
||||||
|
"invocation_key": invocation_key},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"tool_name": tool_name,
|
||||||
|
"trace_id": trace.trace_id,
|
||||||
|
"span_id": trace.span_id,
|
||||||
|
"invocation_key": invocation_key,
|
||||||
|
"status": "AUTHORIZED",
|
||||||
|
"credential_required": row["credential_ref"] is not None,
|
||||||
|
"evidence": evidence,
|
||||||
|
}
|
||||||
|
|
||||||
|
def record_tool_receipt(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
invocation_key: str,
|
||||||
|
result_digest: str,
|
||||||
|
actor: GovernanceActor | Mapping[str, Any],
|
||||||
|
trace: TraceContext,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
actor = GovernanceActor.from_value(actor)
|
||||||
|
invocation_key = _identifier(invocation_key, "TOOL_INVOCATION_KEY_INVALID")
|
||||||
|
if not re.fullmatch(r"[0-9a-f]{64}", result_digest):
|
||||||
|
raise GovernanceError("TOOL_RECEIPT_DIGEST_INVALID")
|
||||||
|
authorize_scope(actor, trace.scope)
|
||||||
|
with self._connection() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT * FROM governed_tool_invocations WHERE invocation_key=?",
|
||||||
|
(invocation_key,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None or row["trace_id"] != trace.trace_id or row["span_id"] != trace.span_id:
|
||||||
|
raise GovernanceError("TOOL_RECEIPT_BINDING_INVALID")
|
||||||
|
if row["status"] == "VERIFIED":
|
||||||
|
if row["receipt_sha256"] != result_digest:
|
||||||
|
raise GovernanceError("TOOL_RECEIPT_CONFLICT")
|
||||||
|
return {"invocation_key": invocation_key, "status": "VERIFIED", "idempotent": True}
|
||||||
|
if row["status"] != "AUTHORIZED":
|
||||||
|
raise GovernanceError("TOOL_INVOCATION_RECONCILIATION_REQUIRED")
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE governed_tool_invocations SET status='VERIFIED',receipt_sha256=?,updated_at=? "
|
||||||
|
"WHERE invocation_key=? AND status='AUTHORIZED'",
|
||||||
|
(result_digest, _now(), invocation_key),
|
||||||
|
)
|
||||||
|
evidence = self._audit(
|
||||||
|
connection, action="TOOL_RESULT_VERIFIED", actor=actor,
|
||||||
|
resource_id=row["tool_name"], outcome="ALLOW", trace=trace,
|
||||||
|
details={"invocation_key": invocation_key, "result_sha256": result_digest},
|
||||||
|
)
|
||||||
|
return {"invocation_key": invocation_key, "status": "VERIFIED", "evidence": evidence}
|
||||||
|
|
||||||
|
def store_grant(self, grant: CredentialGrant, actor: GovernanceActor | Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
actor = GovernanceActor.from_value(actor)
|
||||||
|
if not actor.privileged:
|
||||||
|
raise GovernanceError("CREDENTIAL_SCOPE_DENY")
|
||||||
|
public = grant.public()
|
||||||
|
digest = _sha(public)
|
||||||
|
with self._connection() as connection:
|
||||||
|
existing = connection.execute(
|
||||||
|
"SELECT grant_sha256 FROM governed_credential_grants WHERE grant_id=?", (grant.grant_id,)
|
||||||
|
).fetchone()
|
||||||
|
if existing is not None and existing[0] != digest:
|
||||||
|
raise GovernanceError("CREDENTIAL_GRANT_CONFLICT")
|
||||||
|
connection.execute(
|
||||||
|
"INSERT OR IGNORE INTO governed_credential_grants VALUES (?,?,?,?)",
|
||||||
|
(grant.grant_id, _canonical(public), digest, _now()),
|
||||||
|
)
|
||||||
|
evidence = self._audit(
|
||||||
|
connection, action="CREDENTIAL_GRANT_STORED", actor=actor,
|
||||||
|
resource_id=grant.grant_id, outcome="ALLOW", trace=None,
|
||||||
|
details={"grant_sha256": digest},
|
||||||
|
)
|
||||||
|
return {"grant": public, "grant_sha256": digest, "evidence": evidence}
|
||||||
|
|
||||||
|
def authorize_credential(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
grant_id: str,
|
||||||
|
actor: GovernanceActor | Mapping[str, Any],
|
||||||
|
trace: TraceContext,
|
||||||
|
operation: str,
|
||||||
|
task_id: str | None,
|
||||||
|
tool_name: str | None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
actor = GovernanceActor.from_value(actor)
|
||||||
|
with self._connection() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT grant_json FROM governed_credential_grants WHERE grant_id=?", (grant_id,)
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise GovernanceError("CREDENTIAL_SCOPE_DENY")
|
||||||
|
raw = json.loads(row[0])
|
||||||
|
grant = CredentialGrant(
|
||||||
|
grant_id=raw["grant_id"], credential_ref=raw["credential_ref"],
|
||||||
|
scope=GovernanceScope.from_value(raw), operations=tuple(raw["operations"]),
|
||||||
|
task_id=raw.get("task_id"), tool_name=raw.get("tool_name"),
|
||||||
|
expires_at=raw.get("expires_at"),
|
||||||
|
)
|
||||||
|
if trace.scope != grant.scope:
|
||||||
|
raise GovernanceError("CREDENTIAL_SCOPE_DENY", field="trace_scope")
|
||||||
|
reference = grant.authorize(
|
||||||
|
actor, operation=operation, task_id=task_id, tool_name=tool_name, now=now
|
||||||
|
)
|
||||||
|
evidence = self._audit(
|
||||||
|
connection, action="CREDENTIAL_AUTHORIZED", actor=actor,
|
||||||
|
resource_id=grant_id, outcome="ALLOW", trace=trace,
|
||||||
|
details={"operation": operation, "task_id": task_id, "tool_name": tool_name,
|
||||||
|
"credential_ref_sha256": _sha(reference)},
|
||||||
|
)
|
||||||
|
return {"credential_ref": reference, "evidence": evidence}
|
||||||
|
|
||||||
|
def verify_audit_chain(self, required_actions: Iterable[str] = ()) -> bool:
|
||||||
|
ordered_actions: list[str] = []
|
||||||
|
previous = ""
|
||||||
|
with self._connection() as connection:
|
||||||
|
rows = connection.execute("SELECT * FROM governance_audit_evidence ORDER BY sequence").fetchall()
|
||||||
|
for row in rows:
|
||||||
|
details = json.loads(row["details_json"])
|
||||||
|
material = {
|
||||||
|
"action": row["action"], "actor_identity": row["actor_identity"],
|
||||||
|
"trace_id": row["trace_id"], "span_id": row["span_id"],
|
||||||
|
"resource_id": row["resource_id"], "outcome": row["outcome"],
|
||||||
|
"details": details, "previous_sha256": row["previous_sha256"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
}
|
||||||
|
if row["previous_sha256"] != previous or row["evidence_sha256"] != _sha(material):
|
||||||
|
return False
|
||||||
|
previous = row["evidence_sha256"]
|
||||||
|
ordered_actions.append(row["action"])
|
||||||
|
position = 0
|
||||||
|
for required in required_actions:
|
||||||
|
try:
|
||||||
|
position = ordered_actions.index(required, position) + 1
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CredentialGrant", "GovernanceActor", "GovernanceError", "GovernanceScope",
|
||||||
|
"GovernanceStore", "ToolDescriptor", "TraceContext", "authorize_scope",
|
||||||
|
"bind_trace_lineage",
|
||||||
|
]
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Plan-bound production adapter for releases newer than the legacy baseline.
|
|
||||||
|
|
||||||
All identity comes from the already verified executor request. The adapter has
|
|
||||||
no Git/ref/tag authority and refuses a missing candidate/release-plan binding.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
import hashlib, json, os, shutil, subprocess, sys, tempfile, urllib.error, urllib.request
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
class AdapterBlocked(RuntimeError): pass
|
|
||||||
|
|
||||||
ALLOWED_SERVICE_PORTS = {"aios-v2r2c2-production.service": 18928}
|
|
||||||
|
|
||||||
def _safe(value: str) -> str:
|
|
||||||
if not value or any(ch not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-" for ch in value):
|
|
||||||
raise AdapterBlocked("invalid_release_identity")
|
|
||||||
return value
|
|
||||||
|
|
||||||
def _run(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
||||||
result = subprocess.run(args, check=False, capture_output=True, text=True, timeout=60, env={"PATH": os.environ.get("PATH", "")})
|
|
||||||
if check and result.returncode != 0: raise AdapterBlocked(result.stderr.strip() or "system_command_failed")
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _sha(path: Path) -> str:
|
|
||||||
if path.is_symlink() or not path.is_file(): raise AdapterBlocked("artifact_must_be_regular_file")
|
|
||||||
digest=hashlib.sha256()
|
|
||||||
with path.open("rb") as stream:
|
|
||||||
for block in iter(lambda: stream.read(1024*1024), b""): digest.update(block)
|
|
||||||
return digest.hexdigest()
|
|
||||||
|
|
||||||
def _binding(value: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
required=("version","target_oid","artifact_sha256","artifact_path","artifact_size","production_service","production_port","candidate_id","release_plan_sha256")
|
|
||||||
if any(key not in value for key in required): raise AdapterBlocked("trusted_binding_incomplete")
|
|
||||||
if str(value["version"]).startswith("V2R1") or not value["candidate_id"] or not value["release_plan_sha256"]: raise AdapterBlocked("legacy_or_unbound_release_rejected")
|
|
||||||
service = str(value["production_service"])
|
|
||||||
port = int(value["production_port"])
|
|
||||||
if ALLOWED_SERVICE_PORTS.get(service) != port:
|
|
||||||
raise AdapterBlocked("production_adapter_policy_rejected")
|
|
||||||
return {key:value[key] for key in required}
|
|
||||||
|
|
||||||
def _paths(binding: Mapping[str, Any]) -> dict[str, Path]:
|
|
||||||
version=_safe(str(binding["version"])); service=_safe(str(binding["production_service"]))
|
|
||||||
root=Path(os.environ.get("AIOS_RELEASE_ROOT", "/opt/aios/releases"))
|
|
||||||
slug=version.lower()
|
|
||||||
return {"release_dir":root/(version+"-"+str(binding["target_oid"])[:12]+"-"+str(binding["artifact_sha256"])[:12]),"current_link":Path("/opt/aios")/slug,"env_file":Path("/etc/aios")/(slug+".env"),"state_file":Path("/var/lib/aios")/slug/"deployment-state.json","unit_file":Path("/etc/systemd/system")/(service+".service" if not service.endswith(".service") else service),"health_url":f"http://127.0.0.1:{int(binding['production_port'])}/health"}
|
|
||||||
|
|
||||||
def _health(url: str, binding: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(url, timeout=5) as response: body=json.loads(response.read().decode()); status=response.status
|
|
||||||
except (OSError, ValueError, urllib.error.URLError): return {"status":"UNAVAILABLE"}
|
|
||||||
exact=(status==200 and body.get("version")==binding["version"] and body.get("commit_oid")==binding["target_oid"] and body.get("artifact_sha256")==binding["artifact_sha256"] and body.get("deployment_state")=="ACTIVE" and body.get("production_deployed") is True)
|
|
||||||
return {"status":"ACTIVE_VERIFIED" if exact else "CONFLICT", "http_status":status, **body}
|
|
||||||
|
|
||||||
def _extract(artifact: Path, destination: Path) -> None:
|
|
||||||
import tarfile
|
|
||||||
with tarfile.open(artifact, "r:*") as archive:
|
|
||||||
for member in archive.getmembers():
|
|
||||||
name=Path(member.name)
|
|
||||||
if name.is_absolute() or ".." in name.parts or member.issym() or member.islnk() or not member.isfile() or not member.name.startswith("payload/"):
|
|
||||||
raise AdapterBlocked("unsafe_artifact_member")
|
|
||||||
rel=name.relative_to("payload"); target=destination/rel; target.parent.mkdir(parents=True,exist_ok=True)
|
|
||||||
source=archive.extractfile(member)
|
|
||||||
if source is None: raise AdapterBlocked("artifact_member_unreadable")
|
|
||||||
target.write_bytes(source.read()); os.chmod(target,0o755 if rel.parts and rel.parts[0]=="scripts" else 0o644)
|
|
||||||
|
|
||||||
def probe(value: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
binding=_binding(value); p=_paths(binding); active=_run("systemctl","is-active","--quiet",str(binding["production_service"]),check=False).returncode==0
|
|
||||||
return _health(str(p["health_url"]),binding) if active else {"status":"NOT_APPLIED","version":binding["version"],"target_oid":binding["target_oid"],"artifact_sha256":binding["artifact_sha256"],"production_deployed":False,"deployment_state":"ABSENT"}
|
|
||||||
|
|
||||||
def apply(value: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
binding=_binding(value); p=_paths(binding); p["release_dir"].parent.mkdir(parents=True, exist_ok=True); artifact=Path(str(binding["artifact_path"]))
|
|
||||||
if artifact.stat().st_size != int(binding["artifact_size"]) or _sha(artifact)!=binding["artifact_sha256"]: raise AdapterBlocked("artifact_binding_mismatch")
|
|
||||||
marker={"schema":"aios.trusted-runtime-release-binding.v1",**binding}
|
|
||||||
marker_path=p["release_dir"]/"DEPLOYMENT-BINDING.json"
|
|
||||||
if p["release_dir"].exists():
|
|
||||||
if not marker_path.is_file() or json.loads(marker_path.read_text())!=marker: raise AdapterBlocked("existing_release_binding_conflict")
|
|
||||||
else:
|
|
||||||
temp=Path(tempfile.mkdtemp(prefix=p["release_dir"].name+".",dir=str(p["release_dir"].parent)))
|
|
||||||
try:
|
|
||||||
_extract(artifact,temp); (temp/"DEPLOYMENT-BINDING.json").write_text(json.dumps(marker,sort_keys=True,separators=(",",":"))+"\n"); os.rename(temp,p["release_dir"])
|
|
||||||
except Exception:
|
|
||||||
shutil.rmtree(temp,ignore_errors=True); raise
|
|
||||||
version_file=p["release_dir"] / "VERSION"
|
|
||||||
if not version_file.is_file() or version_file.read_text().strip()!=binding["version"]: raise AdapterBlocked("release_version_mismatch")
|
|
||||||
unit_source=p["release_dir"] / "deploy" / "aios-v2" / "systemd" / str(binding["production_service"])
|
|
||||||
if not unit_source.is_file(): raise AdapterBlocked("release_unit_missing")
|
|
||||||
p["unit_file"].parent.mkdir(parents=True,exist_ok=True); shutil.copyfile(unit_source,p["unit_file"])
|
|
||||||
p["current_link"].parent.mkdir(parents=True,exist_ok=True)
|
|
||||||
if p["current_link"].exists() and not p["current_link"].is_symlink(): raise AdapterBlocked("current_release_path_conflict")
|
|
||||||
if not p["current_link"].is_symlink(): p["current_link"].symlink_to(p["release_dir"])
|
|
||||||
p["state_file"].parent.mkdir(parents=True,exist_ok=True); p["state_file"].write_text(json.dumps({"version":binding["version"],"commit_oid":binding["target_oid"],"artifact_sha256":binding["artifact_sha256"],"candidate_id":binding["candidate_id"],"release_plan_sha256":binding["release_plan_sha256"],"deployment_state":"ACTIVE","production_deployed":True},sort_keys=True)+"\n")
|
|
||||||
env=(f"RELEASE_VERSION={binding['version']}\n" f"TARGET_OID={binding['target_oid']}\n"
|
|
||||||
f"ARTIFACT_SHA256={binding['artifact_sha256']}\n" f"CANDIDATE_ID={binding['candidate_id']}\n"
|
|
||||||
f"RELEASE_PLAN_SHA256={binding['release_plan_sha256']}\n" f"DEPLOYMENT_STATE=ACTIVE\n"
|
|
||||||
f"DEPLOYMENT_STATE_FILE={p['state_file']}\n" f"AIOS_V2R2C2_PORT={binding['production_port']}\n"
|
|
||||||
"AIOS_GITOPS_WRITES_ENABLED=false\n")
|
|
||||||
p["env_file"].parent.mkdir(parents=True,exist_ok=True); p["env_file"].write_text(env)
|
|
||||||
_run("systemctl","daemon-reload"); _run("systemctl","enable",str(binding["production_service"])); _run("systemctl","restart",str(binding["production_service"]))
|
|
||||||
result=_health(str(p["health_url"]),binding)
|
|
||||||
if result["status"]!="ACTIVE_VERIFIED": raise AdapterBlocked("production_health_binding_mismatch")
|
|
||||||
return result
|
|
||||||
|
|
||||||
def rollback(value: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
binding=_binding(value); _run("systemctl","stop",str(binding["production_service"]),check=False); _run("systemctl","disable",str(binding["production_service"]),check=False)
|
|
||||||
p=_paths(binding); p["state_file"].parent.mkdir(parents=True,exist_ok=True); p["state_file"].write_text(json.dumps({"version":binding["version"],"commit_oid":binding["target_oid"],"artifact_sha256":binding["artifact_sha256"],"deployment_state":"ROLLED_BACK","production_deployed":False},sort_keys=True)+"\n")
|
|
||||||
return {"status":"ROLLED_BACK","version":binding["version"],"target_oid":binding["target_oid"],"artifact_sha256":binding["artifact_sha256"],"production_deployed":False,"deployment_state":"ROLLED_BACK"}
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
value=json.loads(sys.stdin.read()); operation=value.get("operation")
|
|
||||||
result=probe(value) if operation=="probe" else apply(value) if operation=="apply" else rollback(value) if operation=="rollback" else (_ for _ in ()).throw(AdapterBlocked("unsupported_operation"))
|
|
||||||
print(json.dumps(result,sort_keys=True)); return 0
|
|
||||||
|
|
||||||
if __name__=="__main__":
|
|
||||||
try: raise SystemExit(main())
|
|
||||||
except Exception as exc: print(json.dumps({"status":"FAIL_CLOSED","error":type(exc).__name__,"reason":str(exc)},sort_keys=True)); raise SystemExit(2)
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Canonical, fail-closed projection for trusted release execution plans.
|
|
||||||
|
|
||||||
A release plan is the only authority for candidate, source, artifact, policy,
|
|
||||||
rollback, and release identity. Callers may not provide executable paths or
|
|
||||||
replace individual fields after the plan has been accepted.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
import hashlib, json, re, stat
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Mapping
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
||||||
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
||||||
PLAN_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
||||||
IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{2,255}$")
|
|
||||||
TOP_LEVEL_KEYS = frozenset({"schema","version","candidate_id","source_commit","tree_oid","artifact_sha256","manifest_sha256","package_closure_sha256","deployment_plan_sha256","policy_sha256","release_plan_id","approval_plan_id","evidence_closure_oid","campaign_authorization_sha256","campaign_generation","artifact","rollback","baseline","adapter_policy","release_plan_sha256"})
|
|
||||||
FORBIDDEN_KEYS = frozenset({"command","commands","executable","exec","shell","argv","adapter_command","tag_command","deploy_command","registry_command","baseline_command","sudo","force_push","direct_main_push"})
|
|
||||||
class PlanError(ValueError):
|
|
||||||
"""A release plan is malformed, inconsistent, or unsafe."""
|
|
||||||
def canonical_json(value: object) -> bytes:
|
|
||||||
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
|
||||||
def _sha(value: Any, label: str) -> str:
|
|
||||||
if not isinstance(value, str) or not SHA_RE.fullmatch(value): raise PlanError(f"{label}_invalid")
|
|
||||||
return value
|
|
||||||
def _oid(value: Any, label: str) -> str:
|
|
||||||
if not isinstance(value, str) or not OID_RE.fullmatch(value): raise PlanError(f"{label}_invalid")
|
|
||||||
return value
|
|
||||||
def _walk_forbidden(value: Any) -> None:
|
|
||||||
if isinstance(value, Mapping):
|
|
||||||
for key, item in value.items():
|
|
||||||
if str(key).lower() in FORBIDDEN_KEYS: raise PlanError(f"forbidden_plan_key:{key}")
|
|
||||||
_walk_forbidden(item)
|
|
||||||
elif isinstance(value, list):
|
|
||||||
for item in value: _walk_forbidden(item)
|
|
||||||
def _exact_mapping(value: Any, required: set[str], label: str) -> Mapping[str, Any]:
|
|
||||||
if not isinstance(value, Mapping) or set(value) != required: raise PlanError(f"{label}_fields_invalid")
|
|
||||||
return value
|
|
||||||
def _secure_json(path: Path) -> dict[str, Any]:
|
|
||||||
try: st = path.lstat()
|
|
||||||
except OSError as exc: raise PlanError(f"plan_unreadable:{path}") from exc
|
|
||||||
if path.is_symlink() or not stat.S_ISREG(st.st_mode): raise PlanError("plan_must_be_regular_file")
|
|
||||||
if st.st_uid != 0 or st.st_mode & 0o022: raise PlanError("plan_ownership_or_mode_invalid")
|
|
||||||
try: value = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, ValueError) as exc: raise PlanError("plan_json_invalid") from exc
|
|
||||||
if not isinstance(value, dict): raise PlanError("plan_object_required")
|
|
||||||
return value
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TrustedReleasePlan:
|
|
||||||
value: Mapping[str, Any]
|
|
||||||
release_plan_sha256: str
|
|
||||||
@classmethod
|
|
||||||
def from_mapping(cls, raw: Mapping[str, Any]) -> "TrustedReleasePlan":
|
|
||||||
if not isinstance(raw, Mapping) or set(raw) - TOP_LEVEL_KEYS: raise PlanError("release_plan_fields_invalid")
|
|
||||||
required = TOP_LEVEL_KEYS - {"release_plan_sha256"}
|
|
||||||
if set(raw) not in (required, TOP_LEVEL_KEYS): raise PlanError("release_plan_fields_incomplete")
|
|
||||||
_walk_forbidden(raw)
|
|
||||||
if raw.get("schema") != "aios.trusted-release-execution-plan.v1": raise PlanError("release_plan_schema_invalid")
|
|
||||||
version = raw.get("version")
|
|
||||||
if not isinstance(version, str) or not re.fullmatch(r"V[0-9]+R[0-9]+C[0-9]+", version): raise PlanError("version_invalid")
|
|
||||||
if not isinstance(raw.get("candidate_id"), str) or not IDENTIFIER_RE.fullmatch(raw["candidate_id"]): raise PlanError("candidate_id_invalid")
|
|
||||||
for key in ("source_commit","tree_oid","evidence_closure_oid"): _oid(raw.get(key), key)
|
|
||||||
for key in ("artifact_sha256","manifest_sha256","package_closure_sha256","deployment_plan_sha256","policy_sha256","campaign_authorization_sha256"): _sha(raw.get(key), key)
|
|
||||||
for key in ("release_plan_id","approval_plan_id"):
|
|
||||||
if not isinstance(raw.get(key), str) or not PLAN_ID_RE.fullmatch(raw[key]): raise PlanError(f"{key}_invalid")
|
|
||||||
if not isinstance(raw.get("campaign_generation"), int) or raw["campaign_generation"] < 1: raise PlanError("campaign_generation_invalid")
|
|
||||||
artifact = _exact_mapping(raw.get("artifact"), {"path","size"}, "artifact")
|
|
||||||
if not isinstance(artifact["path"], str) or not artifact["path"].startswith("/") or "\\" in artifact["path"]: raise PlanError("artifact_path_invalid")
|
|
||||||
if not isinstance(artifact["size"], int) or artifact["size"] <= 0: raise PlanError("artifact_size_invalid")
|
|
||||||
rollback = _exact_mapping(raw.get("rollback"), {"version","target_oid","artifact_sha256"}, "rollback")
|
|
||||||
if not isinstance(rollback["version"], str) or not rollback["version"] or rollback["version"] == version: raise PlanError("rollback_version_invalid")
|
|
||||||
_oid(rollback["target_oid"], "rollback_target_oid"); _sha(rollback["artifact_sha256"], "rollback_artifact_sha256")
|
|
||||||
baseline = _exact_mapping(raw.get("baseline"), {"version","target_oid"}, "baseline")
|
|
||||||
if baseline["version"] != rollback["version"]: raise PlanError("baseline_rollback_version_mismatch")
|
|
||||||
_oid(baseline["target_oid"], "baseline_target_oid")
|
|
||||||
adapter = _exact_mapping(raw.get("adapter_policy"), {"policy_id","service","port"}, "adapter_policy")
|
|
||||||
if adapter["policy_id"] != "aios.production-adapter.allowlisted.v1": raise PlanError("adapter_policy_invalid")
|
|
||||||
if not isinstance(adapter["service"], str) or not IDENTIFIER_RE.fullmatch(adapter["service"]): raise PlanError("adapter_service_invalid")
|
|
||||||
if not isinstance(adapter["port"], int) or not 1024 <= adapter["port"] <= 65535: raise PlanError("adapter_port_invalid")
|
|
||||||
payload = {key: raw[key] for key in TOP_LEVEL_KEYS if key != "release_plan_sha256"}
|
|
||||||
digest = hashlib.sha256(canonical_json(payload)).hexdigest()
|
|
||||||
if raw.get("release_plan_sha256") is not None and raw["release_plan_sha256"] != digest: raise PlanError("release_plan_sha256_mismatch")
|
|
||||||
return cls(value=dict(payload), release_plan_sha256=digest)
|
|
||||||
@classmethod
|
|
||||||
def load(cls, path: Path) -> "TrustedReleasePlan": return cls.from_mapping(_secure_json(path))
|
|
||||||
@property
|
|
||||||
def candidate_binding_sha256(self) -> str:
|
|
||||||
value = {key: self.value[key] for key in ("candidate_id","source_commit","tree_oid","artifact_sha256","manifest_sha256")}
|
|
||||||
return hashlib.sha256(canonical_json(value)).hexdigest()
|
|
||||||
def as_dict(self) -> dict[str, Any]: return {**dict(self.value),"release_plan_sha256":self.release_plan_sha256,"candidate_binding_sha256":self.candidate_binding_sha256}
|
|
||||||
def assert_binding(self, binding: Mapping[str, Any]) -> None:
|
|
||||||
expected={"version":self.value["version"],"target_oid":self.value["source_commit"],"evidence_closure_oid":self.value["evidence_closure_oid"],"artifact_sha256":self.value["artifact_sha256"],"artifact_size":self.value["artifact"]["size"],"approval_plan_id":self.value["approval_plan_id"]}
|
|
||||||
for key,value in expected.items():
|
|
||||||
if binding.get(key) != value: raise PlanError(f"binding_plan_mismatch:{key}")
|
|
||||||
if binding.get("candidate_id") is not None and binding.get("candidate_id") != self.value["candidate_id"]:
|
|
||||||
raise PlanError("binding_plan_mismatch:candidate_id")
|
|
||||||
def assert_artifact(self, artifact: Mapping[str, Any]) -> None:
|
|
||||||
expected=self.value["artifact"]
|
|
||||||
if artifact.get("sha256") != self.value["artifact_sha256"] or artifact.get("size") != expected["size"]: raise PlanError("artifact_plan_mismatch")
|
|
||||||
if artifact.get("manifest_sha256") != self.value["manifest_sha256"]: raise PlanError("manifest_plan_mismatch")
|
|
||||||
if artifact.get("package_closure_sha256") != self.value["package_closure_sha256"]: raise PlanError("package_closure_plan_mismatch")
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"""V2R2C2 production health entry point; all identity is plan supplied."""
|
|
||||||
from v2r1c6_privacy_tools import main
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
providers:
|
||||||
|
oneapi:
|
||||||
|
name: oneapi
|
||||||
|
base_url: http://172.18.0.2:3000/v1
|
||||||
|
key_env: OPENAI_API_KEY
|
||||||
|
default_model: deepseek-v4-flash
|
||||||
|
model:
|
||||||
|
provider: oneapi
|
||||||
|
name: deepseek-v4-flash
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"argv": [
|
||||||
|
"/usr/local/bin/hermes",
|
||||||
|
"run",
|
||||||
|
"--non-interactive"
|
||||||
|
],
|
||||||
|
"input_mode": "stdin_text",
|
||||||
|
"max_output_bytes": 131072,
|
||||||
|
"output_mode": "stdout_text",
|
||||||
|
"runtime": "hermes",
|
||||||
|
"schema": "aios.external-agent-runtime.profile.v1",
|
||||||
|
"timeout_seconds": 900
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"argv": [
|
||||||
|
"/opt/aios-hermes-agent/hermes-agent/.venv/bin/hermes",
|
||||||
|
"-z",
|
||||||
|
"{prompt}",
|
||||||
|
"--provider",
|
||||||
|
"oneapi",
|
||||||
|
"-m",
|
||||||
|
"{model_id}",
|
||||||
|
"--no-restore-cwd"
|
||||||
|
],
|
||||||
|
"credential_env": {
|
||||||
|
"OPENAI_API_KEY": {
|
||||||
|
"credential": "aios-external-agent-oneapi-env",
|
||||||
|
"format": "dotenv",
|
||||||
|
"key": "ONEAPI_REGULAR_TOKEN"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"HERMES_HOME": "/etc/aios/external-agent-runtime.d/hermes-home"
|
||||||
|
},
|
||||||
|
"input_mode": "stdin_text",
|
||||||
|
"max_output_bytes": 131072,
|
||||||
|
"model_governance": {
|
||||||
|
"max_output_tokens": 512,
|
||||||
|
"model_capabilities": [
|
||||||
|
"text"
|
||||||
|
],
|
||||||
|
"provider_ids": [
|
||||||
|
"oneapi"
|
||||||
|
],
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"output_mode": "stdout_text",
|
||||||
|
"runtime": "hermes",
|
||||||
|
"schema": "aios.external-agent-runtime.profile.v1",
|
||||||
|
"timeout_seconds": 180
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"profile": "minimal"
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"search": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": "/var/lib/aios-external-agent-executor/openclaw-workspace",
|
||||||
|
"model": {
|
||||||
|
"primary": "openai/deepseek-v4-flash"
|
||||||
|
},
|
||||||
|
"thinkingDefault": "off"
|
||||||
|
},
|
||||||
|
"entries": {
|
||||||
|
"main": {
|
||||||
|
"default": true,
|
||||||
|
"workspace": "/var/lib/aios-external-agent-executor/openclaw-workspace"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"providers": {
|
||||||
|
"openai": {
|
||||||
|
"baseUrl": "http://172.18.0.2:3000/v1",
|
||||||
|
"api": "openai-completions",
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"id": "deepseek-v4-flash",
|
||||||
|
"name": "deepseek-v4-flash",
|
||||||
|
"contextWindow": 32768,
|
||||||
|
"maxTokens": 256
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"argv": [
|
||||||
|
"/usr/local/bin/openclaw",
|
||||||
|
"run",
|
||||||
|
"--non-interactive"
|
||||||
|
],
|
||||||
|
"input_mode": "stdin_text",
|
||||||
|
"max_output_bytes": 131072,
|
||||||
|
"output_mode": "stdout_text",
|
||||||
|
"runtime": "openclaw",
|
||||||
|
"schema": "aios.external-agent-runtime.profile.v1",
|
||||||
|
"timeout_seconds": 900
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"argv": [
|
||||||
|
"/usr/bin/node",
|
||||||
|
"/opt/aios/openclaw-engineering/v2026.7.2-beta.7/openclaw.mjs",
|
||||||
|
"agent",
|
||||||
|
"--local",
|
||||||
|
"--json",
|
||||||
|
"--agent",
|
||||||
|
"main",
|
||||||
|
"--thinking",
|
||||||
|
"off",
|
||||||
|
"--message",
|
||||||
|
"{prompt}",
|
||||||
|
"--model",
|
||||||
|
"{model_id}",
|
||||||
|
"--timeout",
|
||||||
|
"180"
|
||||||
|
],
|
||||||
|
"credential_env": {
|
||||||
|
"OPENAI_API_KEY": {
|
||||||
|
"credential": "aios-external-agent-oneapi-env",
|
||||||
|
"format": "dotenv",
|
||||||
|
"key": "ONEAPI_REGULAR_TOKEN"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"OPENCLAW_CONFIG_PATH": "/etc/aios/external-agent-runtime.d/openclaw-config.json",
|
||||||
|
"OPENCLAW_HOME": "/var/lib/aios-external-agent-executor/openclaw-home",
|
||||||
|
"OPENCLAW_STATE_DIR": "/var/lib/aios-external-agent-executor/openclaw-state"
|
||||||
|
},
|
||||||
|
"input_mode": "stdin_text",
|
||||||
|
"max_output_bytes": 131072,
|
||||||
|
"model_governance": {
|
||||||
|
"input_reservation_floor_tokens": 16384,
|
||||||
|
"max_output_tokens": 256,
|
||||||
|
"model_capabilities": [
|
||||||
|
"text"
|
||||||
|
],
|
||||||
|
"provider_ids": [
|
||||||
|
"openai"
|
||||||
|
],
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"output_mode": "stdout_json",
|
||||||
|
"runtime": "openclaw",
|
||||||
|
"schema": "aios.external-agent-runtime.profile.v1",
|
||||||
|
"timeout_seconds": 240
|
||||||
|
}
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
{
|
|
||||||
"allowed_peer_cgroups": [
|
|
||||||
"/system.slice/aios-gitops-model-api-v2r2c1-staging.service"
|
|
||||||
],
|
|
||||||
"allowed_peer_executable_prefix": "/usr/bin/python3",
|
|
||||||
"allowed_peer_runtime_prefix": "/opt/aios/gitops-model-api/",
|
|
||||||
"allowed_peer_runtime_suffix": "/runtime/app/aios_model_api_staging.py",
|
|
||||||
"allowed_peer_uid": 1000,
|
|
||||||
"evidence_layout": {
|
|
||||||
"artifact": "{version}/artifact.tar.gz",
|
|
||||||
"canary": "{version}/canary.json",
|
|
||||||
"gitea": "{version}/gitea.json",
|
|
||||||
"manifest": "{version}/manifest.json",
|
|
||||||
"package_closure": "{version}/checksums.sha256",
|
|
||||||
"policy": "{version}/trusted-release-policy.json",
|
|
||||||
"rollback": "{version}/rollback.json",
|
|
||||||
"secret_scan": "{version}/secret-scan.json",
|
|
||||||
"tests": "{version}/tests.json"
|
|
||||||
},
|
|
||||||
"governed_recipes": {},
|
|
||||||
"maximum_request_age_seconds": 60,
|
|
||||||
"operation": "BuildTrustedCandidateV1",
|
|
||||||
"readiness_operation": "ProbeTrustedCandidateBuilderV1",
|
|
||||||
"recipes": {
|
|
||||||
"V2R2C2": {
|
|
||||||
"base_oid": "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6",
|
|
||||||
"candidate_id": "rc-v2r2c2-d2b5a8339a34",
|
|
||||||
"mode": "fixed",
|
|
||||||
"purpose": "AIOS_TRUSTED_RELEASE_BUILDER",
|
|
||||||
"source_commit": "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6",
|
|
||||||
"task_id": "v12-durable-trusted-release-controller-activation-20260829",
|
|
||||||
"version": "V2R2C2"
|
|
||||||
},
|
|
||||||
"V2R2C3": {
|
|
||||||
"derives_from": "V2R2C2",
|
|
||||||
"mode": "governed-derived-only",
|
|
||||||
"purpose": "AIOS_TRUSTED_RELEASE_BUILDER",
|
|
||||||
"version": "V2R2C3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"request_schema": "aios.trusted-candidate-builder.request.v1",
|
|
||||||
"request_timeout_seconds": 30,
|
|
||||||
"required_checks": [
|
|
||||||
"authoritative_git",
|
|
||||||
"artifact",
|
|
||||||
"canary",
|
|
||||||
"gitea",
|
|
||||||
"manifest",
|
|
||||||
"package_closure",
|
|
||||||
"rollback",
|
|
||||||
"secret_scan",
|
|
||||||
"tests"
|
|
||||||
],
|
|
||||||
"response_schema": "aios.trusted-candidate-builder.response.v1",
|
|
||||||
"schema": "aios.trusted-candidate-builder-policy.v1"
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"authoritative_git_dirs": [
|
|
||||||
"/data/aios-gitops/repo.git"
|
|
||||||
],
|
|
||||||
"cutover_links": [
|
|
||||||
"/opt/aios/v2r2c2"
|
|
||||||
],
|
|
||||||
"dropin_paths": [
|
|
||||||
"/var/lib/aios-gitops/release-only/v2r2c2-d2b5a8339a34/payload/deploy/aios-v2/systemd/aios-v2r2c2-production.service"
|
|
||||||
],
|
|
||||||
"release_roots": [
|
|
||||||
"/var/lib/aios-gitops/release-only"
|
|
||||||
],
|
|
||||||
"repository_identities": [
|
|
||||||
"aios/aios"
|
|
||||||
],
|
|
||||||
"services": [
|
|
||||||
"aios-v2r2c2-production.service"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"capabilities": [
|
||||||
|
"agent.runtime.hermes",
|
||||||
|
"text"
|
||||||
|
],
|
||||||
|
"enabled": true,
|
||||||
|
"evaluation_sha256": "43ae0db1640daeb6d33e2787769732844f366d36070065d3a2239905bd9a823b",
|
||||||
|
"evaluation_state": "APPROVED",
|
||||||
|
"input_cost_micros_per_1k": 0,
|
||||||
|
"max_input_tokens": 32768,
|
||||||
|
"max_output_tokens": 512,
|
||||||
|
"model_id": "deepseek-v4-flash",
|
||||||
|
"output_cost_micros_per_1k": 0,
|
||||||
|
"priority": 10,
|
||||||
|
"provider_id": "oneapi",
|
||||||
|
"version": "profile-sha:54615e533bfdb6a2e8b870ae74bb8cd7fc45177495bcf6e8c45a50837cefb85a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"capabilities": [
|
||||||
|
"agent.runtime.openclaw",
|
||||||
|
"text"
|
||||||
|
],
|
||||||
|
"enabled": true,
|
||||||
|
"evaluation_sha256": "7b1c89634eb9c0e7cb5a3d75323d904ebbd91b3ae5494ce938bd8316a4cf5b12",
|
||||||
|
"evaluation_state": "APPROVED",
|
||||||
|
"input_cost_micros_per_1k": 0,
|
||||||
|
"max_input_tokens": 32768,
|
||||||
|
"max_output_tokens": 256,
|
||||||
|
"model_id": "openai/deepseek-v4-flash",
|
||||||
|
"output_cost_micros_per_1k": 0,
|
||||||
|
"priority": 10,
|
||||||
|
"provider_id": "openai",
|
||||||
|
"version": "profile-sha:2a31c480226aa78d44ed9692bddcedc99c3aa03c23c0dd5f031a5cbbb8bbd7ba"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"policies": [
|
||||||
|
{
|
||||||
|
"allowed_models": [
|
||||||
|
"deepseek-v4-flash",
|
||||||
|
"openai/deepseek-v4-flash"
|
||||||
|
],
|
||||||
|
"daily_cost_budget_micros": 0,
|
||||||
|
"daily_token_budget": 250000,
|
||||||
|
"denied_providers": [],
|
||||||
|
"fallback_limit": 1,
|
||||||
|
"max_input_tokens_per_request": 16384,
|
||||||
|
"max_output_tokens_per_request": 512,
|
||||||
|
"project_id": null,
|
||||||
|
"required_capabilities": [
|
||||||
|
"text"
|
||||||
|
],
|
||||||
|
"scope_type": "legacy",
|
||||||
|
"tenant_id": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allowed_models": [
|
||||||
|
"deepseek-v4-flash",
|
||||||
|
"openai/deepseek-v4-flash"
|
||||||
|
],
|
||||||
|
"daily_cost_budget_micros": 0,
|
||||||
|
"daily_token_budget": 250000,
|
||||||
|
"denied_providers": [],
|
||||||
|
"fallback_limit": 1,
|
||||||
|
"max_input_tokens_per_request": 16384,
|
||||||
|
"max_output_tokens_per_request": 512,
|
||||||
|
"project_id": "staging",
|
||||||
|
"required_capabilities": [
|
||||||
|
"text"
|
||||||
|
],
|
||||||
|
"scope_type": "tenant",
|
||||||
|
"tenant_id": "model-governance-live"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"providers": [
|
||||||
|
{
|
||||||
|
"cooldown_seconds": 60,
|
||||||
|
"credential_ref": "runtime-profile:hermes",
|
||||||
|
"enabled": true,
|
||||||
|
"failure_domain": "oneapi",
|
||||||
|
"max_failures": 2,
|
||||||
|
"provider_id": "oneapi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cooldown_seconds": 60,
|
||||||
|
"credential_ref": "runtime-profile:openclaw",
|
||||||
|
"enabled": true,
|
||||||
|
"failure_domain": "oneapi",
|
||||||
|
"max_failures": 2,
|
||||||
|
"provider_id": "openai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"schema": "aios.model-governance.bootstrap.v1"
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- R4.2 P0-B durable Message/Handoff Fabric.
|
||||||
|
-- Forward-only. The existing 20260707 message_outbox remains a compatibility source.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS message_handoff (
|
||||||
|
envelope_id TEXT PRIMARY KEY,
|
||||||
|
stream_key TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
message_type TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
payload_sha256 TEXT NOT NULL,
|
||||||
|
tenant_id TEXT,
|
||||||
|
user_id TEXT,
|
||||||
|
project_id TEXT,
|
||||||
|
conversation_id TEXT,
|
||||||
|
task_id TEXT,
|
||||||
|
run_id TEXT,
|
||||||
|
causation_id TEXT,
|
||||||
|
sequence INTEGER NOT NULL CHECK(sequence >= 1),
|
||||||
|
capability_scope_json TEXT NOT NULL,
|
||||||
|
confidentiality_scope TEXT NOT NULL,
|
||||||
|
idempotency_key TEXT NOT NULL,
|
||||||
|
expires_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'PENDING',
|
||||||
|
delivered_at TEXT,
|
||||||
|
acked_at TEXT,
|
||||||
|
UNIQUE(stream_key, idempotency_key),
|
||||||
|
UNIQUE(stream_key, sequence)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_message_handoff_target_status
|
||||||
|
ON message_handoff(target, status, created_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_message_handoff_scope
|
||||||
|
ON message_handoff(tenant_id, project_id, user_id, conversation_id, run_id);
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=AIOS V2R2C2 governed trusted release health service
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=aios-v2r1c7
|
|
||||||
Group=aios-v2r1c7
|
|
||||||
WorkingDirectory=/opt/aios/v2r2c2
|
|
||||||
EnvironmentFile=/etc/aios/v2r2c2.env
|
|
||||||
ExecStart=/usr/bin/python3 /opt/aios/v2r2c2/active-runtime/app/v2r2c2_health_service.py --serve --host 127.0.0.1 --port ${AIOS_V2R2C2_PORT}
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
ProtectHome=true
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectKernelLogs=true
|
|
||||||
ProtectKernelModules=true
|
|
||||||
ProtectKernelTunables=true
|
|
||||||
ProtectControlGroups=true
|
|
||||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
|
||||||
IPAddressDeny=any
|
|
||||||
IPAddressAllow=localhost
|
|
||||||
UMask=0077
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
+7
-7
@@ -1,20 +1,20 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=AIOS owner-controlled Task initial execution projection API
|
Description=AIOS owner-controlled Task execution-rebind API
|
||||||
After=aios-agent-task-api.service aios-chatgpt-bridge-v2-staging.service
|
After=aios-agent-task-api.service
|
||||||
Wants=aios-agent-task-api.service aios-chatgpt-bridge-v2-staging.service
|
Wants=aios-agent-task-api.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=aios-agent-task
|
User=aios-agent-task
|
||||||
Group=aios-agent-task
|
Group=aios-agent-task
|
||||||
LoadCredential=agent_task_control_token:/etc/aios-gitops/agent-task-control.token
|
LoadCredential=agent_task_control_token:/etc/aios-gitops/agent-task-control.token
|
||||||
|
EnvironmentFile=/etc/aios-gitops/agent-task-execution-rebind.env
|
||||||
Environment=AIOS_AGENT_TASK_DB=/var/lib/aios-agent-task/state.sqlite3
|
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_AGENT_TASK_CONTROL_TOKEN_FILE=%d/agent_task_control_token
|
||||||
Environment=AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892
|
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_EXECUTION_REBIND_HOST=127.0.0.1
|
||||||
Environment=AIOS_TASK_INITIAL_EXECUTION_PROJECTION_HOST=127.0.0.1
|
Environment=AIOS_TASK_EXECUTION_REBIND_PORT=18942
|
||||||
Environment=AIOS_TASK_INITIAL_EXECUTION_PROJECTION_PORT=18943
|
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_agent_task_execution_rebind.py
|
||||||
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_agent_task_initial_execution_projection.py
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AIOS owner-controlled Task retry-extension API
|
||||||
|
After=aios-agent-task-api.service
|
||||||
|
Wants=aios-agent-task-api.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=aios-agent-task
|
||||||
|
Group=aios-agent-task
|
||||||
|
LoadCredential=agent_task_control_token:/etc/aios-gitops/agent-task-control.token
|
||||||
|
EnvironmentFile=/etc/aios-gitops/agent-task-retry-extension.env
|
||||||
|
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_TASK_RETRY_HOST=127.0.0.1
|
||||||
|
Environment=AIOS_TASK_RETRY_PORT=18941
|
||||||
|
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_agent_task_retry_extension.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
LockPersonality=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
RestrictNamespaces=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||||
|
ReadWritePaths=/var/lib/aios-agent-task
|
||||||
|
UMask=0077
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -15,9 +15,17 @@ ExecStart=/usr/bin/python3 /usr/libexec/aios-approved-release-executor/current/a
|
|||||||
--authorization ${AIOS_RELEASE_AUTHORIZATION} \
|
--authorization ${AIOS_RELEASE_AUTHORIZATION} \
|
||||||
--repository ${AIOS_GITOPS_AUTHORITATIVE_GIT_DIR} \
|
--repository ${AIOS_GITOPS_AUTHORITATIVE_GIT_DIR} \
|
||||||
--state-root ${AIOS_RELEASE_EXECUTION_STATE_ROOT} \
|
--state-root ${AIOS_RELEASE_EXECUTION_STATE_ROOT} \
|
||||||
--release-plan ${AIOS_RELEASE_PLAN} \
|
--artifact ${AIOS_RELEASE_ARTIFACT} \
|
||||||
|
--artifact-size ${AIOS_RELEASE_ARTIFACT_SIZE} \
|
||||||
|
--version ${AIOS_RELEASE_VERSION} \
|
||||||
|
--target-oid ${AIOS_RELEASE_TARGET_OID} \
|
||||||
|
--evidence-closure-oid ${AIOS_RELEASE_EVIDENCE_CLOSURE_OID} \
|
||||||
|
--artifact-sha256 ${AIOS_RELEASE_ARTIFACT_SHA256} \
|
||||||
|
--approval-plan-id ${AIOS_RELEASE_APPROVAL_PLAN_ID} \
|
||||||
|
--production-service aios-v2r1c7-production.service \
|
||||||
|
--production-port 18927 \
|
||||||
--tag-adapter /usr/bin/python3 /usr/libexec/aios-approved-release-executor/current/active-runtime/app/governed_tag_adapter.py \
|
--tag-adapter /usr/bin/python3 /usr/libexec/aios-approved-release-executor/current/active-runtime/app/governed_tag_adapter.py \
|
||||||
--deploy-adapter /usr/bin/python3 /usr/libexec/aios-approved-release-executor/current/active-runtime/app/trusted_production_adapter.py \
|
--deploy-adapter /usr/bin/python3 /usr/libexec/aios-approved-release-executor/current/active-runtime/app/v2r1c7_production_adapter.py \
|
||||||
--registry-adapter /usr/bin/env AIOS_RELEASE_METADATA_MODE=registry /usr/bin/python3 /usr/libexec/aios-approved-release-executor/current/active-runtime/app/release_metadata_adapter.py \
|
--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
|
--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
|
NoNewPrivileges=true
|
||||||
|
|||||||
@@ -4,32 +4,19 @@ After=network-online.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=aiman
|
User=aios-bridge-staging
|
||||||
Group=gitops
|
Group=aios-bridge-staging
|
||||||
PermissionsStartOnly=true
|
WorkingDirectory=/opt/aios/gitops-control-plane/current
|
||||||
WorkingDirectory=@RUNTIME_ROOT@
|
|
||||||
Environment=AIOS_BRIDGE_SCHEMA_VERSION=aios.chatgpt-gitops-bridge.v2
|
Environment=AIOS_BRIDGE_SCHEMA_VERSION=aios.chatgpt-gitops-bridge.v2
|
||||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||||
Environment=AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED=true
|
Environment=AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED=true
|
||||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
|
||||||
Environment=AIOS_GITEA_API_BASE=https://g.cnnetmkt.top/git/api/v1
|
|
||||||
Environment=AIOS_GITEA_TOKEN_FILE=/run/credentials/aios-chatgpt-bridge-v2-staging.service/gitea_token
|
|
||||||
Environment=AIOS_GITEA_REMOTE=gitea-http
|
|
||||||
Environment=AIOS_GITOPS_BASELINE_AUTHORITY=/var/lib/aios-gitops/model-api-staging/baseline/current.json
|
|
||||||
Environment=AIOS_CONTROL_PLANE_TARGET_VERSION=V2R2C1
|
|
||||||
Environment=AIOS_CONTROL_PLANE_TARGET_OID=@SOURCE_OID@
|
|
||||||
Environment=AIOS_CONTROL_PLANE_ARTIFACT_SHA256=@ARTIFACT_SHA256@
|
|
||||||
Environment=PYTHONPATH=@RUNTIME_ROOT@:@RUNTIME_ROOT@/app:@RUNTIME_ROOT@/runtime-agent-os
|
|
||||||
Environment=AIOS_DEPLOYMENT_STATE=STAGING
|
Environment=AIOS_DEPLOYMENT_STATE=STAGING
|
||||||
LoadCredential=gitea_token:/run/credentials/aios-governance-executor.service/gitea-governance-executor-token
|
ExecStart=/usr/bin/python3 /opt/aios/gitops-control-plane/current/bridge-v2-staging-server.py
|
||||||
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
|
NoNewPrivileges=true
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=true
|
ProtectHome=true
|
||||||
ReadWritePaths=/var/lib/aios-gitops/bridge-v2-staging /data/aios-gitops/repo.git /data/aios-gitops/repo.git/worktrees
|
ReadWritePaths=/var/lib/aios-gitops/bridge-v2-staging
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AIOS credential-isolated external Agent runtime executor
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=aios-engineering-executor
|
||||||
|
Group=aios-executor-exchange
|
||||||
|
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_external_agent_runtime.py --root /var/lib/aios-external-agent-executor --profile-root /etc/aios/external-agent-runtime.d
|
||||||
|
Restart=always
|
||||||
|
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 AF_INET6
|
||||||
|
IPAddressDeny=127.0.0.0/8
|
||||||
|
IPAddressDeny=::1/128
|
||||||
|
IPAddressAllow=172.18.0.2/32
|
||||||
|
LoadCredential=aios-external-agent-oneapi-env:/data/agent/secrets/oneapi-model-ready.env
|
||||||
|
InaccessiblePaths=/etc/aios-gitops
|
||||||
|
InaccessiblePaths=/data/aios-gitops
|
||||||
|
InaccessiblePaths=/data/agent/secrets
|
||||||
|
ReadOnlyPaths=/etc/aios/external-agent-runtime.d
|
||||||
|
ReadOnlyPaths=/opt/aios-hermes-agent
|
||||||
|
ReadOnlyPaths=/opt/aios/openclaw-engineering
|
||||||
|
Environment=AIOS_MODEL_GOVERNANCE_DB=/var/lib/aios-model-governance/state.sqlite3
|
||||||
|
Environment=AIOS_MODEL_GOVERNANCE_ENABLED=1
|
||||||
|
ReadWritePaths=/var/lib/aios-external-agent-executor
|
||||||
|
ReadWritePaths=/var/lib/aios-model-governance
|
||||||
|
UMask=0027
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# Non-secret activation template for a verified immutable model-api package.
|
|
||||||
# Install only through the governed runtime activation procedure.
|
|
||||||
[Service]
|
|
||||||
Environment=AIOS_GITOPS_WORKSPACE=/opt/aios/gitops-model-api/<VERIFIED_PACKAGE_OID>/runtime
|
|
||||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_ENABLED=true
|
|
||||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_SOCKET=/run/aios-trusted-candidate-builder.sock
|
|
||||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_POLICY=/opt/aios/gitops-model-api/<VERIFIED_PACKAGE_OID>/runtime/config/gitops/trusted-candidate-builder-policy.json
|
|
||||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_PUBLIC_KEYRING=/etc/aios-gitops/trusted-builder/public-keyring.json
|
|
||||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_CREDENTIALS_DIRECTORY=%d
|
|
||||||
Environment=AIOS_SILENT_RELEASE_TRUSTED_POLICY=/opt/aios/gitops-model-api/<VERIFIED_PACKAGE_OID>/runtime/config/gitops/trusted-release-policy.json
|
|
||||||
LoadCredential=builder_hmac:/etc/aios-gitops/credential-references/trusted-candidate-builder-hmac
|
|
||||||
# The credential source above is a reference only; no secret value belongs here.
|
|
||||||
# Keep execution shadow-disabled and do not add Owner/deploy/tag/baseline switches.
|
|
||||||
Environment=AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AIOS disabled-by-default HTTPS edge for GPT read-only Online Task ingress
|
||||||
|
After=network-online.target aios-gpt-online-task-ingress.service
|
||||||
|
Wants=network-online.target aios-gpt-online-task-ingress.service
|
||||||
|
ConditionPathExists=/etc/aios-gitops/gpt-https-edge.approved
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
DynamicUser=yes
|
||||||
|
LoadCredential=tls_cert:/etc/aios-gitops/gpt-https-edge-tls.crt
|
||||||
|
LoadCredential=tls_key:/etc/aios-gitops/gpt-https-edge-tls.key
|
||||||
|
Environment=AIOS_GPT_EDGE_TLS_CERT_FILE=%d/tls_cert
|
||||||
|
Environment=AIOS_GPT_EDGE_TLS_KEY_FILE=%d/tls_key
|
||||||
|
Environment=AIOS_GPT_EDGE_HOST=127.0.0.1
|
||||||
|
Environment=AIOS_GPT_EDGE_PORT=18443
|
||||||
|
Environment=AIOS_GPT_EDGE_PUBLIC_BIND_APPROVED=0
|
||||||
|
Environment=AIOS_GPT_EDGE_PUBLIC_DOMAIN=
|
||||||
|
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_gpt_https_edge.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
PrivateDevices=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
ProtectKernelLogs=yes
|
||||||
|
ProtectClock=yes
|
||||||
|
LockPersonality=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
RestrictRealtime=yes
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
MemoryDenyWriteExecute=yes
|
||||||
|
RestrictAddressFamilies=AF_INET AF_INET6
|
||||||
|
InaccessiblePaths=/data/aios-gitops /etc/aios-gitops /home /var/lib/aios-agent-task
|
||||||
|
UMask=0077
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AIOS external GPT read-only Online Task ingress
|
||||||
|
After=network.target aios-chatgpt-bridge-v2-staging.service aios-agent-task-api.service
|
||||||
|
Wants=aios-chatgpt-bridge-v2-staging.service aios-agent-task-api.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
DynamicUser=yes
|
||||||
|
StateDirectory=aios-gpt-online-task-ingress
|
||||||
|
StateDirectoryMode=0750
|
||||||
|
LoadCredential=gpt_ingress_token:/etc/aios-gitops/gpt-online-task-ingress.token
|
||||||
|
LoadCredential=agent_task_control_token:/etc/aios-gitops/agent-task-control.token
|
||||||
|
Environment=AIOS_GPT_INGRESS_TOKEN_FILE=%d/gpt_ingress_token
|
||||||
|
Environment=AIOS_AGENT_TASK_CONTROL_TOKEN_FILE=%d/agent_task_control_token
|
||||||
|
Environment=AIOS_GPT_INGRESS_STATE_ROOT=%S/aios-gpt-online-task-ingress
|
||||||
|
Environment=AIOS_GPT_INGRESS_HOST=127.0.0.1
|
||||||
|
Environment=AIOS_GPT_INGRESS_PORT=18945
|
||||||
|
Environment=AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892
|
||||||
|
Environment=AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940
|
||||||
|
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_gpt_online_task_ingress.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
PrivateDevices=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
ProtectKernelLogs=yes
|
||||||
|
ProtectClock=yes
|
||||||
|
LockPersonality=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
RestrictRealtime=yes
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
MemoryDenyWriteExecute=yes
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||||
|
IPAddressDeny=any
|
||||||
|
IPAddressAllow=127.0.0.0/8
|
||||||
|
InaccessiblePaths=/data/aios-gitops /etc/aios-gitops /home
|
||||||
|
UMask=0077
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AIOS Hermes Online Agent Worker
|
||||||
|
After=aios-agent-task-api.service aios-chatgpt-bridge-v2-staging.service aios-external-agent-executor.service
|
||||||
|
Wants=aios-agent-task-api.service aios-chatgpt-bridge-v2-staging.service aios-external-agent-executor.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=aios-engineering-worker
|
||||||
|
Group=aios-engineering-worker
|
||||||
|
SupplementaryGroups=gitops aios-executor-exchange
|
||||||
|
LoadCredentialEncrypted=agent_token:/etc/credstore.encrypted/aios-online-agent-worker-20260815/agent_token
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
Environment=AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940
|
||||||
|
Environment=AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892
|
||||||
|
Environment=AIOS_AGENT_ID=online-hermes-worker-cn-network-mkt
|
||||||
|
Environment=AIOS_AGENT_TYPE=external-agent-runtime-worker
|
||||||
|
Environment=AIOS_AGENT_VERSION=2026-08-18.1-hermes-runtime
|
||||||
|
Environment=AIOS_WORKER_CAPABILITIES=agent.runtime.hermes
|
||||||
|
Environment=AIOS_WORKER_ALLOWED_ADAPTERS=hermes
|
||||||
|
Environment=AIOS_AUTHORITY_GIT_DIR=/data/aios-gitops/repo.git
|
||||||
|
Environment=AIOS_EXTERNAL_AGENT_EXECUTOR_ROOT=/var/lib/aios-external-agent-executor
|
||||||
|
Environment=AIOS_WORKER_STATE_ROOT=/var/lib/aios-online-agent-hermes
|
||||||
|
Environment=AIOS_WORKER_CHECKPOINT_DB=/var/lib/aios-online-agent-hermes/checkpoints.sqlite3
|
||||||
|
Environment=AIOS_WORKER_WORKSPACE_ROOT=/var/lib/aios-online-agent-hermes/workspaces
|
||||||
|
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_online_agent_worker.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
TimeoutStopSec=20
|
||||||
|
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 AF_INET6
|
||||||
|
ReadOnlyPaths=/data/aios-gitops/repo.git
|
||||||
|
ReadWritePaths=/var/lib/aios-online-agent-hermes
|
||||||
|
ReadWritePaths=/var/lib/aios-external-agent-executor
|
||||||
|
UMask=0027
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AIOS uncredentialed deterministic Planner/Executor
|
||||||
|
After=local-fs.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=aios-engineering-executor
|
||||||
|
Group=aios-executor-exchange
|
||||||
|
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_isolated_executor.py --root /var/lib/aios-online-agent-executor
|
||||||
|
Restart=always
|
||||||
|
RestartSec=2
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
PrivateNetwork=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
|
||||||
|
IPAddressDeny=any
|
||||||
|
IPAddressAllow=localhost
|
||||||
|
InaccessiblePaths=/run/credentials
|
||||||
|
ReadWritePaths=/var/lib/aios-online-agent-executor
|
||||||
|
UMask=0027
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AIOS governed Online Agent Worker
|
||||||
|
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-engineering-worker
|
||||||
|
Group=aios-engineering-worker
|
||||||
|
SupplementaryGroups=gitops aios-executor-exchange
|
||||||
|
LoadCredentialEncrypted=agent_token:/etc/credstore.encrypted/aios-online-agent-worker-20260815/agent_token
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
Environment=AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940
|
||||||
|
Environment=AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892
|
||||||
|
Environment=AIOS_AGENT_ID=online-engineering-worker
|
||||||
|
Environment=AIOS_AGENT_TYPE=trusted-online-engineering-worker
|
||||||
|
Environment=AIOS_AGENT_VERSION=2026-08-17.2-durable
|
||||||
|
Environment=AIOS_WORKER_CAPABILITIES=git.read,git.test,governed.write.branch,governed.write.file
|
||||||
|
Environment=AIOS_AUTHORITY_GIT_DIR=/data/aios-gitops/repo.git
|
||||||
|
Environment=AIOS_ISOLATED_EXECUTOR_ROOT=/var/lib/aios-online-agent-executor
|
||||||
|
Environment=AIOS_WORKER_STATE_ROOT=/var/lib/aios-online-agent-worker
|
||||||
|
Environment=AIOS_WORKER_CHECKPOINT_DB=/var/lib/aios-online-agent-worker/checkpoints.sqlite3
|
||||||
|
Environment=AIOS_WORKER_WORKSPACE_ROOT=/var/lib/aios-online-agent-worker/workspaces
|
||||||
|
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_online_agent_worker.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
TimeoutStopSec=20
|
||||||
|
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 AF_INET6
|
||||||
|
ReadOnlyPaths=/data/aios-gitops/repo.git
|
||||||
|
ReadWritePaths=/var/lib/aios-online-agent-worker
|
||||||
|
UMask=0027
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AIOS OpenClaw Online Agent Worker
|
||||||
|
After=aios-agent-task-api.service aios-chatgpt-bridge-v2-staging.service aios-external-agent-executor.service
|
||||||
|
Wants=aios-agent-task-api.service aios-chatgpt-bridge-v2-staging.service aios-external-agent-executor.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=aios-engineering-worker
|
||||||
|
Group=aios-engineering-worker
|
||||||
|
SupplementaryGroups=gitops aios-executor-exchange
|
||||||
|
LoadCredentialEncrypted=agent_token:/etc/credstore.encrypted/aios-online-agent-worker-20260815/agent_token
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
Environment=AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940
|
||||||
|
Environment=AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892
|
||||||
|
Environment=AIOS_AGENT_ID=online-openclaw-worker-cn-network-mkt
|
||||||
|
Environment=AIOS_AGENT_TYPE=external-agent-runtime-worker
|
||||||
|
Environment=AIOS_AGENT_VERSION=2026-08-18.1-openclaw-runtime
|
||||||
|
Environment=AIOS_WORKER_CAPABILITIES=agent.runtime.openclaw
|
||||||
|
Environment=AIOS_WORKER_ALLOWED_ADAPTERS=openclaw
|
||||||
|
Environment=AIOS_AUTHORITY_GIT_DIR=/data/aios-gitops/repo.git
|
||||||
|
Environment=AIOS_EXTERNAL_AGENT_EXECUTOR_ROOT=/var/lib/aios-external-agent-executor
|
||||||
|
Environment=AIOS_WORKER_STATE_ROOT=/var/lib/aios-online-agent-openclaw
|
||||||
|
Environment=AIOS_WORKER_CHECKPOINT_DB=/var/lib/aios-online-agent-openclaw/checkpoints.sqlite3
|
||||||
|
Environment=AIOS_WORKER_WORKSPACE_ROOT=/var/lib/aios-online-agent-openclaw/workspaces
|
||||||
|
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_online_agent_worker.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
TimeoutStopSec=20
|
||||||
|
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 AF_INET6
|
||||||
|
ReadOnlyPaths=/data/aios-gitops/repo.git
|
||||||
|
ReadWritePaths=/var/lib/aios-online-agent-openclaw
|
||||||
|
ReadWritePaths=/var/lib/aios-external-agent-executor
|
||||||
|
UMask=0027
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=AIOS trusted candidate builder
|
|
||||||
Requires=aios-trusted-candidate-builder.socket
|
|
||||||
After=aios-trusted-candidate-builder.socket
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=root
|
|
||||||
Group=root
|
|
||||||
UMask=0077
|
|
||||||
LoadCredentialEncrypted=signing_private_key:/etc/credstore.encrypted/aios-trusted-candidate-builder/signing_private_key
|
|
||||||
LoadCredentialEncrypted=builder_hmac:/etc/credstore.encrypted/aios-trusted-candidate-builder/builder_hmac
|
|
||||||
Environment=AIOS_TRUSTED_BUILDER_POLICY=/opt/aios/trusted-candidate-builder/current/config/gitops/trusted-candidate-builder-policy.json
|
|
||||||
Environment=AIOS_TRUSTED_BUILDER_GIT_DIR=/data/aios-gitops/repo.git
|
|
||||||
Environment=AIOS_TRUSTED_BUILDER_EVIDENCE_ROOT=/var/lib/aios-trusted-candidate-builder/evidence
|
|
||||||
Environment=AIOS_TRUSTED_BUILDER_NONCE_LEDGER=/var/lib/aios-trusted-candidate-builder/nonce-ledger.json
|
|
||||||
Environment=AIOS_TRUSTED_BUILDER_KEY_ID=builder-attestation-ed25519-g1
|
|
||||||
Environment=AIOS_TRUSTED_BUILDER_KEY_GENERATION=1
|
|
||||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
|
||||||
ExecStart=/usr/bin/python3 -B /opt/aios/trusted-candidate-builder/current/app/aios_trusted_candidate_builder.py --socket-fd 3 --policy ${AIOS_TRUSTED_BUILDER_POLICY}
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=2
|
|
||||||
StateDirectory=aios-trusted-candidate-builder
|
|
||||||
StateDirectoryMode=0700
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
PrivateNetwork=true
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectHome=true
|
|
||||||
ProtectControlGroups=true
|
|
||||||
ProtectKernelTunables=true
|
|
||||||
ProtectKernelModules=true
|
|
||||||
ProtectKernelLogs=true
|
|
||||||
RestrictAddressFamilies=AF_UNIX
|
|
||||||
LockPersonality=true
|
|
||||||
MemoryDenyWriteExecute=true
|
|
||||||
RestrictSUIDSGID=true
|
|
||||||
SystemCallArchitectures=native
|
|
||||||
CapabilityBoundingSet=
|
|
||||||
ReadOnlyPaths=/opt/aios/trusted-candidate-builder /data/aios-gitops/repo.git /var/lib/aios-trusted-candidate-builder/evidence
|
|
||||||
ReadWritePaths=/var/lib/aios-trusted-candidate-builder
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=AIOS trusted candidate builder AF_UNIX socket
|
|
||||||
|
|
||||||
[Socket]
|
|
||||||
ListenStream=/run/aios-trusted-candidate-builder.sock
|
|
||||||
SocketUser=root
|
|
||||||
SocketGroup=gitops
|
|
||||||
SocketMode=0660
|
|
||||||
RemoveOnStop=yes
|
|
||||||
Accept=no
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=sockets.target
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AIOS persistent Online Agent worker availability guard
|
||||||
|
After=local-fs.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=root
|
||||||
|
Group=root
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
Environment=AIOS_WORKER_AVAILABILITY_MAX_STALE_SECONDS=90
|
||||||
|
Environment=AIOS_WORKER_AVAILABILITY_RESTART_COOLDOWN_SECONDS=60
|
||||||
|
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_worker_availability_guard.py --heal
|
||||||
|
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
|
||||||
|
ReadWritePaths=/run/aios-worker-availability
|
||||||
|
UMask=0022
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Periodically ensure AIOS Online Agent workers remain available
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=15s
|
||||||
|
OnUnitActiveSec=30s
|
||||||
|
AccuracySec=5s
|
||||||
|
RandomizedDelaySec=3s
|
||||||
|
Persistent=true
|
||||||
|
Unit=aios-worker-availability-guard.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
g aios-executor-exchange -
|
||||||
|
u aios-engineering-executor - "AIOS isolated engineering executor" /var/lib/aios-online-agent-executor /usr/sbin/nologin
|
||||||
|
m aios-engineering-executor aios-executor-exchange
|
||||||
|
m aios-engineering-worker aios-executor-exchange
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
d /var/lib/aios-external-agent-executor 2770 aios-engineering-executor aios-executor-exchange -
|
||||||
|
d /var/lib/aios-external-agent-executor/requests 2770 aios-engineering-worker aios-executor-exchange -
|
||||||
|
d /var/lib/aios-external-agent-executor/results 2770 aios-engineering-executor aios-executor-exchange -
|
||||||
|
d /var/lib/aios-external-agent-executor/failed 2770 aios-engineering-executor aios-executor-exchange -
|
||||||
|
d /var/lib/aios-external-agent-executor/openclaw-workspace 0750 aios-engineering-executor aios-executor-exchange -
|
||||||
|
d /var/lib/aios-external-agent-executor/openclaw-state 0750 aios-engineering-executor aios-executor-exchange -
|
||||||
|
d /var/lib/aios-external-agent-executor/openclaw-home 0750 aios-engineering-executor aios-executor-exchange -
|
||||||
|
d /var/lib/aios-online-agent-hermes 0750 aios-engineering-worker aios-engineering-worker -
|
||||||
|
d /var/lib/aios-online-agent-hermes/workspaces 0750 aios-engineering-worker aios-engineering-worker -
|
||||||
|
d /var/lib/aios-online-agent-openclaw 0750 aios-engineering-worker aios-engineering-worker -
|
||||||
|
d /var/lib/aios-online-agent-openclaw/workspaces 0750 aios-engineering-worker aios-engineering-worker -
|
||||||
|
d /etc/aios/external-agent-runtime.d 0755 root root -
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
d /var/lib/aios-model-governance 0750 aios-engineering-executor aios-executor-exchange -
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
d /var/lib/aios-online-agent-executor 2770 aios-engineering-executor aios-executor-exchange -
|
||||||
|
d /var/lib/aios-online-agent-executor/requests 2770 aios-engineering-worker aios-executor-exchange -
|
||||||
|
d /var/lib/aios-online-agent-executor/results 2770 aios-engineering-executor aios-executor-exchange -
|
||||||
|
d /var/lib/aios-online-agent-executor/failed 2770 aios-engineering-executor aios-executor-exchange -
|
||||||
|
d /var/lib/aios-online-agent-executor/sources 2770 aios-engineering-worker aios-executor-exchange -
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
d /run/aios-worker-availability 0755 root root -
|
||||||
@@ -240,51 +240,115 @@ components:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
properties:
|
properties:
|
||||||
candidate_id: {type: string}
|
candidate_id: {type: string}
|
||||||
paths:
|
BridgeWriterLeaseRecoveryRequest:
|
||||||
/v2/commit-files:
|
type: object
|
||||||
post:
|
additionalProperties: false
|
||||||
operationId: commitFilesGoverned
|
required: [schema, task_id, repository, branch_mode, base_oid, expected_branch_oid, target_branch, generation, lease_seconds]
|
||||||
summary: Submit one atomic governed commit containing multiple files
|
properties:
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: string
|
||||||
additionalProperties: false
|
enum: [aios.task-writer-lease.v2, aios.task-writer-lease-request.v2]
|
||||||
required:
|
task_id: {type: string, minLength: 1, maxLength: 256}
|
||||||
- task_id
|
job_id: {type: string, pattern: '^job-[A-Za-z0-9][A-Za-z0-9._-]{7,255}$'}
|
||||||
- repository
|
repository: {type: string, const: aios/aios}
|
||||||
- expected_branch_oid
|
branch_mode: {type: string, enum: [CREATE_NEW, CONTINUE_EXISTING]}
|
||||||
- target_branch
|
base_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||||
- generation
|
|
||||||
- idempotency_key
|
|
||||||
- files
|
|
||||||
- message
|
|
||||||
properties:
|
|
||||||
task_id: {type: string}
|
|
||||||
job_id: {type: string, description: External durable Job identity for exact completed-replay reconciliation}
|
|
||||||
source_lease_epoch: {type: integer, minimum: 1}
|
|
||||||
repository: {type: string, example: aios/aios}
|
|
||||||
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||||
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9._/-]+$'}
|
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'}
|
||||||
generation: {type: integer, minimum: 1}
|
generation: {type: integer, minimum: 1}
|
||||||
idempotency_key: {type: string}
|
lease_seconds: {type: integer, minimum: 1, maximum: 86400}
|
||||||
files:
|
BridgeWriterLeaseResponse:
|
||||||
type: array
|
|
||||||
minItems: 1
|
|
||||||
items:
|
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required: [path, content_base64]
|
required: [schema, task_id, job_id, repository, branch_mode, base_oid, expected_branch_oid, target_branch, generation, lease_seconds, create_target_branch, worktree_mode, cas_ref_update_required]
|
||||||
properties:
|
properties:
|
||||||
|
schema: {type: string, enum: [aios.task-writer-lease.v2]}
|
||||||
|
task_id: {type: string}
|
||||||
|
job_id: {type: string, pattern: '^job-[A-Za-z0-9][A-Za-z0-9._-]{7,255}$'}
|
||||||
|
repository: {type: string, const: aios/aios}
|
||||||
|
branch_mode: {type: string, enum: [CREATE_NEW, CONTINUE_EXISTING]}
|
||||||
|
base_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||||
|
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||||
|
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'}
|
||||||
|
generation: {type: integer, minimum: 1}
|
||||||
|
lease_seconds: {type: integer, minimum: 1, maximum: 86400}
|
||||||
|
create_target_branch: {type: boolean}
|
||||||
|
worktree_mode: {type: string, enum: [detached_at_base_oid, detached_at_expected_head]}
|
||||||
|
cas_ref_update_required: {type: boolean, const: true}
|
||||||
|
lease_expires_at: {type: string, format: date-time}
|
||||||
|
BridgeWriterLeaseRecoveryResponse:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [schema, task_id, job_id, repository, branch_mode, base_oid, expected_branch_oid, target_branch, generation, lease_seconds, lease_expires_at, create_target_branch, worktree_mode, cas_ref_update_required, recovered, bootstrapped, idempotent, runtime_rehydrated, durable_branch_lease_present]
|
||||||
|
properties:
|
||||||
|
schema: {type: string, enum: [aios.task-writer-lease.v2]}
|
||||||
|
task_id: {type: string}
|
||||||
|
job_id: {type: string, pattern: '^job-[A-Za-z0-9][A-Za-z0-9._-]{7,255}$'}
|
||||||
|
repository: {type: string, const: aios/aios}
|
||||||
|
branch_mode: {type: string, enum: [CREATE_NEW, CONTINUE_EXISTING]}
|
||||||
|
base_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||||
|
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||||
|
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'}
|
||||||
|
generation: {type: integer, minimum: 1}
|
||||||
|
lease_seconds: {type: integer, minimum: 1, maximum: 86400}
|
||||||
|
lease_expires_at: {type: string, format: date-time}
|
||||||
|
create_target_branch: {type: boolean, const: false}
|
||||||
|
worktree_mode: {type: string, enum: [detached_at_base_oid, detached_at_expected_head]}
|
||||||
|
cas_ref_update_required: {type: boolean, const: true}
|
||||||
|
recovered: {type: boolean}
|
||||||
|
bootstrapped: {type: boolean}
|
||||||
|
idempotent: {type: boolean}
|
||||||
|
runtime_rehydrated: {type: boolean}
|
||||||
|
durable_branch_lease_present: {type: boolean}
|
||||||
|
BridgeCommitFileRequest:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [task_id, repository, expected_branch_oid, target_branch, generation, idempotency_key, path, content_base64, message]
|
||||||
|
properties:
|
||||||
|
task_id: {type: string, minLength: 1}
|
||||||
|
job_id: {type: string, pattern: '^job-[A-Za-z0-9][A-Za-z0-9._-]{7,255}$'}
|
||||||
|
repository: {type: string, const: aios/aios}
|
||||||
|
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||||
|
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'}
|
||||||
|
generation: {type: integer, minimum: 1}
|
||||||
|
idempotency_key: {type: string, minLength: 1, maxLength: 256}
|
||||||
path: {type: string, minLength: 1, maxLength: 512}
|
path: {type: string, minLength: 1, maxLength: 512}
|
||||||
content_base64: {type: string}
|
content_base64: {type: string, format: byte}
|
||||||
message: {type: string, minLength: 1, maxLength: 200}
|
message: {type: string, minLength: 1, maxLength: 200}
|
||||||
responses:
|
BridgeCommitFilesRequest:
|
||||||
'200': {description: Governed commit job and verified branch result}
|
type: object
|
||||||
'400': {description: Contract or policy rejection}
|
additionalProperties: false
|
||||||
'409': {description: Branch CAS or idempotency conflict}
|
required: [task_id, repository, expected_branch_oid, target_branch, generation, idempotency_key, files, message]
|
||||||
|
properties:
|
||||||
|
task_id: {type: string, minLength: 1}
|
||||||
|
job_id: {type: string, pattern: '^job-[A-Za-z0-9][A-Za-z0-9._-]{7,255}$'}
|
||||||
|
repository: {type: string, const: aios/aios}
|
||||||
|
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||||
|
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'}
|
||||||
|
generation: {type: integer, minimum: 1}
|
||||||
|
idempotency_key: {type: string, minLength: 1, maxLength: 256}
|
||||||
|
files: {type: object, additionalProperties: {type: string, format: byte}, minProperties: 1}
|
||||||
|
message: {type: string, minLength: 1, maxLength: 200}
|
||||||
|
BridgeCommitResponse:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
required: [job_id, commit_oid, commit_visible, branch_mode, target_branch, generation]
|
||||||
|
properties:
|
||||||
|
job_id: {type: string, pattern: '^job-[A-Za-z0-9][A-Za-z0-9._-]{7,255}$'}
|
||||||
|
commit_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||||
|
commit_visible: {type: boolean}
|
||||||
|
branch_mode: {type: string, enum: [CONTINUE_EXISTING]}
|
||||||
|
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'}
|
||||||
|
generation: {type: integer, minimum: 1}
|
||||||
|
BridgeGovernedCapabilities:
|
||||||
|
type: object
|
||||||
|
required: [bridge_schema_version, governed_writes_enabled, operations, operation_contracts]
|
||||||
|
properties:
|
||||||
|
bridge_schema_version: {type: string, const: aios.chatgpt-gitops-bridge.v2}
|
||||||
|
governed_writes_enabled: {type: boolean, const: true}
|
||||||
|
operations: {type: array, items: {type: string}}
|
||||||
|
operation_contracts: {type: object, additionalProperties: true}
|
||||||
|
paths:
|
||||||
/createReleaseIntent:
|
/createReleaseIntent:
|
||||||
post:
|
post:
|
||||||
operationId: createReleaseIntent
|
operationId: createReleaseIntent
|
||||||
@@ -611,3 +675,118 @@ paths:
|
|||||||
'200': {description: Safe GitOps plan}
|
'200': {description: Safe GitOps plan}
|
||||||
'401': {description: Unauthorized}
|
'401': {description: Unauthorized}
|
||||||
x-openai-isConsequential: true
|
x-openai-isConsequential: true
|
||||||
|
/v2/capabilities:
|
||||||
|
get:
|
||||||
|
operationId: getGovernedWriteCapabilities
|
||||||
|
summary: Read the governed Bridge v2 capability contract
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Governed operations and their request/response contracts
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BridgeGovernedCapabilities'}
|
||||||
|
/v2/writer-lease:
|
||||||
|
post:
|
||||||
|
operationId: acquireWriterLease
|
||||||
|
summary: Acquire the exact governed writer lease for a task generation
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BridgeWriterLeaseRecoveryRequest'}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Exact detached worktree lease binding
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BridgeWriterLeaseResponse'}
|
||||||
|
'400': {description: Invalid lease binding}
|
||||||
|
'409': {description: Branch, base, or generation conflict}
|
||||||
|
x-openai-isConsequential: true
|
||||||
|
/v2/writer-lease/bootstrap:
|
||||||
|
post:
|
||||||
|
operationId: bootstrapWriterLease
|
||||||
|
summary: Bootstrap a governed writer runtime without creating a durable lease
|
||||||
|
description: >
|
||||||
|
Staging-only runtime recovery. It validates the exact task, repository,
|
||||||
|
base OID, expected branch OID, target branch, and generation. It must
|
||||||
|
not create a branch, insert a branch lease, or consume a generation.
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BridgeWriterLeaseRecoveryRequest'}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Runtime binding bootstrapped or returned idempotently
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BridgeWriterLeaseRecoveryResponse'}
|
||||||
|
'400': {description: Invalid lease binding}
|
||||||
|
'409': {description: A matching durable lease already exists or binding conflicts}
|
||||||
|
'503': {description: Runtime or durable state unavailable}
|
||||||
|
x-openai-isConsequential: true
|
||||||
|
/v2/writer-lease/renew:
|
||||||
|
post:
|
||||||
|
operationId: renewWriterLease
|
||||||
|
summary: Recover an expired governed writer lease without changing its binding
|
||||||
|
description: >
|
||||||
|
Renews only the current task generation and exact repository, base OID,
|
||||||
|
expected branch OID, and target branch. Durable renewal is revision-CAS
|
||||||
|
protected; runtime rehydration is reported separately.
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BridgeWriterLeaseRecoveryRequest'}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Lease recovered or idempotently observed as already active
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BridgeWriterLeaseRecoveryResponse'}
|
||||||
|
'400': {description: Invalid schema, generation, or lease duration}
|
||||||
|
'409': {description: Task, base, repository, branch, generation, or durable lease conflict}
|
||||||
|
'503': {description: Durable job store or runtime unavailable}
|
||||||
|
x-openai-isConsequential: true
|
||||||
|
/v2/commit-file:
|
||||||
|
post:
|
||||||
|
operationId: commitFileGoverned
|
||||||
|
summary: Commit one file through the governed writer lease
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BridgeCommitFileRequest'}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Authority and remote visibility receipt
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BridgeCommitResponse'}
|
||||||
|
'409': {description: Lease, expected-head, idempotency, or repository conflict}
|
||||||
|
'503': {description: Reconciliation required}
|
||||||
|
x-openai-isConsequential: true
|
||||||
|
/v2/commit-files:
|
||||||
|
post:
|
||||||
|
operationId: commitFilesGoverned
|
||||||
|
summary: Atomically commit a bounded set of files through the governed writer lease
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BridgeCommitFilesRequest'}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Authority and remote visibility receipt
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BridgeCommitResponse'}
|
||||||
|
'409': {description: Lease, expected-head, idempotency, or repository conflict}
|
||||||
|
'503': {description: Reconciliation required}
|
||||||
|
x-openai-isConsequential: true
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# R4.2 P0-A TaskPlane Scope Isolation Design
|
||||||
|
|
||||||
|
Status: approved candidate design. This document is not evidence of a protected-main merge or production deployment.
|
||||||
|
|
||||||
|
## Boundary
|
||||||
|
|
||||||
|
- Task: `r42-p0a-taskplane-scope-isolation-20260816`
|
||||||
|
- Governed Job: `job-3fd743b51971c6007923699c1233f84c`
|
||||||
|
- Repository: `aios/aios`
|
||||||
|
- Base: `0eb6e1a864edfdaeef37b438991707a110e21558`
|
||||||
|
- Target branch: `agent/r42-p0a-taskplane-scope-isolation-20260816`
|
||||||
|
- Generation: `1`
|
||||||
|
|
||||||
|
The implementation reuses this exact boundary. It does not create a replacement Task, Job, branch, generation, or idempotency key.
|
||||||
|
|
||||||
|
## Model and invariants
|
||||||
|
|
||||||
|
`TaskPlan` gains first-class `scope_type`, `tenant_id`, `user_id`, `project_id`, `conversation_id`, and `run_id` fields. The fields participate in accepted-key validation, parsing, serialization, persistence, and authorization.
|
||||||
|
|
||||||
|
Historical rows without scope data normalize to `scope_type=legacy`; no tenant is inferred. Tenant-scoped tasks require a non-empty `tenant_id`. System/global resources use an explicit `scope_type=system` invariant and do not receive a fabricated tenant. A task may add user, project, conversation, and run ownership within its tenant.
|
||||||
|
|
||||||
|
## Trusted ActorContext
|
||||||
|
|
||||||
|
TaskPlane defines a small immutable `TaskActorContext` used only for authorization. The HTTP authenticator returns an identity. `PolicyEngine` resolves that identity through server-owned policy configuration into the effective context and capabilities. Request fields such as `tenant_id`, `user_id`, `group_id`, and `role` are declarations only and never expand authority.
|
||||||
|
|
||||||
|
Public create, list, and get paths always use the resolved context. Internal compatibility calls may omit a context and retain explicit legacy behavior, but the HTTP adapter never uses that path.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
SQLite remains the current runtime. `_create_schema()` performs an idempotent forward migration that adds the six scope columns and indexes, then backfills only missing `scope_type` values to `legacy`.
|
||||||
|
|
||||||
|
New writes persist scope in both the canonical `task_json` representation and first-class columns. Reads accept historical JSON without scope, using legacy columns, but reject a new-row mismatch between JSON and columns. SQL list/get predicates are parameterized and applied before row hydration.
|
||||||
|
|
||||||
|
PostgreSQL remains a separate migration target and is not claimed by this change.
|
||||||
|
|
||||||
|
## Authorization flow
|
||||||
|
|
||||||
|
1. HTTP bearer authentication yields an identity.
|
||||||
|
2. `PolicyEngine` verifies capability and resolves the identity to `TaskActorContext`.
|
||||||
|
3. Create binds the requested scope to the trusted context and rejects mismatches.
|
||||||
|
4. List/get pass the trusted context to `TaskPlaneStore`.
|
||||||
|
5. Store applies tenant and ownership predicates in SQL; privileged capabilities may explicitly cross scope.
|
||||||
|
|
||||||
|
Tenant actors cannot read legacy tasks, another tenant, or mismatched owned resources. Legacy actors can only read legacy tasks. System actors require explicit privileged capability for cross-scope access.
|
||||||
|
|
||||||
|
## Failure behavior
|
||||||
|
|
||||||
|
Missing tenant scope, forged scope, unknown scope types, JSON/column divergence, and unauthorized list/get operations fail closed with canonical TaskPlane errors. Public get does not reveal whether an out-of-scope task exists.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Tests exercise real model round trips, SQLite migration/restart, Service/Policy/Store wiring, tenant A versus tenant B list/get denial, ownership filtering, forged create payload rejection, legacy isolation, privileged access, and SQL parameterization. Existing TaskPlane API, RBAC, recovery/security, and security-contract suites remain mandatory.
|
||||||
|
|
||||||
|
The candidate is complete only after real `TASK_CLAIMED`, `TASK_HEARTBEAT`, exact WriterLease, governed commit, OID convergence, result submission, and `TASK_VERIFIED`. Protected main and production remain unchanged.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user