Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39c2caecd6 | ||
|
|
28b68ed0e5 | ||
|
|
6d92ce8c21 | ||
|
|
4d4f7b52a5 | ||
|
|
44ca3496ec | ||
|
|
52b74ba610 | ||
|
|
51b5f0e53e | ||
|
|
b660e2cc32 | ||
|
|
6ece3525a7 | ||
|
|
2253b83ee4 | ||
|
|
cef2a3e1e5 |
@@ -13,7 +13,6 @@ 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:
|
||||||
@@ -56,19 +55,13 @@ 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 = stable_event_id(
|
event_id = "evt_" + sha256(
|
||||||
source=source, target=target, message_type=kind, payload=payload,
|
json.dumps(
|
||||||
tenant_id=(payload.get('tenant_id') if isinstance(payload, dict) else None),
|
{"kind": kind, "source": source, "summary": summary, "payload": payload},
|
||||||
user_id=(payload.get('user_id') if isinstance(payload, dict) else None),
|
sort_keys=True,
|
||||||
project_id=(payload.get('project_id') if isinstance(payload, dict) else None),
|
ensure_ascii=False,
|
||||||
conversation_id=(payload.get('conversation_id') if isinstance(payload, dict) else None),
|
).encode("utf-8")
|
||||||
task_id=(payload.get('task_id') if isinstance(payload, dict) else None),
|
).hexdigest()[:16]
|
||||||
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, *, actor=None, trace=None):
|
def consume(self, event: dict):
|
||||||
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,8 +16,6 @@ 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,18 +31,6 @@ 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,49 +9,25 @@ try:
|
|||||||
except Exception:
|
except Exception:
|
||||||
APIRouter = None
|
APIRouter = None
|
||||||
|
|
||||||
from .agent_observability import AgentExecutionRecord, AgentObservability
|
from .agent_observability import AgentExecutionRecord
|
||||||
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], *, actor: GovernanceActor | dict[str, Any] | None = None, trace: TraceContext | None = None):
|
def record_agent_execution(payload: dict[str, Any]):
|
||||||
scoped = any(payload.get(name) is not None for name in (
|
record = AgentExecutionRecord(**payload)
|
||||||
"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", *, actor: GovernanceActor | dict[str, Any] | None = None):
|
def get_agent_observability(user_id: str | None = None, group_id: str | None = None, role: str = "user"):
|
||||||
records = store.query(user_id, group_id, role, actor=actor)
|
records = store.query(user_id, group_id, role)
|
||||||
visible = AgentObservability()
|
|
||||||
visible.records = [AgentExecutionRecord(**record) for record in records]
|
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"records": records,
|
"records": records,
|
||||||
"summary": visible.summary(None),
|
"summary": store.load().summary(user_id if role != "owner" else None),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,6 @@ 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:
|
||||||
@@ -41,7 +36,6 @@ 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:
|
||||||
@@ -65,19 +59,7 @@ 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,
|
||||||
@@ -85,7 +67,6 @@ 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__}
|
||||||
@@ -125,18 +106,6 @@ 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,12 +12,6 @@ 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:
|
||||||
@@ -29,11 +23,6 @@ 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
|
||||||
|
|
||||||
@@ -44,36 +33,8 @@ 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", *, actor: GovernanceActor | dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
def query(self, user_id: str | None = None, group_id: str | None = None, role: str = "user") -> 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:
|
||||||
|
|||||||
@@ -1,722 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,427 +0,0 @@
|
|||||||
"""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,91 +94,6 @@ 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 recover_failed_detached_worktree_exact(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
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")
|
|
||||||
if self.read_ref(target_ref) is not None:
|
|
||||||
raise AuthorityGitError("AUTHORITY_TARGET_BRANCH_ALREADY_EXISTS")
|
|
||||||
if (
|
|
||||||
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 == new_oid:
|
|
||||||
return new_oid
|
|
||||||
raise AuthorityGitError("AUTHORITY_TARGET_BRANCH_ALREADY_EXISTS")
|
|
||||||
if not self.ancestry_check(expected_base_oid, new_oid):
|
|
||||||
raise AuthorityGitError("NEW_OID_NOT_DESCENDANT")
|
|
||||||
self._git(["update-ref", branch, new_oid, "0" * 40])
|
|
||||||
readback = self.read_ref(branch)
|
|
||||||
if readback != new_oid:
|
|
||||||
raise AuthorityGitError("AUTHORITY_CREATE_READBACK_MISMATCH")
|
|
||||||
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:
|
||||||
if not TASK_BRANCH_RE.fullmatch(branch):
|
if not TASK_BRANCH_RE.fullmatch(branch):
|
||||||
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ from __future__ import annotations
|
|||||||
import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, urllib.request, uuid
|
import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, urllib.request, uuid
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from version_registry import (
|
||||||
|
configured_authoritative_git_dir,
|
||||||
|
configured_repository_identity,
|
||||||
|
list_registry_versions,
|
||||||
|
list_registry_versions_report,
|
||||||
|
)
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -72,8 +78,8 @@ HOST = os.getenv('AIOS_GITOPS_HOST', '127.0.0.1')
|
|||||||
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
|
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
|
||||||
OPENAPI = WS / 'docs' / 'gitops' / 'openapi-chatgpt-actions.yaml'
|
OPENAPI = WS / 'docs' / 'gitops' / 'openapi-chatgpt-actions.yaml'
|
||||||
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios.git')))
|
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios.git')))
|
||||||
AUTHORITATIVE_GIT_DIR = Path(os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR')) if os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR') else None
|
AUTHORITATIVE_GIT_DIR = configured_authoritative_git_dir()
|
||||||
REPOSITORY_IDENTITY = os.getenv('AIOS_GITOPS_REPOSITORY_IDENTITY', '')
|
REPOSITORY_IDENTITY = configured_repository_identity()
|
||||||
SILENT_RELEASE_STATE_ROOT = Path(os.getenv(
|
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',
|
||||||
@@ -466,6 +472,14 @@ class H(BaseHTTPRequestHandler):
|
|||||||
))
|
))
|
||||||
return self.sendj(data)
|
return self.sendj(data)
|
||||||
if path in ('/api/ver','/api/versions','/ver'):
|
if path in ('/api/ver','/api/versions','/ver'):
|
||||||
|
registry_report = list_registry_versions_report(
|
||||||
|
WS,
|
||||||
|
baseline_file=Path(os.getenv('AIOS_GITOPS_BASELINE_AUTHORITY', '/var/lib/aios-gitops-runtime-anchor/baseline/current.json')),
|
||||||
|
# Do not invent an authoritative repository when authority
|
||||||
|
# discovery is disabled/omitted. Preserve the unavailable
|
||||||
|
# authority path (503) instead of turning it into a 404.
|
||||||
|
repo_git_dir=AUTHORITATIVE_GIT_DIR,
|
||||||
|
)
|
||||||
snapshot = repository_snapshot(
|
snapshot = repository_snapshot(
|
||||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||||
repository_identity=REPOSITORY_IDENTITY,
|
repository_identity=REPOSITORY_IDENTITY,
|
||||||
@@ -474,7 +488,8 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.sendj({
|
return self.sendj({
|
||||||
'repository_available': False,
|
'repository_available': False,
|
||||||
'query_error': snapshot['query_error'],
|
'query_error': snapshot['query_error'],
|
||||||
'registry_versions': [p.name for p in (WS/'versions').glob('*')],
|
'registry_versions': registry_report['registry_versions'],
|
||||||
|
**{key: registry_report[key] for key in ('effective_registry_versions', 'runtime_registry_versions', 'attested_projection_versions', 'attested_projection_version', 'source_registry_converged', 'runtime_registry_converged', 'runtime_bundle_contains_current_version', 'runtime_bundle_contains_version', 'registry_consistency_status')},
|
||||||
'local_authoritative_tags': [],
|
'local_authoritative_tags': [],
|
||||||
}, 503)
|
}, 503)
|
||||||
try:
|
try:
|
||||||
@@ -483,10 +498,11 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.sendj({
|
return self.sendj({
|
||||||
'repository_available': False,
|
'repository_available': False,
|
||||||
'query_error': str(exc),
|
'query_error': str(exc),
|
||||||
'registry_versions': [p.name for p in (WS/'versions').glob('*')],
|
'registry_versions': registry_report['registry_versions'],
|
||||||
|
**{key: registry_report[key] for key in ('effective_registry_versions', 'runtime_registry_versions', 'attested_projection_versions', 'attested_projection_version', 'source_registry_converged', 'runtime_registry_converged', 'runtime_bundle_contains_current_version', 'runtime_bundle_contains_version', 'registry_consistency_status')},
|
||||||
'local_authoritative_tags': [],
|
'local_authoritative_tags': [],
|
||||||
}, 503)
|
}, 503)
|
||||||
registry = [p.name for p in (WS/'versions').glob('*')]
|
registry = registry_report['registry_versions']
|
||||||
return self.sendj({
|
return self.sendj({
|
||||||
'registry_versions': registry,
|
'registry_versions': registry,
|
||||||
'local_authoritative_tags': tags,
|
'local_authoritative_tags': tags,
|
||||||
@@ -496,6 +512,7 @@ class H(BaseHTTPRequestHandler):
|
|||||||
'versions_dir': registry,
|
'versions_dir': registry,
|
||||||
'head': snapshot['authoritative_head_oid'],
|
'head': snapshot['authoritative_head_oid'],
|
||||||
'authoritative_head_oid': snapshot['authoritative_head_oid'],
|
'authoritative_head_oid': snapshot['authoritative_head_oid'],
|
||||||
|
**{key: registry_report[key] for key in ('effective_registry_versions', 'runtime_registry_versions', 'attested_projection_versions', 'attested_projection_version', 'source_registry_converged', 'runtime_registry_converged', 'runtime_bundle_contains_current_version', 'runtime_bundle_contains_version', 'registry_consistency_status')},
|
||||||
})
|
})
|
||||||
if path in ('/api/diff','/diff'):
|
if path in ('/api/diff','/diff'):
|
||||||
frm=(q.get('from') or [''])[0]
|
frm=(q.get('from') or [''])[0]
|
||||||
@@ -950,4 +967,3 @@ 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()
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,9 @@ BRIDGE_OPERATIONS = (
|
|||||||
"getGovernedWriteCapabilities",
|
"getGovernedWriteCapabilities",
|
||||||
"createGovernedTask",
|
"createGovernedTask",
|
||||||
"acquireWriterLease",
|
"acquireWriterLease",
|
||||||
"bootstrapWriterLease",
|
|
||||||
"renewWriterLease",
|
|
||||||
"commitFileGoverned",
|
"commitFileGoverned",
|
||||||
"commitFilesGoverned",
|
"commitFilesGoverned",
|
||||||
|
"publishExistingBranchGoverned",
|
||||||
"getGitOpsJob",
|
"getGitOpsJob",
|
||||||
"resumeGitOpsJob",
|
"resumeGitOpsJob",
|
||||||
"reconcileGitOpsJob",
|
"reconcileGitOpsJob",
|
||||||
@@ -33,10 +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"),
|
||||||
"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"),
|
||||||
|
"publishExistingBranchGoverned": ("repository", "branch", "expected_branch_oid", "owner_approval_reference", "idempotency_key"),
|
||||||
"getGitOpsJob": ("job_id",),
|
"getGitOpsJob": ("job_id",),
|
||||||
"resumeGitOpsJob": ("job_id", "expected_revision"),
|
"resumeGitOpsJob": ("job_id", "expected_revision"),
|
||||||
"reconcileGitOpsJob": ("job_id", "expected_revision"),
|
"reconcileGitOpsJob": ("job_id", "expected_revision"),
|
||||||
@@ -50,49 +48,6 @@ OPERATION_REQUIRED_FIELDS = {
|
|||||||
"getControlPlaneSnapshot": (),
|
"getControlPlaneSnapshot": (),
|
||||||
}
|
}
|
||||||
|
|
||||||
BRIDGE_OPERATION_CONTRACTS = {
|
|
||||||
"acquireWriterLease": {
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/v2/writer-lease",
|
|
||||||
"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"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class RuntimeDriftError(RuntimeError):
|
class RuntimeDriftError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
@@ -169,14 +124,6 @@ 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: {
|
|
||||||
**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,
|
||||||
@@ -206,10 +153,9 @@ class BridgeV2Adapter:
|
|||||||
|
|
||||||
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 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 publishExistingBranchGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("publishExistingBranchGoverned", 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)
|
||||||
def resumeGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("resumeGitOpsJob", request)
|
def resumeGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("resumeGitOpsJob", request)
|
||||||
def reconcileGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("reconcileGitOpsJob", request)
|
def reconcileGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("reconcileGitOpsJob", request)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -51,13 +51,11 @@ 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_new_exact(self, branch: str, new: str) -> None: ...
|
|
||||||
def push_exact(self, branch: str, expected: 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: ...
|
||||||
@@ -105,9 +103,6 @@ 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)
|
||||||
|
|
||||||
@@ -263,16 +258,7 @@ class GiteaContinuationRemote:
|
|||||||
raise ContinuationExecutionError("GITEA_GIT_READBACK_INVALID")
|
raise ContinuationExecutionError("GITEA_GIT_READBACK_INVALID")
|
||||||
return oid
|
return oid
|
||||||
|
|
||||||
def push_new_exact(self, branch: str, new: str) -> None:
|
def push_exact(self, branch: str, expected: str | None, new: str) -> None:
|
||||||
self._require_projected()
|
|
||||||
self.repository_identity()
|
|
||||||
if self.read_git_ref(branch) is not 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._require_projected()
|
||||||
self.repository_identity()
|
self.repository_identity()
|
||||||
if self.read_git_ref(branch) != expected:
|
if self.read_git_ref(branch) != expected:
|
||||||
@@ -343,7 +329,6 @@ 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
|
||||||
@@ -404,20 +389,13 @@ 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, *, exclude_job_id: str | None = None) -> int:
|
def _max_generation(self, branch: str) -> 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)
|
||||||
|
|
||||||
@@ -426,7 +404,6 @@ 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,
|
||||||
@@ -436,21 +413,10 @@ class ContinuationExecutor:
|
|||||||
"lease_seconds": request.lease_seconds,
|
"lease_seconds": request.lease_seconds,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _validate_preflight(self, binding: ContinuationRuntimeBinding, *, job_id: str | None = None) -> None:
|
def _validate_preflight(self, binding: ContinuationRuntimeBinding) -> None:
|
||||||
request = binding.request
|
request = binding.request
|
||||||
request_mapping = self._request_mapping(request)
|
normalized = validate_writer_lease_request_v2(self._request_mapping(request), self.authority)
|
||||||
normalized = validate_writer_lease_request_v2(request_mapping, self.authority)
|
if normalized != {**self._request_mapping(request), "create_target_branch": False, "worktree_mode": "detached_at_expected_head", "cas_ref_update_required": True}:
|
||||||
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")
|
||||||
@@ -460,7 +426,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, exclude_job_id=job_id):
|
if request.generation <= self._max_generation(request.target_branch):
|
||||||
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")
|
||||||
@@ -486,102 +452,57 @@ class ContinuationExecutor:
|
|||||||
"generation": request.generation,
|
"generation": request.generation,
|
||||||
}
|
}
|
||||||
|
|
||||||
def execute(
|
def execute(self, binding: ContinuationRuntimeBinding, commit: ContinuationCommit) -> dict[str, object]:
|
||||||
self,
|
payload = commit.payload()
|
||||||
binding: ContinuationRuntimeBinding,
|
submitted = self.controller.submit(self._job_request(binding, commit, payload), payload=payload)
|
||||||
commit: ContinuationCommit,
|
current = self.controller.store.read(submitted.job_id)
|
||||||
*,
|
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()
|
||||||
if current_state == JobState.REMOTE_VERIFIED:
|
self._validate_preflight(binding)
|
||||||
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(binding.job_id)
|
row = self.controller.store.read(submitted.job_id)
|
||||||
state = JobState(row["state"])
|
if JobState(row["state"]) == JobState.REQUESTED:
|
||||||
if state == JobState.REQUESTED:
|
|
||||||
self.controller.advance(
|
self.controller.advance(
|
||||||
binding.job_id,
|
submitted.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(binding.job_id)
|
row = self.controller.store.read(submitted.job_id)
|
||||||
state = JobState(row["state"])
|
if JobState(row["state"]) == JobState.AUTHORIZED:
|
||||||
if state == JobState.AUTHORIZED:
|
|
||||||
self.controller.lease_job(
|
self.controller.lease_job(
|
||||||
binding.job_id,
|
submitted.job_id,
|
||||||
lease_id=f"lease-{binding.job_id}",
|
lease_id=f"lease-{submitted.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"]),
|
||||||
)
|
)
|
||||||
row = self.controller.store.read(binding.job_id)
|
self.controller.advance(submitted.job_id, JobState.WORKTREE_READY, side_effect_state="WORKTREE_READY")
|
||||||
state = JobState(row["state"])
|
self.controller.advance(submitted.job_id, JobState.EXECUTING, side_effect_state="IN_PROGRESS")
|
||||||
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.compare_and_swap_branch(request.target_branch, request.expected_branch_oid, new_oid)
|
||||||
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)
|
|
||||||
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(
|
||||||
binding.job_id,
|
submitted.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(binding.job_id, JobState.REMOTE_SYNCING, side_effect_state="IN_PROGRESS")
|
self.controller.advance(submitted.job_id, JobState.REMOTE_SYNCING, side_effect_state="IN_PROGRESS")
|
||||||
if request.branch_mode == "CREATE_NEW":
|
self.remote.push_exact(request.target_branch, request.expected_branch_oid, new_oid)
|
||||||
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(binding.job_id, authority_oid, new_oid)
|
return self._remote_pending(submitted.job_id, authority_oid, new_oid)
|
||||||
return self._finish_remote_verification(
|
return self._finish_remote_verification(submitted.job_id, authority_oid, new_oid)
|
||||||
binding.job_id,
|
|
||||||
authority_oid,
|
|
||||||
new_oid,
|
|
||||||
before_complete=before_complete,
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
if projected:
|
if projected:
|
||||||
self.credential.revoke()
|
self.credential.revoke()
|
||||||
@@ -604,14 +525,7 @@ class ContinuationExecutor:
|
|||||||
)
|
)
|
||||||
return result.as_dict()
|
return result.as_dict()
|
||||||
|
|
||||||
def _finish_remote_verification(
|
def _finish_remote_verification(self, job_id: str, authority_oid: str, new_oid: str) -> dict[str, object]:
|
||||||
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"]
|
||||||
@@ -628,20 +542,8 @@ class ContinuationExecutor:
|
|||||||
"reconciliation_decision": decision.action.value,
|
"reconciliation_decision": decision.action.value,
|
||||||
"force_push": False,
|
"force_push": False,
|
||||||
}
|
}
|
||||||
self.controller.advance(
|
self.controller.advance(job_id, JobState.REMOTE_VERIFIED, side_effect_state="REMOTE_VERIFIED", receipt=receipt)
|
||||||
job_id,
|
return self.controller.advance(job_id, JobState.COMPLETED, side_effect_state="REMOTE_VERIFIED", receipt=receipt).as_dict()
|
||||||
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)
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ 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"})
|
||||||
@@ -57,8 +56,6 @@ 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")
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -22,14 +21,9 @@ 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, "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}
|
return {"lease_id": self.lease_id, "task_id": self.task_id, "repository": self.repository, "target_branch": self.target_branch, "generation": self.generation, "issued_at": self.issued_at, "expires_at": self.expires_at, "scopes": list(self.scopes), "provider": self.provider, "state": self.state}
|
||||||
|
|
||||||
|
|
||||||
class FileCredentialProvider:
|
class FileCredentialProvider:
|
||||||
@@ -49,19 +43,11 @@ 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, 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]:
|
def issue(self, *, task_id: str, repository: str, target_branch: str, generation: int, scopes: tuple[str, ...] = ("git:push", "gitea:read"), lease_seconds: int = 900) -> dict[str, Any]:
|
||||||
if repository != "aios/aios" or not target_branch.startswith("agent/") or generation < 1 or not 1 <= lease_seconds <= 86400:
|
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__, "ACTIVE", scope_type, tenant_id, user_id, project_id, run_id)
|
lease = CredentialLease("lease-" + secrets.token_hex(16), task_id, repository, target_branch, generation, now.isoformat(), (now + timedelta(seconds=lease_seconds)).isoformat(), tuple(scopes), type(self.provider).__name__)
|
||||||
self._leases[lease.lease_id] = lease
|
self._leases[lease.lease_id] = lease
|
||||||
return lease.as_dict()
|
return lease.as_dict()
|
||||||
|
|
||||||
@@ -78,39 +64,7 @@ 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")
|
||||||
environment = self.provider.environment(lease)
|
return 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()
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""Fail-closed governed publication of an existing Authority branch at an exact OID."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from enum import Enum
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
|
BRANCH_RE = re.compile(r"^(?!refs/)(?!tags/)[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
||||||
|
IDEMPOTENCY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$")
|
||||||
|
|
||||||
|
|
||||||
|
class ExistingBranchPublicationError(RuntimeError):
|
||||||
|
"""Stable, non-secret error for the governed existing-branch publisher."""
|
||||||
|
|
||||||
|
|
||||||
|
class ExistingBranchPublicationConflict(ExistingBranchPublicationError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ExistingBranchPublicationState(str, Enum):
|
||||||
|
QUEUED = "QUEUED"
|
||||||
|
PUBLISHING = "PUBLISHING"
|
||||||
|
REMOTE_PUSH_PENDING = "REMOTE_PUSH_PENDING"
|
||||||
|
RECONCILIATION_REQUIRED = "RECONCILIATION_REQUIRED"
|
||||||
|
MANUAL_OWNER_REVIEW_REQUIRED = "MANUAL_OWNER_REVIEW_REQUIRED"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ExistingBranchPublicationRequest:
|
||||||
|
repository: str
|
||||||
|
branch: str
|
||||||
|
expected_branch_oid: str
|
||||||
|
owner_approval_reference: str
|
||||||
|
idempotency_key: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mapping(cls, value: Mapping[str, Any]) -> "ExistingBranchPublicationRequest":
|
||||||
|
required = ("repository", "branch", "expected_branch_oid", "owner_approval_reference", "idempotency_key")
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise ExistingBranchPublicationConflict("REQUEST_OBJECT_REQUIRED")
|
||||||
|
if any(not isinstance(value.get(field), str) or not value[field].strip() for field in required):
|
||||||
|
raise ExistingBranchPublicationConflict("REQUEST_FIELDS_MISSING")
|
||||||
|
repository = value["repository"].strip()
|
||||||
|
branch = value["branch"].strip()
|
||||||
|
expected = value["expected_branch_oid"].strip().lower()
|
||||||
|
approval = value["owner_approval_reference"].strip()
|
||||||
|
idempotency = value["idempotency_key"].strip()
|
||||||
|
if repository != "aios/aios":
|
||||||
|
raise ExistingBranchPublicationConflict("REPOSITORY_IDENTITY_MISMATCH")
|
||||||
|
if not BRANCH_RE.fullmatch(branch) or branch in {"main", "master"}:
|
||||||
|
raise ExistingBranchPublicationConflict("BRANCH_NAMESPACE_REJECTED")
|
||||||
|
if not OID_RE.fullmatch(expected):
|
||||||
|
raise ExistingBranchPublicationConflict("EXPECTED_BRANCH_OID_INVALID")
|
||||||
|
if len(approval) > 512:
|
||||||
|
raise ExistingBranchPublicationConflict("OWNER_APPROVAL_REFERENCE_INVALID")
|
||||||
|
if not IDEMPOTENCY_RE.fullmatch(idempotency):
|
||||||
|
raise ExistingBranchPublicationConflict("IDEMPOTENCY_KEY_INVALID")
|
||||||
|
return cls(repository, branch, expected, approval, idempotency)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def publication_job_id(self) -> str:
|
||||||
|
return "publication-" + hashlib.sha256(self.idempotency_key.encode()).hexdigest()[:32]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def digest(self) -> str:
|
||||||
|
raw = json.dumps(asdict(self), sort_keys=True, separators=(",", ":")).encode()
|
||||||
|
return hashlib.sha256(raw).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class ExistingBranchAuthority(Protocol):
|
||||||
|
def read_ref(self, ref: str) -> str | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ExistingBranchRemote(Protocol):
|
||||||
|
def repository_identity(self) -> str: ...
|
||||||
|
def read_git_ref(self, branch: str) -> str | None: ...
|
||||||
|
def read_api_branch(self, branch: str) -> str | None: ...
|
||||||
|
def commit_visible(self, oid: str) -> bool: ...
|
||||||
|
def push_exact(self, branch: str, expected: str | None, new: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ExistingBranchPublicationStore:
|
||||||
|
def __init__(self, root: Path):
|
||||||
|
self.root = Path(root)
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _path(self, request: ExistingBranchPublicationRequest) -> Path:
|
||||||
|
return self.root / f"{request.publication_job_id}.json"
|
||||||
|
|
||||||
|
def load(self, request: ExistingBranchPublicationRequest) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
value = json.loads(self._path(request).read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {}
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ExistingBranchPublicationConflict("PUBLICATION_STATE_INVALID")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def record(self, request: ExistingBranchPublicationRequest, state: ExistingBranchPublicationState, **evidence: Any) -> dict[str, Any]:
|
||||||
|
payload = {
|
||||||
|
"schema": "aios.existing-branch-publication.v1",
|
||||||
|
"publication_job_id": request.publication_job_id,
|
||||||
|
"repository": request.repository,
|
||||||
|
"branch": request.branch,
|
||||||
|
"expected_branch_oid": request.expected_branch_oid,
|
||||||
|
"owner_approval_reference": request.owner_approval_reference,
|
||||||
|
"idempotency_key": request.idempotency_key,
|
||||||
|
"request_digest": request.digest,
|
||||||
|
"publication_state": state.value,
|
||||||
|
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
**evidence,
|
||||||
|
}
|
||||||
|
path = self._path(request)
|
||||||
|
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(payload, handle, ensure_ascii=False, sort_keys=True)
|
||||||
|
handle.write("\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(temporary, path)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temporary):
|
||||||
|
os.unlink(temporary)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class ExistingBranchPublisher:
|
||||||
|
"""Publish only the exact, already-authorized Authority branch tip."""
|
||||||
|
|
||||||
|
def __init__(self, authority: ExistingBranchAuthority, remote: ExistingBranchRemote, state_root: Path):
|
||||||
|
self.authority = authority
|
||||||
|
self.remote = remote
|
||||||
|
self.store = ExistingBranchPublicationStore(state_root)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_error(exc: BaseException) -> str:
|
||||||
|
return type(exc).__name__
|
||||||
|
|
||||||
|
def _observe(self, request: ExistingBranchPublicationRequest) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"authority_branch_oid": self.authority.read_ref(f"refs/heads/{request.branch}"),
|
||||||
|
"gitea_git_branch_oid": self.remote.read_git_ref(request.branch),
|
||||||
|
"gitea_api_branch_oid": self.remote.read_api_branch(request.branch),
|
||||||
|
"commit_visible": bool(self.remote.commit_visible(request.expected_branch_oid)),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_remote_conflict(request: ExistingBranchPublicationRequest, observation: Mapping[str, Any]) -> bool:
|
||||||
|
return any(
|
||||||
|
observation.get(field) not in (None, request.expected_branch_oid)
|
||||||
|
for field in ("gitea_git_branch_oid", "gitea_api_branch_oid")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _manual(self, request: ExistingBranchPublicationRequest, reason: str, observation: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
return self.store.record(
|
||||||
|
request,
|
||||||
|
ExistingBranchPublicationState.MANUAL_OWNER_REVIEW_REQUIRED,
|
||||||
|
error_code=reason,
|
||||||
|
**dict(observation),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _finish(self, request: ExistingBranchPublicationRequest) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
observation = self._observe(request)
|
||||||
|
except Exception as exc:
|
||||||
|
return self.store.record(
|
||||||
|
request,
|
||||||
|
ExistingBranchPublicationState.RECONCILIATION_REQUIRED,
|
||||||
|
error_code=self._safe_error(exc),
|
||||||
|
)
|
||||||
|
if observation.get("authority_branch_oid") != request.expected_branch_oid:
|
||||||
|
return self._manual(request, "AUTHORITY_BRANCH_OID_CHANGED", observation)
|
||||||
|
if self._has_remote_conflict(request, observation):
|
||||||
|
return self._manual(request, "REMOTE_BRANCH_OID_CONFLICT", observation)
|
||||||
|
if (
|
||||||
|
observation.get("gitea_git_branch_oid") == request.expected_branch_oid
|
||||||
|
and observation.get("gitea_api_branch_oid") == request.expected_branch_oid
|
||||||
|
and observation.get("commit_visible") is True
|
||||||
|
):
|
||||||
|
return self.store.record(request, ExistingBranchPublicationState.COMPLETED, **dict(observation))
|
||||||
|
return self.store.record(
|
||||||
|
request,
|
||||||
|
ExistingBranchPublicationState.RECONCILIATION_REQUIRED,
|
||||||
|
error_code="REMOTE_READBACK_INCOMPLETE",
|
||||||
|
**dict(observation),
|
||||||
|
)
|
||||||
|
|
||||||
|
def publish(self, value: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
request = ExistingBranchPublicationRequest.from_mapping(value)
|
||||||
|
previous = self.store.load(request)
|
||||||
|
if previous:
|
||||||
|
if previous.get("request_digest") != request.digest:
|
||||||
|
raise ExistingBranchPublicationConflict("IDEMPOTENCY_KEY_REUSED")
|
||||||
|
if previous.get("publication_state") == ExistingBranchPublicationState.COMPLETED.value:
|
||||||
|
return {**previous, "idempotent": True}
|
||||||
|
authority_oid = self.authority.read_ref(f"refs/heads/{request.branch}")
|
||||||
|
if authority_oid is None:
|
||||||
|
raise ExistingBranchPublicationConflict("AUTHORITY_BRANCH_MISSING")
|
||||||
|
if authority_oid != request.expected_branch_oid:
|
||||||
|
raise ExistingBranchPublicationConflict("AUTHORITY_BRANCH_OID_MISMATCH")
|
||||||
|
if self.remote.repository_identity() != request.repository:
|
||||||
|
raise ExistingBranchPublicationConflict("REMOTE_REPOSITORY_IDENTITY_MISMATCH")
|
||||||
|
self.store.record(request, ExistingBranchPublicationState.QUEUED)
|
||||||
|
try:
|
||||||
|
observation = self._observe(request)
|
||||||
|
except Exception as exc:
|
||||||
|
return self.store.record(
|
||||||
|
request,
|
||||||
|
ExistingBranchPublicationState.RECONCILIATION_REQUIRED,
|
||||||
|
error_code=self._safe_error(exc),
|
||||||
|
)
|
||||||
|
if observation.get("authority_branch_oid") != request.expected_branch_oid:
|
||||||
|
return self._manual(request, "AUTHORITY_BRANCH_OID_CHANGED", observation)
|
||||||
|
if self._has_remote_conflict(request, observation):
|
||||||
|
return self._manual(request, "REMOTE_BRANCH_OID_CONFLICT", observation)
|
||||||
|
if (
|
||||||
|
observation.get("gitea_git_branch_oid") == request.expected_branch_oid
|
||||||
|
and observation.get("gitea_api_branch_oid") == request.expected_branch_oid
|
||||||
|
and observation.get("commit_visible") is True
|
||||||
|
):
|
||||||
|
return self.store.record(request, ExistingBranchPublicationState.COMPLETED, **dict(observation))
|
||||||
|
if observation.get("gitea_git_branch_oid") is not None or observation.get("gitea_api_branch_oid") is not None:
|
||||||
|
return self.store.record(
|
||||||
|
request,
|
||||||
|
ExistingBranchPublicationState.RECONCILIATION_REQUIRED,
|
||||||
|
error_code="REMOTE_READBACK_INCOMPLETE",
|
||||||
|
**dict(observation),
|
||||||
|
)
|
||||||
|
self.store.record(request, ExistingBranchPublicationState.PUBLISHING, **dict(observation))
|
||||||
|
try:
|
||||||
|
self.remote.push_exact(request.branch, None, request.expected_branch_oid)
|
||||||
|
except Exception as exc:
|
||||||
|
return self.store.record(
|
||||||
|
request,
|
||||||
|
ExistingBranchPublicationState.REMOTE_PUSH_PENDING,
|
||||||
|
error_code=self._safe_error(exc),
|
||||||
|
desired_remote_oid=request.expected_branch_oid,
|
||||||
|
**dict(observation),
|
||||||
|
)
|
||||||
|
return self._finish(request)
|
||||||
@@ -1,525 +0,0 @@
|
|||||||
"""Credential-isolated external Agent runtime executor for Online Agent Tasks.
|
|
||||||
|
|
||||||
Task payloads never choose commands. A root-owned runtime profile chooses the executable and
|
|
||||||
I/O mode. The Online Agent worker writes a hash-bound request into an exchange directory; this
|
|
||||||
uncredentialed process executes Hermes/OpenClaw and returns a bounded, sanitized result.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
from typing import Any, Mapping, Sequence
|
|
||||||
|
|
||||||
REQUEST_SCHEMA = "aios.external-agent-runtime.request.v1"
|
|
||||||
RESULT_SCHEMA = "aios.external-agent-runtime.result.v1"
|
|
||||||
PROFILE_SCHEMA = "aios.external-agent-runtime.profile.v1"
|
|
||||||
SUPPORTED_RUNTIMES = frozenset({"hermes", "openclaw"})
|
|
||||||
REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,254}$")
|
|
||||||
RUNTIME_RE = re.compile(r"^[a-z][a-z0-9_-]{2,31}$")
|
|
||||||
SENSITIVE_RE = re.compile(
|
|
||||||
r"(?i)(authorization\s*[:=]\s*bearer\s+\S+|"
|
|
||||||
r"(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S+)"
|
|
||||||
)
|
|
||||||
SECRET_ENV_MARKERS = ("TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "AUTHORIZATION", "PRIVATE_KEY")
|
|
||||||
MAX_REQUEST_BYTES = 256 * 1024
|
|
||||||
MAX_OUTPUT_BYTES = 128 * 1024
|
|
||||||
MAX_PROFILE_BYTES = 64 * 1024
|
|
||||||
DEFAULT_PROFILE_ROOT = Path("/etc/aios/external-agent-runtime.d")
|
|
||||||
DEFAULT_EXCHANGE_ROOT = Path("/var/lib/aios-external-agent-executor")
|
|
||||||
ALLOWED_EXECUTABLE_ROOTS = (
|
|
||||||
Path("/usr/bin"),
|
|
||||||
Path("/usr/local/bin"),
|
|
||||||
Path("/opt/aios/bin"),
|
|
||||||
Path("/opt/hermes"),
|
|
||||||
Path("/opt/openclaw"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ExternalAgentRuntimeError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def canonical(value: object) -> bytes:
|
|
||||||
return json.dumps(
|
|
||||||
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
||||||
).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def sha256(data: bytes) -> str:
|
|
||||||
return hashlib.sha256(data).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_text(value: str) -> str:
|
|
||||||
return SENSITIVE_RE.sub("[REDACTED]", str(value))
|
|
||||||
|
|
||||||
|
|
||||||
def _bounded_text(value: str, limit: int = MAX_OUTPUT_BYTES) -> str:
|
|
||||||
data = sanitize_text(value).encode("utf-8", "replace")
|
|
||||||
if len(data) <= limit:
|
|
||||||
return data.decode("utf-8", "replace")
|
|
||||||
marker = b"\n...[TRUNCATED]...\n"
|
|
||||||
half = max(1, (limit - len(marker)) // 2)
|
|
||||||
return (data[:half] + marker + data[-half:]).decode("utf-8", "replace")
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_runtime(value: object) -> str:
|
|
||||||
runtime = str(value or "").strip().lower()
|
|
||||||
if RUNTIME_RE.fullmatch(runtime) is None or runtime not in SUPPORTED_RUNTIMES:
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_RUNTIME_UNSUPPORTED")
|
|
||||||
return runtime
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_no_secret_env() -> None:
|
|
||||||
leaked = sorted(
|
|
||||||
key
|
|
||||||
for key, value in os.environ.items()
|
|
||||||
if value and any(marker in key.upper() for marker in SECRET_ENV_MARKERS)
|
|
||||||
)
|
|
||||||
# CREDENTIALS_DIRECTORY itself is systemd metadata; the directory is expected to be hidden.
|
|
||||||
leaked = [key for key in leaked if key != "CREDENTIALS_DIRECTORY"]
|
|
||||||
if leaked:
|
|
||||||
raise ExternalAgentRuntimeError(
|
|
||||||
"EXTERNAL_AGENT_CREDENTIAL_ENV_EXPOSED:" + ",".join(leaked)
|
|
||||||
)
|
|
||||||
credential_dir = Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials"))
|
|
||||||
try:
|
|
||||||
if credential_dir.exists() and os.access(credential_dir, os.R_OK):
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_CREDENTIAL_MOUNT_ACCESSIBLE")
|
|
||||||
except PermissionError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_executable(value: object) -> str:
|
|
||||||
if not isinstance(value, str) or not value.startswith("/"):
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_EXECUTABLE_ABSOLUTE_REQUIRED")
|
|
||||||
path = Path(value)
|
|
||||||
if path.is_symlink():
|
|
||||||
resolved = path.resolve(strict=True)
|
|
||||||
else:
|
|
||||||
resolved = path.resolve(strict=True)
|
|
||||||
if not resolved.is_file() or not os.access(resolved, os.X_OK):
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_EXECUTABLE_INVALID")
|
|
||||||
if not any(resolved == root or root in resolved.parents for root in ALLOWED_EXECUTABLE_ROOTS):
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_EXECUTABLE_ROOT_DENY")
|
|
||||||
return str(resolved)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RuntimeProfile:
|
|
||||||
runtime: str
|
|
||||||
argv: tuple[str, ...]
|
|
||||||
input_mode: str
|
|
||||||
output_mode: str
|
|
||||||
timeout_seconds: int
|
|
||||||
max_output_bytes: int
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def load(
|
|
||||||
cls,
|
|
||||||
runtime: str,
|
|
||||||
profile_root: Path = DEFAULT_PROFILE_ROOT,
|
|
||||||
) -> "RuntimeProfile":
|
|
||||||
runtime = _safe_runtime(runtime)
|
|
||||||
root = Path(profile_root)
|
|
||||||
if root.is_symlink():
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_PROFILE_ROOT_SYMLINK_DENY")
|
|
||||||
path = root / f"{runtime}.json"
|
|
||||||
if path.is_symlink() or not path.is_file() or path.stat().st_size > MAX_PROFILE_BYTES:
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_PROFILE_UNAVAILABLE")
|
|
||||||
try:
|
|
||||||
value = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_PROFILE_INVALID") from exc
|
|
||||||
if not isinstance(value, Mapping) or value.get("schema") != PROFILE_SCHEMA:
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_PROFILE_INVALID")
|
|
||||||
if _safe_runtime(value.get("runtime")) != runtime:
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_PROFILE_RUNTIME_MISMATCH")
|
|
||||||
argv = value.get("argv")
|
|
||||||
if (
|
|
||||||
not isinstance(argv, Sequence)
|
|
||||||
or isinstance(argv, (str, bytes))
|
|
||||||
or not argv
|
|
||||||
or not all(isinstance(item, str) and item and "\x00" not in item for item in argv)
|
|
||||||
):
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_PROFILE_ARGV_INVALID")
|
|
||||||
executable = _safe_executable(argv[0])
|
|
||||||
normalized = [executable, *list(argv[1:])]
|
|
||||||
if any(item in {"sh", "bash", "-c", "--command"} for item in normalized[1:]):
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_SHELL_MODE_DENY")
|
|
||||||
input_mode = str(value.get("input_mode") or "stdin_text")
|
|
||||||
output_mode = str(value.get("output_mode") or "stdout_text")
|
|
||||||
if input_mode not in {"stdin_text", "stdin_json", "file_json"}:
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_INPUT_MODE_INVALID")
|
|
||||||
if output_mode not in {"stdout_text", "stdout_json", "file_json"}:
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_OUTPUT_MODE_INVALID")
|
|
||||||
timeout = int(value.get("timeout_seconds") or 900)
|
|
||||||
max_output = int(value.get("max_output_bytes") or MAX_OUTPUT_BYTES)
|
|
||||||
if not 1 <= timeout <= 3600:
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_TIMEOUT_INVALID")
|
|
||||||
if not 1024 <= max_output <= MAX_OUTPUT_BYTES:
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_OUTPUT_LIMIT_INVALID")
|
|
||||||
return cls(runtime, tuple(normalized), input_mode, output_mode, timeout, max_output)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_request(value: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
if value.get("schema") != REQUEST_SCHEMA:
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_REQUEST_SCHEMA_INVALID")
|
|
||||||
request_id = str(value.get("request_id") or "")
|
|
||||||
if REQUEST_ID_RE.fullmatch(request_id) is None:
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_REQUEST_ID_INVALID")
|
|
||||||
runtime = _safe_runtime(value.get("runtime"))
|
|
||||||
task_id = str(value.get("task_id") or "")
|
|
||||||
execution_digest = str(value.get("execution_digest") or "")
|
|
||||||
if not task_id or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{2,179}", task_id):
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_TASK_ID_INVALID")
|
|
||||||
if re.fullmatch(r"[0-9a-f]{64}", execution_digest) is None:
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_EXECUTION_DIGEST_INVALID")
|
|
||||||
payload = value.get("payload")
|
|
||||||
if not isinstance(payload, Mapping):
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_PAYLOAD_INVALID")
|
|
||||||
encoded = canonical(payload)
|
|
||||||
if len(encoded) > MAX_REQUEST_BYTES:
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_PAYLOAD_TOO_LARGE")
|
|
||||||
goal = payload.get("goal")
|
|
||||||
instructions = payload.get("instructions")
|
|
||||||
if not isinstance(goal, str) or not goal.strip():
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_GOAL_REQUIRED")
|
|
||||||
if instructions is not None and not isinstance(instructions, str):
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_INSTRUCTIONS_INVALID")
|
|
||||||
return {
|
|
||||||
"schema": REQUEST_SCHEMA,
|
|
||||||
"request_id": request_id,
|
|
||||||
"runtime": runtime,
|
|
||||||
"task_id": task_id,
|
|
||||||
"execution_digest": execution_digest,
|
|
||||||
"payload": dict(payload),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def render_prompt(payload: Mapping[str, Any]) -> str:
|
|
||||||
goal = str(payload.get("goal") or "").strip()
|
|
||||||
instructions = str(payload.get("instructions") or "").strip()
|
|
||||||
criteria = payload.get("success_criteria") or []
|
|
||||||
constraints = payload.get("constraints") or {}
|
|
||||||
lines = [
|
|
||||||
"AIOS Online Agent Task",
|
|
||||||
"",
|
|
||||||
"Goal:",
|
|
||||||
goal,
|
|
||||||
]
|
|
||||||
if instructions:
|
|
||||||
lines.extend(["", "Instructions:", instructions])
|
|
||||||
if criteria:
|
|
||||||
lines.extend(["", "Success criteria:", json.dumps(criteria, ensure_ascii=False, sort_keys=True)])
|
|
||||||
if constraints:
|
|
||||||
lines.extend(["", "Constraints:", json.dumps(constraints, ensure_ascii=False, sort_keys=True)])
|
|
||||||
lines.extend([
|
|
||||||
"",
|
|
||||||
"Return the final task result only. Do not claim repository, production, credential, or control-plane side effects unless they are explicitly represented in the task payload.",
|
|
||||||
])
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _argv_with_files(
|
|
||||||
profile: RuntimeProfile,
|
|
||||||
input_path: Path,
|
|
||||||
output_path: Path,
|
|
||||||
) -> list[str]:
|
|
||||||
replacements = {
|
|
||||||
"{input_json}": str(input_path),
|
|
||||||
"{output_json}": str(output_path),
|
|
||||||
}
|
|
||||||
result = []
|
|
||||||
for item in profile.argv:
|
|
||||||
for marker, replacement in replacements.items():
|
|
||||||
item = item.replace(marker, replacement)
|
|
||||||
result.append(item)
|
|
||||||
if profile.input_mode == "file_json" and not any("{input_json}" in item for item in profile.argv):
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_INPUT_PLACEHOLDER_REQUIRED")
|
|
||||||
if profile.output_mode == "file_json" and not any("{output_json}" in item for item in profile.argv):
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_OUTPUT_PLACEHOLDER_REQUIRED")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_output(
|
|
||||||
profile: RuntimeProfile,
|
|
||||||
stdout: str,
|
|
||||||
output_path: Path,
|
|
||||||
) -> tuple[str, object]:
|
|
||||||
if profile.output_mode == "file_json":
|
|
||||||
if output_path.is_symlink() or not output_path.is_file():
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_OUTPUT_FILE_MISSING")
|
|
||||||
raw = output_path.read_bytes()
|
|
||||||
if len(raw) > profile.max_output_bytes:
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_OUTPUT_TOO_LARGE")
|
|
||||||
text = raw.decode("utf-8", "replace")
|
|
||||||
try:
|
|
||||||
parsed = json.loads(text)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_OUTPUT_JSON_INVALID") from exc
|
|
||||||
return _bounded_text(text, profile.max_output_bytes), parsed
|
|
||||||
if len(stdout.encode("utf-8", "replace")) > profile.max_output_bytes:
|
|
||||||
stdout = _bounded_text(stdout, profile.max_output_bytes)
|
|
||||||
if profile.output_mode == "stdout_json":
|
|
||||||
try:
|
|
||||||
parsed = json.loads(stdout)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_OUTPUT_JSON_INVALID") from exc
|
|
||||||
return _bounded_text(stdout, profile.max_output_bytes), parsed
|
|
||||||
return _bounded_text(stdout, profile.max_output_bytes), None
|
|
||||||
|
|
||||||
|
|
||||||
def execute_request(
|
|
||||||
request_path: Path,
|
|
||||||
result_path: Path,
|
|
||||||
*,
|
|
||||||
profile_root: Path = DEFAULT_PROFILE_ROOT,
|
|
||||||
) -> None:
|
|
||||||
_assert_no_secret_env()
|
|
||||||
if request_path.is_symlink() or not request_path.is_file():
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_REQUEST_FILE_INVALID")
|
|
||||||
raw_request = request_path.read_bytes()
|
|
||||||
if len(raw_request) > MAX_REQUEST_BYTES:
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_REQUEST_TOO_LARGE")
|
|
||||||
try:
|
|
||||||
request = validate_request(json.loads(raw_request.decode("utf-8")))
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_REQUEST_JSON_INVALID") from exc
|
|
||||||
profile = RuntimeProfile.load(request["runtime"], profile_root)
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory(prefix=f"aios-{request['runtime']}-") as temporary:
|
|
||||||
temp_root = Path(temporary)
|
|
||||||
input_path = temp_root / "input.json"
|
|
||||||
output_path = temp_root / "output.json"
|
|
||||||
input_path.write_bytes(canonical(request["payload"]))
|
|
||||||
argv = _argv_with_files(profile, input_path, output_path)
|
|
||||||
|
|
||||||
stdin_data = None
|
|
||||||
if profile.input_mode == "stdin_json":
|
|
||||||
stdin_data = canonical(request["payload"]).decode("utf-8")
|
|
||||||
elif profile.input_mode == "stdin_text":
|
|
||||||
stdin_data = render_prompt(request["payload"])
|
|
||||||
|
|
||||||
started = time.monotonic()
|
|
||||||
try:
|
|
||||||
completed = subprocess.run(
|
|
||||||
argv,
|
|
||||||
input=stdin_data,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
shell=False,
|
|
||||||
timeout=profile.timeout_seconds,
|
|
||||||
cwd=str(temp_root),
|
|
||||||
env={
|
|
||||||
"PATH": "/usr/bin:/usr/local/bin:/bin",
|
|
||||||
"LC_ALL": "C.UTF-8",
|
|
||||||
"LANG": "C.UTF-8",
|
|
||||||
"HOME": str(temp_root / "home"),
|
|
||||||
"NO_COLOR": "1",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except subprocess.TimeoutExpired as exc:
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_TIMEOUT") from exc
|
|
||||||
|
|
||||||
if completed.returncode != 0:
|
|
||||||
raise ExternalAgentRuntimeError(
|
|
||||||
f"EXTERNAL_AGENT_PROCESS_FAILED:{completed.returncode}:"
|
|
||||||
+ _bounded_text(completed.stderr, 2048)
|
|
||||||
)
|
|
||||||
|
|
||||||
output_text, output_json = _parse_output(profile, completed.stdout, output_path)
|
|
||||||
result: dict[str, Any] = {
|
|
||||||
"schema": RESULT_SCHEMA,
|
|
||||||
"request_id": request["request_id"],
|
|
||||||
"request_sha256": sha256(canonical(request)),
|
|
||||||
"runtime": request["runtime"],
|
|
||||||
"task_id": request["task_id"],
|
|
||||||
"execution_digest": request["execution_digest"],
|
|
||||||
"exit_code": completed.returncode,
|
|
||||||
"duration_ms": int((time.monotonic() - started) * 1000),
|
|
||||||
"output_text": output_text,
|
|
||||||
"output_sha256": sha256(output_text.encode("utf-8", "replace")),
|
|
||||||
"stderr_sha256": sha256(completed.stderr.encode("utf-8", "replace")),
|
|
||||||
"credential_material_exposed": False,
|
|
||||||
"control_plane_access_granted": False,
|
|
||||||
}
|
|
||||||
if output_json is not None:
|
|
||||||
result["output_json"] = output_json
|
|
||||||
result["result_sha256"] = sha256(canonical(result))
|
|
||||||
result_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
fd, name = tempfile.mkstemp(prefix=f".{result_path.name}.", dir=result_path.parent)
|
|
||||||
try:
|
|
||||||
os.fchmod(fd, 0o640)
|
|
||||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
||||||
json.dump(result, handle, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
||||||
handle.write("\n")
|
|
||||||
handle.flush()
|
|
||||||
os.fsync(handle.fileno())
|
|
||||||
os.replace(name, result_path)
|
|
||||||
finally:
|
|
||||||
if os.path.exists(name):
|
|
||||||
os.unlink(name)
|
|
||||||
|
|
||||||
|
|
||||||
class ExternalAgentExchange:
|
|
||||||
"""Worker-side idempotent request/result exchange."""
|
|
||||||
|
|
||||||
def __init__(self, root: Path = DEFAULT_EXCHANGE_ROOT) -> None:
|
|
||||||
self.root = Path(root)
|
|
||||||
|
|
||||||
def execute(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
request_id: str,
|
|
||||||
runtime: str,
|
|
||||||
task_id: str,
|
|
||||||
execution_digest: str,
|
|
||||||
payload: Mapping[str, Any],
|
|
||||||
timeout_seconds: int = 1800,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
request = validate_request(
|
|
||||||
{
|
|
||||||
"schema": REQUEST_SCHEMA,
|
|
||||||
"request_id": request_id,
|
|
||||||
"runtime": runtime,
|
|
||||||
"task_id": task_id,
|
|
||||||
"execution_digest": execution_digest,
|
|
||||||
"payload": dict(payload),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
requests = self.root / "requests"
|
|
||||||
results = self.root / "results"
|
|
||||||
failed = self.root / "failed"
|
|
||||||
for directory in (requests, results, failed):
|
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
|
||||||
request_path = requests / f"{request_id}.json"
|
|
||||||
result_path = results / f"{request_id}.json"
|
|
||||||
failed_path = failed / f"{request_id}.json"
|
|
||||||
request_sha = sha256(canonical(request))
|
|
||||||
|
|
||||||
if result_path.is_file():
|
|
||||||
return self._validated_result(result_path, request, request_sha)
|
|
||||||
if failed_path.is_file():
|
|
||||||
value = json.loads(failed_path.read_text(encoding="utf-8"))
|
|
||||||
raise ExternalAgentRuntimeError(
|
|
||||||
str(value.get("error") or "EXTERNAL_AGENT_EXECUTOR_FAILED")
|
|
||||||
)
|
|
||||||
|
|
||||||
if request_path.exists():
|
|
||||||
try:
|
|
||||||
existing = json.loads(request_path.read_text(encoding="utf-8"))
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_EXISTING_REQUEST_INVALID") from exc
|
|
||||||
if sha256(canonical(existing)) != request_sha:
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_REQUEST_ID_CONFLICT")
|
|
||||||
else:
|
|
||||||
fd, name = tempfile.mkstemp(prefix=f".{request_path.name}.", dir=requests)
|
|
||||||
try:
|
|
||||||
os.fchmod(fd, 0o640)
|
|
||||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
||||||
json.dump(request, handle, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
||||||
handle.write("\n")
|
|
||||||
handle.flush()
|
|
||||||
os.fsync(handle.fileno())
|
|
||||||
os.replace(name, request_path)
|
|
||||||
finally:
|
|
||||||
if os.path.exists(name):
|
|
||||||
os.unlink(name)
|
|
||||||
|
|
||||||
deadline = time.monotonic() + timeout_seconds
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
if result_path.is_file():
|
|
||||||
return self._validated_result(result_path, request, request_sha)
|
|
||||||
if failed_path.is_file():
|
|
||||||
try:
|
|
||||||
value = json.loads(failed_path.read_text(encoding="utf-8"))
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
value = {"error": "EXTERNAL_AGENT_EXECUTOR_FAILED"}
|
|
||||||
raise ExternalAgentRuntimeError(
|
|
||||||
str(value.get("error") or "EXTERNAL_AGENT_EXECUTOR_FAILED")
|
|
||||||
)
|
|
||||||
time.sleep(0.25)
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_EXCHANGE_TIMEOUT")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validated_result(
|
|
||||||
result_path: Path,
|
|
||||||
request: Mapping[str, Any],
|
|
||||||
request_sha: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
value = json.loads(result_path.read_text(encoding="utf-8"))
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_RESULT_INVALID") from exc
|
|
||||||
if (
|
|
||||||
not isinstance(value, Mapping)
|
|
||||||
or value.get("schema") != RESULT_SCHEMA
|
|
||||||
or value.get("request_id") != request["request_id"]
|
|
||||||
or value.get("request_sha256") != request_sha
|
|
||||||
or value.get("runtime") != request["runtime"]
|
|
||||||
or value.get("task_id") != request["task_id"]
|
|
||||||
or value.get("execution_digest") != request["execution_digest"]
|
|
||||||
):
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_RESULT_BINDING_INVALID")
|
|
||||||
material = dict(value)
|
|
||||||
declared = str(material.pop("result_sha256", ""))
|
|
||||||
if declared != sha256(canonical(material)):
|
|
||||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_RESULT_DIGEST_INVALID")
|
|
||||||
return dict(value)
|
|
||||||
|
|
||||||
|
|
||||||
def serve(
|
|
||||||
root: Path = DEFAULT_EXCHANGE_ROOT,
|
|
||||||
profile_root: Path = DEFAULT_PROFILE_ROOT,
|
|
||||||
) -> None:
|
|
||||||
_assert_no_secret_env()
|
|
||||||
root = Path(root)
|
|
||||||
for directory in (root / "requests", root / "results", root / "failed"):
|
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
|
||||||
while True:
|
|
||||||
for request_path in sorted((root / "requests").glob("*.json")):
|
|
||||||
if request_path.is_symlink():
|
|
||||||
continue
|
|
||||||
result_path = root / "results" / request_path.name
|
|
||||||
failed_path = root / "failed" / request_path.name
|
|
||||||
try:
|
|
||||||
execute_request(request_path, result_path, profile_root=profile_root)
|
|
||||||
request_path.unlink(missing_ok=True)
|
|
||||||
except Exception as exc:
|
|
||||||
failed_path.write_text(
|
|
||||||
json.dumps(
|
|
||||||
{"error": sanitize_text(str(exc) or type(exc).__name__)},
|
|
||||||
ensure_ascii=False,
|
|
||||||
sort_keys=True,
|
|
||||||
)
|
|
||||||
+ "\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
request_path.unlink(missing_ok=True)
|
|
||||||
time.sleep(0.25)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import argparse
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--root", default=str(DEFAULT_EXCHANGE_ROOT))
|
|
||||||
parser.add_argument("--profile-root", default=str(DEFAULT_PROFILE_ROOT))
|
|
||||||
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),
|
|
||||||
profile_root=Path(args.profile_root),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
serve(Path(args.root), Path(args.profile_root))
|
|
||||||
@@ -27,19 +27,6 @@ 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
|
||||||
|
|
||||||
@@ -79,14 +66,6 @@ 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"
|
||||||
@@ -425,16 +404,13 @@ 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", lease_expires_at: str | None = None) -> None:
|
def update_lease(self, lease_id: str, *, expected_revision: int, current_head_oid: str, generation: int, state: str = "ACTIVE") -> 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()
|
||||||
@@ -443,94 +419,6 @@ 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,
|
||||||
|
|||||||
@@ -1,306 +0,0 @@
|
|||||||
"""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))
|
|
||||||
@@ -3,6 +3,12 @@ from __future__ import annotations
|
|||||||
import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, urllib.request, uuid
|
import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, urllib.request, uuid
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from version_registry import (
|
||||||
|
configured_authoritative_git_dir,
|
||||||
|
configured_repository_identity,
|
||||||
|
list_registry_versions,
|
||||||
|
list_registry_versions_report,
|
||||||
|
)
|
||||||
from baseline_authority import BaselineAuthority, BaselineAuthorityError
|
from baseline_authority import BaselineAuthority, BaselineAuthorityError
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -15,6 +21,9 @@ except ModuleNotFoundError:
|
|||||||
from gitea_tag_publication import (
|
from gitea_tag_publication import (
|
||||||
GiteaTagClient,
|
GiteaTagClient,
|
||||||
GitTagBackend,
|
GitTagBackend,
|
||||||
|
GiteaConfigurationError,
|
||||||
|
InvalidReleaseVersion,
|
||||||
|
PublicationError,
|
||||||
PublicationConflict,
|
PublicationConflict,
|
||||||
PublicationPending,
|
PublicationPending,
|
||||||
PublicationManualOwnerReviewRequired,
|
PublicationManualOwnerReviewRequired,
|
||||||
@@ -73,10 +82,13 @@ HOST = os.getenv('AIOS_GITOPS_HOST', '127.0.0.1')
|
|||||||
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
|
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
|
||||||
OPENAPI = WS / 'docs' / 'gitops' / 'openapi-chatgpt-actions.yaml'
|
OPENAPI = WS / 'docs' / 'gitops' / 'openapi-chatgpt-actions.yaml'
|
||||||
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios.git')))
|
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios.git')))
|
||||||
AUTHORITATIVE_GIT_DIR = Path(os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR')) if os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR') else None
|
AUTHORITATIVE_GIT_DIR = configured_authoritative_git_dir()
|
||||||
REPOSITORY_IDENTITY = os.getenv('AIOS_GITOPS_REPOSITORY_IDENTITY', '')
|
REPOSITORY_IDENTITY = configured_repository_identity()
|
||||||
BASELINE_AUTHORITY = BaselineAuthority()
|
BASELINE_AUTHORITY = BaselineAuthority()
|
||||||
GIT_DIR = Path(os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR') or os.getenv('AIOS_GITOPS_REPO', '/data/aios-gitops/repo.git'))
|
# Retain the generic Git directory only for legacy/staging compatibility
|
||||||
|
# commands. Source-registry convergence receives the separate authority
|
||||||
|
# below and never falls back to this path.
|
||||||
|
GIT_DIR = Path(os.getenv('AIOS_GITOPS_REPO', '/data/aios-gitops/repo.git'))
|
||||||
LEGACY_GIT_WRITE_COMMANDS = frozenset({'add','commit','checkout','branch','tag','push','fetch','reset','merge','rebase','update-ref','worktree'})
|
LEGACY_GIT_WRITE_COMMANDS = frozenset({'add','commit','checkout','branch','tag','push','fetch','reset','merge','rebase','update-ref','worktree'})
|
||||||
SILENT_RELEASE_STATE_ROOT = Path(os.getenv(
|
SILENT_RELEASE_STATE_ROOT = Path(os.getenv(
|
||||||
'AIOS_SILENT_RELEASE_STATE_ROOT',
|
'AIOS_SILENT_RELEASE_STATE_ROOT',
|
||||||
@@ -358,12 +370,7 @@ def publication_publisher() -> TagPublisher:
|
|||||||
return TagPublisher(
|
return TagPublisher(
|
||||||
WS,
|
WS,
|
||||||
GitTagBackend(WS, authoritative_repo=authoritative_repo),
|
GitTagBackend(WS, authoritative_repo=authoritative_repo),
|
||||||
GiteaTagClient(
|
GiteaTagClient.from_authoritative_environment(),
|
||||||
os.getenv('AIOS_GITOPS_GITEA_API_URL',''),
|
|
||||||
os.getenv('AIOS_GITOPS_GITEA_REPOSITORY',''),
|
|
||||||
Path(os.getenv('AIOS_GITOPS_GITEA_TOKEN_FILE','')),
|
|
||||||
token_line=int(os.getenv('AIOS_GITOPS_GITEA_TOKEN_LINE','1')),
|
|
||||||
),
|
|
||||||
os.getenv('AIOS_GITOPS_GITEA_REMOTE','gitea'),
|
os.getenv('AIOS_GITOPS_GITEA_REMOTE','gitea'),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -495,6 +502,11 @@ class H(BaseHTTPRequestHandler):
|
|||||||
data['legacy_global_writes_enabled'] = False
|
data['legacy_global_writes_enabled'] = False
|
||||||
return self.sendj(data)
|
return self.sendj(data)
|
||||||
if path in ('/api/ver','/api/versions','/ver'):
|
if path in ('/api/ver','/api/versions','/ver'):
|
||||||
|
registry_report = list_registry_versions_report(
|
||||||
|
WS,
|
||||||
|
baseline_file=BASELINE_AUTHORITY.path,
|
||||||
|
repo_git_dir=AUTHORITATIVE_GIT_DIR,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
baseline = dict(BASELINE_AUTHORITY.read())
|
baseline = dict(BASELINE_AUTHORITY.read())
|
||||||
except BaselineAuthorityError as exc:
|
except BaselineAuthorityError as exc:
|
||||||
@@ -507,7 +519,8 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.sendj({
|
return self.sendj({
|
||||||
'repository_available': False,
|
'repository_available': False,
|
||||||
'query_error': snapshot['query_error'],
|
'query_error': snapshot['query_error'],
|
||||||
'registry_versions': [p.name for p in (WS/'versions').glob('*')],
|
'registry_versions': registry_report['registry_versions'],
|
||||||
|
**{key: registry_report[key] for key in ('effective_registry_versions', 'runtime_registry_versions', 'attested_projection_versions', 'attested_projection_version', 'source_registry_converged', 'runtime_registry_converged', 'runtime_bundle_contains_current_version', 'runtime_bundle_contains_version', 'registry_consistency_status')},
|
||||||
'local_authoritative_tags': [],
|
'local_authoritative_tags': [],
|
||||||
'baseline_version': baseline.get('current_baseline'),
|
'baseline_version': baseline.get('current_baseline'),
|
||||||
'baseline_generation': baseline.get('generation'),
|
'baseline_generation': baseline.get('generation'),
|
||||||
@@ -519,13 +532,14 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.sendj({
|
return self.sendj({
|
||||||
'repository_available': False,
|
'repository_available': False,
|
||||||
'query_error': str(exc),
|
'query_error': str(exc),
|
||||||
'registry_versions': [p.name for p in (WS/'versions').glob('*')],
|
'registry_versions': registry_report['registry_versions'],
|
||||||
|
**{key: registry_report[key] for key in ('effective_registry_versions', 'runtime_registry_versions', 'attested_projection_versions', 'attested_projection_version', 'source_registry_converged', 'runtime_registry_converged', 'runtime_bundle_contains_current_version', 'runtime_bundle_contains_version', 'registry_consistency_status')},
|
||||||
'local_authoritative_tags': [],
|
'local_authoritative_tags': [],
|
||||||
'baseline_version': baseline.get('current_baseline'),
|
'baseline_version': baseline.get('current_baseline'),
|
||||||
'baseline_generation': baseline.get('generation'),
|
'baseline_generation': baseline.get('generation'),
|
||||||
'baseline_sha256': baseline.get('authority_sha256'),
|
'baseline_sha256': baseline.get('authority_sha256'),
|
||||||
}, 503)
|
}, 503)
|
||||||
registry = [p.name for p in (WS/'versions').glob('*')]
|
registry = registry_report['registry_versions']
|
||||||
return self.sendj({
|
return self.sendj({
|
||||||
'registry_versions': registry,
|
'registry_versions': registry,
|
||||||
'local_authoritative_tags': tags,
|
'local_authoritative_tags': tags,
|
||||||
@@ -539,6 +553,7 @@ class H(BaseHTTPRequestHandler):
|
|||||||
'baseline_generation': baseline.get('generation'),
|
'baseline_generation': baseline.get('generation'),
|
||||||
'baseline_sha256': baseline.get('authority_sha256'),
|
'baseline_sha256': baseline.get('authority_sha256'),
|
||||||
'baseline': baseline,
|
'baseline': baseline,
|
||||||
|
**{key: registry_report[key] for key in ('effective_registry_versions', 'runtime_registry_versions', 'attested_projection_versions', 'attested_projection_version', 'source_registry_converged', 'runtime_registry_converged', 'runtime_bundle_contains_current_version', 'runtime_bundle_contains_version', 'registry_consistency_status')},
|
||||||
})
|
})
|
||||||
if path in ('/api/diff','/diff'):
|
if path in ('/api/diff','/diff'):
|
||||||
frm=(q.get('from') or [''])[0]
|
frm=(q.get('from') or [''])[0]
|
||||||
@@ -634,12 +649,22 @@ class H(BaseHTTPRequestHandler):
|
|||||||
try:
|
try:
|
||||||
version=validate_version(version)
|
version=validate_version(version)
|
||||||
status=publication_publisher().get_tag_publication_status(version)
|
status=publication_publisher().get_tag_publication_status(version)
|
||||||
except ValueError:
|
except InvalidReleaseVersion as exc:
|
||||||
return self.sendj({'error':'invalid release version'},400)
|
return self.sendj({'error': exc.code},400)
|
||||||
|
except GiteaConfigurationError as exc:
|
||||||
|
return self.sendj({'error': exc.code},503)
|
||||||
|
except PublicationError as exc:
|
||||||
|
return self.sendj({'error': getattr(exc, 'code', str(exc))},503)
|
||||||
return self.sendj(status)
|
return self.sendj(status)
|
||||||
if path == '/api/release-consistency':
|
if path == '/api/release-consistency':
|
||||||
request=publication_request_from_query(q)
|
request=publication_request_from_query(q)
|
||||||
return self.sendj(publication_publisher().get_release_consistency(request))
|
try:
|
||||||
|
result=publication_publisher().get_release_consistency(request)
|
||||||
|
except GiteaConfigurationError as exc:
|
||||||
|
return self.sendj({'error': exc.code},503)
|
||||||
|
except PublicationError as exc:
|
||||||
|
return self.sendj({'error': getattr(exc, 'code', str(exc))},503)
|
||||||
|
return self.sendj(result)
|
||||||
if path == '/api/release-acceptance':
|
if path == '/api/release-acceptance':
|
||||||
request=publication_request_from_query(q)
|
request=publication_request_from_query(q)
|
||||||
return self.sendj(publication_publisher().run_release_acceptance(request))
|
return self.sendj(publication_publisher().run_release_acceptance(request))
|
||||||
@@ -1019,4 +1044,3 @@ 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()
|
||||||
|
|
||||||
|
|||||||
@@ -1,301 +0,0 @@
|
|||||||
"""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,
|
|
||||||
"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
|
|
||||||
@@ -1,463 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,482 +0,0 @@
|
|||||||
"""Canonical governed execution projection for Online Agent Tasks.
|
|
||||||
|
|
||||||
The durable governed binding is authoritative. Natural-language task fields and
|
|
||||||
worker configuration are never allowed to invent repository, ref, generation, or
|
|
||||||
idempotency coordinates.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import asdict, dataclass
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Mapping, Sequence
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
|
|
||||||
EXECUTION_SCHEMA = "aios.online-agent.execution.v2"
|
|
||||||
PROJECTION_SCHEMA = "aios.online-agent.task-projection.v1"
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
||||||
TASK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
|
||||||
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
|
||||||
KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,159}$")
|
|
||||||
PROTECTED_PREFIXES = ("main", "master", "refs/", "tags/", "tag/", "release/", "baseline/", "production/")
|
|
||||||
MUTATION_ACTIONS = frozenset({"deterministic"})
|
|
||||||
READ_ONLY_ACTIONS = frozenset({"source_capture", "agent_runtime"})
|
|
||||||
SUPPORTED_ACTIONS = frozenset({"deterministic", "source_capture", "agent_runtime"})
|
|
||||||
AGENT_RUNTIME_ADAPTERS = frozenset({"hermes", "openclaw"})
|
|
||||||
MAX_AGENT_RUNTIME_INPUT_BYTES = 64 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
class ExecutionProjectionError(RuntimeError):
|
|
||||||
"""A canonical fail-closed projection or validation error."""
|
|
||||||
|
|
||||||
|
|
||||||
class OnlineTaskProjectionClient:
|
|
||||||
"""Official HTTP projection client using only a control credential reference."""
|
|
||||||
|
|
||||||
def __init__(self, api_base: str, control_token_path: Path) -> None:
|
|
||||||
self.api_base = api_base.rstrip("/")
|
|
||||||
self.control_token_path = Path(control_token_path)
|
|
||||||
|
|
||||||
def _token(self) -> str:
|
|
||||||
path = self.control_token_path
|
|
||||||
if path.is_symlink() or not path.is_file():
|
|
||||||
_error("ONLINE_TASK_PROJECTION_CONTROL_UNAVAILABLE")
|
|
||||||
value = path.read_text(encoding="utf-8").strip()
|
|
||||||
if len(value) < 32:
|
|
||||||
_error("ONLINE_TASK_PROJECTION_CONTROL_INVALID")
|
|
||||||
return value
|
|
||||||
|
|
||||||
def create(
|
|
||||||
self,
|
|
||||||
projected_task: Mapping[str, Any],
|
|
||||||
*,
|
|
||||||
binding: "GovernedExecutionBinding",
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
body = dict(projected_task)
|
|
||||||
payload = _canonical(body).encode("utf-8")
|
|
||||||
request = urllib.request.Request(
|
|
||||||
self.api_base + "/v1/tasks",
|
|
||||||
method="POST",
|
|
||||||
data=payload,
|
|
||||||
headers={
|
|
||||||
"Authorization": "Bearer " + self._token(),
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Accept": "application/json",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=15) as response:
|
|
||||||
result = json.loads(response.read().decode("utf-8"))
|
|
||||||
status = response.status
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
raw = exc.read(8192).decode("utf-8", "replace")
|
|
||||||
try:
|
|
||||||
error = json.loads(raw).get("error")
|
|
||||||
except (AttributeError, json.JSONDecodeError):
|
|
||||||
error = "HTTP_ERROR"
|
|
||||||
raise ExecutionProjectionError(f"ONLINE_TASK_PROJECTION_FAILED:{exc.code}:{error}") from exc
|
|
||||||
except (OSError, urllib.error.URLError, TimeoutError) as exc:
|
|
||||||
raise ExecutionProjectionError("ONLINE_TASK_PROJECTION_UNAVAILABLE") from exc
|
|
||||||
if status not in {200, 201} or not isinstance(result, dict):
|
|
||||||
_error("ONLINE_TASK_PROJECTION_RESPONSE_INVALID")
|
|
||||||
if result.get("task_id") != binding.task_id or result.get("status") not in {"QUEUED", "VERIFYING", "SUCCEEDED"}:
|
|
||||||
_error("ONLINE_TASK_PROJECTION_RESPONSE_INVALID")
|
|
||||||
metadata = result.get("metadata")
|
|
||||||
if not isinstance(metadata, Mapping):
|
|
||||||
_error("ONLINE_TASK_PROJECTION_RESPONSE_INVALID")
|
|
||||||
returned_execution = validate_execution_envelope(
|
|
||||||
_object(metadata.get("execution"), "ONLINE_TASK_PROJECTION_RESPONSE_INVALID"),
|
|
||||||
binding=binding,
|
|
||||||
)
|
|
||||||
submitted_metadata = _object(body.get("metadata"), "ONLINE_TASK_PROJECTION_REQUEST_INVALID")
|
|
||||||
submitted_execution = validate_execution_envelope(
|
|
||||||
_object(submitted_metadata.get("execution"), "ONLINE_TASK_PROJECTION_REQUEST_INVALID"),
|
|
||||||
binding=binding,
|
|
||||||
)
|
|
||||||
if _canonical(returned_execution) != _canonical(submitted_execution):
|
|
||||||
_error("ONLINE_TASK_PROJECTION_RESPONSE_BINDING_INVALID")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _error(code: str) -> None:
|
|
||||||
raise ExecutionProjectionError(code)
|
|
||||||
|
|
||||||
|
|
||||||
def _object(value: object, code: str) -> dict[str, Any]:
|
|
||||||
if not isinstance(value, Mapping):
|
|
||||||
_error(code)
|
|
||||||
return dict(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _string(value: object, code: str) -> str:
|
|
||||||
if not isinstance(value, str) or not value.strip():
|
|
||||||
_error(code)
|
|
||||||
return value.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _task_id(value: object) -> str:
|
|
||||||
value = _string(value, "TASK_ID_INVALID")
|
|
||||||
if TASK_RE.fullmatch(value) is None:
|
|
||||||
_error("TASK_ID_INVALID")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _oid(value: object, code: str) -> str:
|
|
||||||
value = _string(value, code)
|
|
||||||
if OID_RE.fullmatch(value) is None:
|
|
||||||
_error(code)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _branch(value: object) -> str:
|
|
||||||
value = _string(value, "TARGET_BRANCH_INVALID")
|
|
||||||
if BRANCH_RE.fullmatch(value) is None or value.startswith(PROTECTED_PREFIXES):
|
|
||||||
_error("TARGET_BRANCH_INVALID")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _generation(value: object) -> int:
|
|
||||||
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
|
|
||||||
_error("GENERATION_INVALID")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical(value: object) -> str:
|
|
||||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
||||||
|
|
||||||
|
|
||||||
def _digest(value: object) -> str:
|
|
||||||
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _source_signals(value: object) -> tuple[str, ...]:
|
|
||||||
"""Normalize untrusted natural-language hints before they enter a signed envelope."""
|
|
||||||
if value is None:
|
|
||||||
return ()
|
|
||||||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
|
||||||
_error("SOURCE_CAPTURE_SIGNALS_INVALID")
|
|
||||||
result: list[str] = []
|
|
||||||
for item in value:
|
|
||||||
if not isinstance(item, str):
|
|
||||||
_error("SOURCE_CAPTURE_SIGNALS_INVALID")
|
|
||||||
text = " ".join(item.split()).strip()
|
|
||||||
if not text:
|
|
||||||
continue
|
|
||||||
# Projection may accept long task prose, but execution envelopes stay bounded.
|
|
||||||
text = text[:512].rstrip()
|
|
||||||
if text not in result:
|
|
||||||
result.append(text)
|
|
||||||
if len(result) == 16:
|
|
||||||
break
|
|
||||||
return tuple(result)
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_runtime_input(value: object) -> dict[str, Any]:
|
|
||||||
if not isinstance(value, Mapping):
|
|
||||||
_error("AGENT_RUNTIME_INPUT_REQUIRED")
|
|
||||||
raw = dict(value)
|
|
||||||
allowed = {"goal", "instructions", "success_criteria", "constraints"}
|
|
||||||
if set(raw) - allowed:
|
|
||||||
_error("AGENT_RUNTIME_INPUT_FIELDS_INVALID")
|
|
||||||
goal = _string(raw.get("goal"), "AGENT_RUNTIME_GOAL_REQUIRED")
|
|
||||||
instructions = raw.get("instructions", "")
|
|
||||||
if not isinstance(instructions, str):
|
|
||||||
_error("AGENT_RUNTIME_INSTRUCTIONS_INVALID")
|
|
||||||
criteria = raw.get("success_criteria", [])
|
|
||||||
if not isinstance(criteria, list) or len(criteria) > 32:
|
|
||||||
_error("AGENT_RUNTIME_SUCCESS_CRITERIA_INVALID")
|
|
||||||
normalized_criteria: list[dict[str, str]] = []
|
|
||||||
for item in criteria:
|
|
||||||
if not isinstance(item, Mapping):
|
|
||||||
_error("AGENT_RUNTIME_SUCCESS_CRITERIA_INVALID")
|
|
||||||
criterion_id = _string(item.get("id"), "AGENT_RUNTIME_SUCCESS_CRITERIA_INVALID")
|
|
||||||
description = _string(
|
|
||||||
item.get("description"), "AGENT_RUNTIME_SUCCESS_CRITERIA_INVALID"
|
|
||||||
)
|
|
||||||
normalized_criteria.append(
|
|
||||||
{"id": criterion_id[:120], "description": description[:1000]}
|
|
||||||
)
|
|
||||||
constraints = raw.get("constraints", {})
|
|
||||||
if not isinstance(constraints, Mapping):
|
|
||||||
_error("AGENT_RUNTIME_CONSTRAINTS_INVALID")
|
|
||||||
sensitive_markers = ("token", "secret", "password", "credential", "authorization", "api_key", "apikey")
|
|
||||||
for key in constraints:
|
|
||||||
lowered = str(key).lower()
|
|
||||||
if any(marker in lowered for marker in sensitive_markers):
|
|
||||||
_error("AGENT_RUNTIME_SECRET_FIELD_DENY")
|
|
||||||
normalized = {
|
|
||||||
"goal": goal[:12000],
|
|
||||||
"instructions": instructions[:32000],
|
|
||||||
"success_criteria": normalized_criteria,
|
|
||||||
"constraints": dict(constraints),
|
|
||||||
}
|
|
||||||
if len(_canonical(normalized).encode("utf-8")) > MAX_AGENT_RUNTIME_INPUT_BYTES:
|
|
||||||
_error("AGENT_RUNTIME_INPUT_TOO_LARGE")
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def _capabilities(value: object) -> tuple[str, ...]:
|
|
||||||
if value is None:
|
|
||||||
return ()
|
|
||||||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
|
||||||
_error("CAPABILITY_SCOPE_INVALID")
|
|
||||||
result = tuple(sorted({_string(item, "CAPABILITY_SCOPE_INVALID") for item in value}))
|
|
||||||
if any(len(item) > 120 for item in result):
|
|
||||||
_error("CAPABILITY_SCOPE_INVALID")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class GovernedExecutionBinding:
|
|
||||||
task_id: str
|
|
||||||
job_id: str
|
|
||||||
repository: str
|
|
||||||
base_oid: str
|
|
||||||
expected_branch_oid: str
|
|
||||||
target_branch: str
|
|
||||||
branch_mode: str
|
|
||||||
generation: int
|
|
||||||
idempotency_key: str
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_mapping(cls, value: Mapping[str, Any]) -> "GovernedExecutionBinding":
|
|
||||||
data = dict(value)
|
|
||||||
task_id = _task_id(data.get("task_id"))
|
|
||||||
job_id = _string(data.get("job_id"), "JOB_ID_REQUIRED")
|
|
||||||
if TASK_RE.fullmatch(job_id) is None:
|
|
||||||
_error("JOB_ID_INVALID")
|
|
||||||
repository = _string(data.get("repository"), "REPOSITORY_INVALID")
|
|
||||||
if repository != "aios/aios":
|
|
||||||
_error("WRONG_REPOSITORY_ENVELOPE_DENY")
|
|
||||||
base_oid = _oid(data.get("base_oid"), "BASE_OID_INVALID")
|
|
||||||
expected = _oid(data.get("expected_branch_oid"), "EXPECTED_BRANCH_OID_INVALID")
|
|
||||||
target = _branch(data.get("target_branch"))
|
|
||||||
mode = _string(data.get("branch_mode"), "BRANCH_MODE_MISSING")
|
|
||||||
if mode not in {"CREATE_NEW", "CONTINUE_EXISTING"}:
|
|
||||||
_error("BRANCH_MODE_INVALID")
|
|
||||||
generation = _generation(data.get("generation"))
|
|
||||||
if mode == "CREATE_NEW" and expected != base_oid:
|
|
||||||
_error("EXPECTED_BASE_CONFLICT")
|
|
||||||
key = _string(data.get("idempotency_key"), "IDEMPOTENCY_KEY_INVALID")
|
|
||||||
if KEY_RE.fullmatch(key) is None:
|
|
||||||
_error("IDEMPOTENCY_KEY_INVALID")
|
|
||||||
return cls(task_id, job_id, repository, base_oid, expected, target, mode, generation, key)
|
|
||||||
|
|
||||||
def public(self) -> dict[str, Any]:
|
|
||||||
return asdict(self)
|
|
||||||
|
|
||||||
|
|
||||||
def binding_from_bootstrap(value: Mapping[str, Any]) -> GovernedExecutionBinding:
|
|
||||||
return GovernedExecutionBinding.from_mapping(value)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_execution_envelope(
|
|
||||||
value: Mapping[str, Any],
|
|
||||||
*,
|
|
||||||
binding: GovernedExecutionBinding | None = None,
|
|
||||||
worker_capabilities: Sequence[str] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
envelope = dict(value)
|
|
||||||
if envelope.get("schema") != EXECUTION_SCHEMA:
|
|
||||||
_error("EXECUTION_ENVELOPE_SCHEMA_VALIDATION")
|
|
||||||
candidate = GovernedExecutionBinding.from_mapping(envelope)
|
|
||||||
if binding is not None and candidate != binding:
|
|
||||||
comparisons = (
|
|
||||||
(candidate.task_id, binding.task_id, "TASK_BINDING_CONFLICT"),
|
|
||||||
(candidate.job_id, binding.job_id, "JOB_BINDING_CONFLICT"),
|
|
||||||
(candidate.repository, binding.repository, "WRONG_REPOSITORY_ENVELOPE_DENY"),
|
|
||||||
(candidate.base_oid, binding.base_oid, "WRONG_BASE_ENVELOPE_DENY"),
|
|
||||||
(candidate.expected_branch_oid, binding.expected_branch_oid, "WRONG_EXPECTED_HEAD_ENVELOPE_DENY"),
|
|
||||||
(candidate.target_branch, binding.target_branch, "WRONG_BRANCH_ENVELOPE_DENY"),
|
|
||||||
(candidate.generation, binding.generation, "WRONG_GENERATION_ENVELOPE_DENY"),
|
|
||||||
(candidate.branch_mode, binding.branch_mode, "BRANCH_MODE_BINDING_CONFLICT"),
|
|
||||||
(candidate.idempotency_key, binding.idempotency_key, "IDEMPOTENCY_BINDING_CONFLICT"),
|
|
||||||
)
|
|
||||||
for actual, expected, code in comparisons:
|
|
||||||
if actual != expected:
|
|
||||||
_error(code)
|
|
||||||
action = _string(envelope.get("action"), "EXECUTION_ACTION_MISSING")
|
|
||||||
if action not in SUPPORTED_ACTIONS:
|
|
||||||
_error("EXECUTION_ACTION_UNSUPPORTED")
|
|
||||||
capability_scope = _capabilities(envelope.get("capability_scope"))
|
|
||||||
if worker_capabilities is not None and not set(capability_scope).issubset(set(worker_capabilities)):
|
|
||||||
_error("CAPABILITY_MISMATCH_DENY")
|
|
||||||
workspace = _object(envelope.get("workspace"), "WORKSPACE_POLICY_REQUIRED")
|
|
||||||
if workspace.get("authority_git_dir") != "/data/aios-gitops/repo.git" or workspace.get("read_only_repository") is not True:
|
|
||||||
_error("WORKSPACE_POLICY_DENY")
|
|
||||||
governance = _object(envelope.get("governance"), "GOVERNANCE_POLICY_REQUIRED")
|
|
||||||
governed_coordinates = (
|
|
||||||
(governance.get("task_id"), candidate.task_id, "TASK_BINDING_CONFLICT"),
|
|
||||||
(governance.get("job_id"), candidate.job_id, "JOB_BINDING_CONFLICT"),
|
|
||||||
(governance.get("idempotency_key"), candidate.idempotency_key, "IDEMPOTENCY_BINDING_CONFLICT"),
|
|
||||||
)
|
|
||||||
for actual_coordinate, expected_coordinate, code in governed_coordinates:
|
|
||||||
if actual_coordinate != expected_coordinate:
|
|
||||||
_error(code)
|
|
||||||
commit_key = _string(governance.get("commit_idempotency_key"), "COMMIT_IDEMPOTENCY_KEY_INVALID")
|
|
||||||
if KEY_RE.fullmatch(commit_key) is None:
|
|
||||||
_error("COMMIT_IDEMPOTENCY_KEY_INVALID")
|
|
||||||
if governance.get("writer_lease") is not (action in MUTATION_ACTIONS):
|
|
||||||
_error("WRITER_LEASE_POLICY_DENY")
|
|
||||||
for field in ("main_write", "production_write", "tag_write", "baseline_write", "force_push", "protected_ref_bypass"):
|
|
||||||
if governance.get(field, False) is not False:
|
|
||||||
_error("POLICY_DENY")
|
|
||||||
changes = envelope.get("change_set")
|
|
||||||
verification = envelope.get("verification")
|
|
||||||
if not isinstance(changes, list) or not isinstance(verification, list):
|
|
||||||
_error("EXECUTION_OPERATIONS_INVALID")
|
|
||||||
if action == "source_capture":
|
|
||||||
if changes or verification:
|
|
||||||
_error("SOURCE_CAPTURE_MUTATION_DENY")
|
|
||||||
signals = list(_source_signals(envelope.get("source_signals") or []))
|
|
||||||
if envelope.get("source_signals") != signals:
|
|
||||||
_error("SOURCE_CAPTURE_SIGNALS_INVALID")
|
|
||||||
if envelope.get("runtime_input") not in (None, {}):
|
|
||||||
_error("SOURCE_CAPTURE_RUNTIME_INPUT_DENY")
|
|
||||||
elif action == "agent_runtime":
|
|
||||||
if changes or verification:
|
|
||||||
_error("AGENT_RUNTIME_MUTATION_DENY")
|
|
||||||
adapter = str(envelope.get("adapter") or "")
|
|
||||||
if adapter not in AGENT_RUNTIME_ADAPTERS:
|
|
||||||
_error("AGENT_RUNTIME_ADAPTER_INVALID")
|
|
||||||
runtime_input = _agent_runtime_input(envelope.get("runtime_input"))
|
|
||||||
if envelope.get("runtime_input") != runtime_input:
|
|
||||||
_error("AGENT_RUNTIME_INPUT_NOT_CANONICAL")
|
|
||||||
required_runtime_capability = f"agent.runtime.{adapter}"
|
|
||||||
if required_runtime_capability not in capability_scope:
|
|
||||||
_error("AGENT_RUNTIME_CAPABILITY_REQUIRED")
|
|
||||||
if envelope.get("source_signals") not in (None, []):
|
|
||||||
_error("AGENT_RUNTIME_SOURCE_SIGNALS_DENY")
|
|
||||||
elif not changes:
|
|
||||||
_error("CHANGE_SET_INVALID")
|
|
||||||
declared = str(envelope.get("execution_digest") or "")
|
|
||||||
material = {key: item for key, item in envelope.items() if key != "execution_digest"}
|
|
||||||
actual = _digest(material)
|
|
||||||
if declared != actual:
|
|
||||||
_error("EXECUTION_DIGEST_INVALID")
|
|
||||||
return {**material, "execution_digest": actual}
|
|
||||||
|
|
||||||
|
|
||||||
def project_execution_envelope(
|
|
||||||
binding: GovernedExecutionBinding,
|
|
||||||
execution_request: Mapping[str, Any] | None = None,
|
|
||||||
*,
|
|
||||||
required_capabilities: Sequence[str] = (),
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
request = dict(execution_request or {})
|
|
||||||
action = str(request.get("action") or "source_capture")
|
|
||||||
if action not in SUPPORTED_ACTIONS:
|
|
||||||
_error("EXECUTION_ACTION_UNSUPPORTED")
|
|
||||||
capabilities = _capabilities(request.get("capability_scope", required_capabilities))
|
|
||||||
governance = {
|
|
||||||
"task_id": binding.task_id,
|
|
||||||
"job_id": binding.job_id,
|
|
||||||
"idempotency_key": binding.idempotency_key,
|
|
||||||
"commit_idempotency_key": str(request.get("commit_idempotency_key") or f"{binding.task_id}-commit-g{binding.generation}"),
|
|
||||||
"writer_lease": action in MUTATION_ACTIONS,
|
|
||||||
"main_write": False,
|
|
||||||
"production_write": False,
|
|
||||||
"tag_write": False,
|
|
||||||
"baseline_write": False,
|
|
||||||
"force_push": False,
|
|
||||||
"protected_ref_bypass": False,
|
|
||||||
}
|
|
||||||
supplied_governance = request.get("governance")
|
|
||||||
if supplied_governance is not None:
|
|
||||||
governance.update(_object(supplied_governance, "GOVERNANCE_POLICY_REQUIRED"))
|
|
||||||
material: dict[str, Any] = {
|
|
||||||
"schema": EXECUTION_SCHEMA,
|
|
||||||
**binding.public(),
|
|
||||||
"action": action,
|
|
||||||
"adapter": str(
|
|
||||||
request.get("adapter")
|
|
||||||
or ("deterministic" if action == "deterministic" else "source_capture")
|
|
||||||
),
|
|
||||||
"capability_scope": list(capabilities),
|
|
||||||
"change_set": list(request.get("change_set") or []),
|
|
||||||
"verification": list(request.get("verification") or []),
|
|
||||||
"source_signals": list(_source_signals(request.get("source_signals") or [])) if action == "source_capture" else [],
|
|
||||||
"runtime_input": _agent_runtime_input(request.get("runtime_input")) if action == "agent_runtime" else {},
|
|
||||||
"governance": governance,
|
|
||||||
"workspace": {
|
|
||||||
"authority_git_dir": "/data/aios-gitops/repo.git",
|
|
||||||
"read_only_repository": True,
|
|
||||||
"cleanup_policy": "always",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
envelope = {**material, "execution_digest": _digest(material)}
|
|
||||||
return validate_execution_envelope(envelope, binding=binding)
|
|
||||||
|
|
||||||
|
|
||||||
def project_online_task(
|
|
||||||
binding: GovernedExecutionBinding,
|
|
||||||
task_request: Mapping[str, Any],
|
|
||||||
execution_request: Mapping[str, Any] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
request = dict(task_request)
|
|
||||||
required = _capabilities(request.get("required_capabilities"))
|
|
||||||
execution_value = dict(execution_request or {})
|
|
||||||
if str(execution_value.get("action") or "source_capture") == "agent_runtime":
|
|
||||||
adapter = str(execution_value.get("adapter") or "")
|
|
||||||
if adapter not in AGENT_RUNTIME_ADAPTERS:
|
|
||||||
_error("AGENT_RUNTIME_ADAPTER_INVALID")
|
|
||||||
runtime_input = execution_value.get("runtime_input")
|
|
||||||
if runtime_input is None:
|
|
||||||
runtime_input = {
|
|
||||||
"goal": request.get("goal"),
|
|
||||||
"instructions": request.get("instructions") or "",
|
|
||||||
"success_criteria": request.get("success_criteria") or [],
|
|
||||||
"constraints": request.get("constraints") or {},
|
|
||||||
}
|
|
||||||
execution_value["runtime_input"] = runtime_input
|
|
||||||
execution = project_execution_envelope(
|
|
||||||
binding,
|
|
||||||
execution_value,
|
|
||||||
required_capabilities=required,
|
|
||||||
)
|
|
||||||
metadata = dict(request.get("metadata") or {})
|
|
||||||
if "execution" in metadata:
|
|
||||||
validate_execution_envelope(_object(metadata["execution"], "EXECUTION_ENVELOPE_INVALID"), binding=binding)
|
|
||||||
if metadata["execution"] != execution:
|
|
||||||
_error("EXECUTION_PROJECTION_CONFLICT")
|
|
||||||
metadata["execution"] = execution
|
|
||||||
metadata["execution_projection"] = {
|
|
||||||
"schema": PROJECTION_SCHEMA,
|
|
||||||
"task_id": binding.task_id,
|
|
||||||
"job_id": binding.job_id,
|
|
||||||
"execution_digest": execution["execution_digest"],
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
**request,
|
|
||||||
"task_id": binding.task_id,
|
|
||||||
"metadata": metadata,
|
|
||||||
"required_capabilities": list(required),
|
|
||||||
"idempotency_key": str(request.get("idempotency_key") or f"online-task:{binding.idempotency_key}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_task_execution(
|
|
||||||
task: Mapping[str, Any],
|
|
||||||
binding: GovernedExecutionBinding,
|
|
||||||
*,
|
|
||||||
worker_capabilities: Sequence[str] | None = None,
|
|
||||||
) -> tuple[dict[str, Any], str]:
|
|
||||||
metadata = task.get("metadata")
|
|
||||||
if isinstance(metadata, Mapping) and "execution" in metadata:
|
|
||||||
value = validate_execution_envelope(
|
|
||||||
_object(metadata["execution"], "EXECUTION_ENVELOPE_INVALID"),
|
|
||||||
binding=binding,
|
|
||||||
worker_capabilities=worker_capabilities,
|
|
||||||
)
|
|
||||||
return value, "persisted"
|
|
||||||
projected = project_execution_envelope(
|
|
||||||
binding,
|
|
||||||
{"action": "source_capture", "capability_scope": task.get("required_capabilities") or []},
|
|
||||||
required_capabilities=task.get("required_capabilities") or [],
|
|
||||||
)
|
|
||||||
validate_execution_envelope(projected, binding=binding, worker_capabilities=worker_capabilities)
|
|
||||||
return projected, "recovered_projection"
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
"""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
@@ -0,0 +1,86 @@
|
|||||||
|
"""Narrow, non-root client contract for the privileged signer broker."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
DEFAULT_SIGNER_BROKER_SOCKET = Path("/run/aios/signer-broker.sock")
|
||||||
|
MAX_SIGNING_PAYLOAD = 1024 * 1024
|
||||||
|
CODE_RE = re.compile(r"^[A-Z0-9_.:-]{3,96}$")
|
||||||
|
|
||||||
|
|
||||||
|
class SignerBrokerError(RuntimeError):
|
||||||
|
def __init__(self, code: str):
|
||||||
|
self.code = code
|
||||||
|
super().__init__(code)
|
||||||
|
|
||||||
|
|
||||||
|
class SignerBroker(Protocol):
|
||||||
|
def self_test(self) -> None: ...
|
||||||
|
def sign(self, payload_kind: str, payload: bytes) -> tuple[bytes, str]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class UnixSignerBroker:
|
||||||
|
"""AF_UNIX request client; it never accepts commands, paths, or shell text."""
|
||||||
|
|
||||||
|
def __init__(self, socket_path: Path = DEFAULT_SIGNER_BROKER_SOCKET, timeout: float = 15.0):
|
||||||
|
path = Path(socket_path)
|
||||||
|
if path != DEFAULT_SIGNER_BROKER_SOCKET or not path.is_absolute():
|
||||||
|
raise SignerBrokerError("SIGNER_BROKER_SOCKET_INVALID")
|
||||||
|
self.socket_path = path
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
def _request(self, operation: str, **payload: Any) -> dict[str, Any]:
|
||||||
|
if operation not in {"self_test", "sign"}:
|
||||||
|
raise SignerBrokerError("SIGNER_OPERATION_REJECTED")
|
||||||
|
request = {
|
||||||
|
"schema": "aios.signer-broker.request.v1",
|
||||||
|
"operation": operation,
|
||||||
|
**payload,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as channel:
|
||||||
|
channel.settimeout(self.timeout)
|
||||||
|
channel.connect(str(self.socket_path))
|
||||||
|
channel.sendall((json.dumps(request, sort_keys=True, separators=(",", ":")) + "\n").encode())
|
||||||
|
raw = channel.makefile("rb").readline()
|
||||||
|
except (OSError, TimeoutError) as exc:
|
||||||
|
raise SignerBrokerError("SIGNER_BROKER_UNAVAILABLE") from exc
|
||||||
|
try:
|
||||||
|
response = json.loads(raw.decode())
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise SignerBrokerError("SIGNER_BROKER_RESPONSE_INVALID") from exc
|
||||||
|
if not isinstance(response, dict) or response.get("schema") != "aios.signer-broker.response.v1":
|
||||||
|
raise SignerBrokerError("SIGNER_BROKER_RESPONSE_INVALID")
|
||||||
|
if response.get("status") != "PASS":
|
||||||
|
code = str(response.get("error_code") or "SIGNER_BROKER_REJECTED")
|
||||||
|
raise SignerBrokerError(code if CODE_RE.fullmatch(code) else "SIGNER_BROKER_REJECTED")
|
||||||
|
return response
|
||||||
|
|
||||||
|
def self_test(self) -> None:
|
||||||
|
self._request("self_test")
|
||||||
|
|
||||||
|
def sign(self, payload_kind: str, payload: bytes) -> tuple[bytes, str]:
|
||||||
|
if payload_kind not in {"approval_plan", "grant_payload"}:
|
||||||
|
raise SignerBrokerError("SIGNER_PAYLOAD_KIND_REJECTED")
|
||||||
|
if not isinstance(payload, bytes) or not payload or len(payload) > MAX_SIGNING_PAYLOAD:
|
||||||
|
raise SignerBrokerError("SIGNER_PAYLOAD_INVALID")
|
||||||
|
response = self._request(
|
||||||
|
"sign",
|
||||||
|
payload_kind=payload_kind,
|
||||||
|
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||||
|
payload_base64=base64.b64encode(payload).decode("ascii"),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
signature = base64.b64decode(str(response["signature_base64"]), validate=True)
|
||||||
|
key_id = str(response["key_id"]).strip()
|
||||||
|
except (KeyError, ValueError) as exc:
|
||||||
|
raise SignerBrokerError("SIGNER_BROKER_RESPONSE_INVALID") from exc
|
||||||
|
if not signature or not key_id or len(key_id) > 256:
|
||||||
|
raise SignerBrokerError("SIGNER_BROKER_RESPONSE_INVALID")
|
||||||
|
return signature, key_id
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"""Privileged, fixed-function signer broker service."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import socketserver
|
||||||
|
import stat
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from typing import Any, Callable, Mapping, Protocol, Sequence
|
||||||
|
|
||||||
|
REQUEST_SCHEMA = "aios.signer-broker.request.v1"
|
||||||
|
RESPONSE_SCHEMA = "aios.signer-broker.response.v1"
|
||||||
|
AUDIT_SCHEMA = "aios.signer-broker.audit.v1"
|
||||||
|
DEFAULT_SOCKET_PATH = Path("/run/aios/signer-broker.sock")
|
||||||
|
DEFAULT_SIGNER_PATH = Path("/usr/local/libexec/aios-grant-signer-adapter")
|
||||||
|
DEFAULT_AUDIT_PATH = Path("/var/lib/aios-gitops/audit/signer-broker.jsonl")
|
||||||
|
DEFAULT_STATE_ROOT = Path("/var/lib/aios-gitops/signer-broker")
|
||||||
|
ALLOWED_OPERATIONS = frozenset({"self_test", "sign"})
|
||||||
|
ALLOWED_PAYLOAD_KINDS = frozenset({"approval_plan", "grant_payload"})
|
||||||
|
MAX_SIGNING_PAYLOAD = 1024 * 1024
|
||||||
|
MAX_REQUEST_BYTES = 2 * MAX_SIGNING_PAYLOAD + 8192
|
||||||
|
CODE_RE = re.compile(r"^[A-Z0-9_.:-]{3,96}$")
|
||||||
|
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||||
|
|
||||||
|
|
||||||
|
class SignerBrokerServiceError(RuntimeError):
|
||||||
|
def __init__(self, code: str):
|
||||||
|
self.code = code if CODE_RE.fullmatch(code) else "SIGNER_INTERNAL_ERROR"
|
||||||
|
super().__init__(self.code)
|
||||||
|
|
||||||
|
|
||||||
|
class SignerRunner(Protocol):
|
||||||
|
def self_test(self) -> None: ...
|
||||||
|
def sign(self, payload_kind: str, payload: bytes) -> tuple[bytes, str]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SignerBrokerPolicy:
|
||||||
|
socket_path: Path = DEFAULT_SOCKET_PATH
|
||||||
|
signer_path: Path = DEFAULT_SIGNER_PATH
|
||||||
|
audit_path: Path = DEFAULT_AUDIT_PATH
|
||||||
|
state_root: Path = DEFAULT_STATE_ROOT
|
||||||
|
allowed_uids: frozenset[int] = frozenset()
|
||||||
|
signer_timeout: float = 60.0
|
||||||
|
max_payload_bytes: int = MAX_SIGNING_PAYLOAD
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in ("socket_path", "signer_path", "audit_path", "state_root"):
|
||||||
|
if not Path(getattr(self, name)).is_absolute():
|
||||||
|
raise SignerBrokerServiceError("SIGNER_POLICY_PATH_INVALID")
|
||||||
|
if not self.allowed_uids or any(not isinstance(uid, int) or uid < 0 for uid in self.allowed_uids):
|
||||||
|
raise SignerBrokerServiceError("SIGNER_POLICY_CALLER_INVALID")
|
||||||
|
if not 0 < self.signer_timeout <= 300:
|
||||||
|
raise SignerBrokerServiceError("SIGNER_POLICY_TIMEOUT_INVALID")
|
||||||
|
if not 0 < self.max_payload_bytes <= MAX_SIGNING_PAYLOAD:
|
||||||
|
raise SignerBrokerServiceError("SIGNER_POLICY_PAYLOAD_LIMIT_INVALID")
|
||||||
|
|
||||||
|
|
||||||
|
class FixedSignerRunner:
|
||||||
|
"""Use one configured signer executable and broker-owned temporary paths."""
|
||||||
|
|
||||||
|
def __init__(self, policy: SignerBrokerPolicy):
|
||||||
|
self.policy = policy
|
||||||
|
|
||||||
|
def _run(self, argv: Sequence[str]) -> None:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(list(argv), capture_output=True, text=True, check=False, timeout=self.policy.signer_timeout)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise SignerBrokerServiceError("SIGNER_UNAVAILABLE") from exc
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
raise SignerBrokerServiceError("SIGNER_TIMEOUT") from exc
|
||||||
|
except OSError as exc:
|
||||||
|
raise SignerBrokerServiceError("SIGNER_UNAVAILABLE") from exc
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise SignerBrokerServiceError("SIGNER_FAILED")
|
||||||
|
|
||||||
|
def self_test(self) -> None:
|
||||||
|
self._run((str(self.policy.signer_path), "self-test"))
|
||||||
|
|
||||||
|
def sign(self, payload_kind: str, payload: bytes) -> tuple[bytes, str]:
|
||||||
|
if payload_kind not in ALLOWED_PAYLOAD_KINDS:
|
||||||
|
raise SignerBrokerServiceError("SIGNER_PAYLOAD_KIND_REJECTED")
|
||||||
|
try:
|
||||||
|
self.policy.state_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
os.chmod(self.policy.state_root, 0o700)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="request-", dir=self.policy.state_root) as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
input_path = root / "payload.canonical"
|
||||||
|
signature_path = root / "payload.sig"
|
||||||
|
key_id_path = root / "payload.key-id"
|
||||||
|
input_path.write_bytes(payload)
|
||||||
|
os.chmod(input_path, 0o600)
|
||||||
|
self._run((str(self.policy.signer_path), "sign", "--namespace", payload_kind, "--input", str(input_path), "--signature-out", str(signature_path), "--key-id-out", str(key_id_path)))
|
||||||
|
signature = signature_path.read_bytes()
|
||||||
|
key_id = key_id_path.read_text(encoding="utf-8").strip()
|
||||||
|
except SignerBrokerServiceError:
|
||||||
|
raise
|
||||||
|
except (OSError, UnicodeError) as exc:
|
||||||
|
raise SignerBrokerServiceError("SIGNER_OUTPUT_INVALID") from exc
|
||||||
|
if not signature or not key_id or len(key_id) > 256 or "\n" in key_id or "\r" in key_id:
|
||||||
|
raise SignerBrokerServiceError("SIGNER_OUTPUT_INVALID")
|
||||||
|
return signature, key_id
|
||||||
|
|
||||||
|
|
||||||
|
class SignerBrokerService:
|
||||||
|
def __init__(self, policy: SignerBrokerPolicy, runner: SignerRunner | None = None, *, now: Callable[[], datetime] | None = None) -> None:
|
||||||
|
self.policy = policy
|
||||||
|
self.runner = runner or FixedSignerRunner(policy)
|
||||||
|
self.now = now or (lambda: datetime.now(timezone.utc))
|
||||||
|
self._audit_lock = threading.Lock()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _operation(value: Any) -> str:
|
||||||
|
return value if isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9_.:-]{1,64}", value) else "INVALID"
|
||||||
|
|
||||||
|
def _audit(self, *, peer_uid: int, operation: str, status: str, error_code: str | None = None, payload_sha256: str | None = None) -> None:
|
||||||
|
event: dict[str, Any] = {"schema": AUDIT_SCHEMA, "timestamp": self.now().astimezone(timezone.utc).isoformat(), "peer_uid": peer_uid, "operation": self._operation(operation), "status": status}
|
||||||
|
if isinstance(payload_sha256, str) and SHA256_RE.fullmatch(payload_sha256):
|
||||||
|
event["payload_sha256"] = payload_sha256
|
||||||
|
if error_code:
|
||||||
|
event["error_code"] = error_code if CODE_RE.fullmatch(error_code) else "SIGNER_INTERNAL_ERROR"
|
||||||
|
try:
|
||||||
|
with self._audit_lock:
|
||||||
|
self.policy.audit_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self.policy.audit_path.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(event, sort_keys=True, separators=(",", ":")) + "\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.chmod(self.policy.audit_path, 0o600)
|
||||||
|
except OSError as exc:
|
||||||
|
raise SignerBrokerServiceError("SIGNER_AUDIT_UNAVAILABLE") from exc
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _failure(code: str) -> dict[str, Any]:
|
||||||
|
return {"schema": RESPONSE_SCHEMA, "status": "FAIL", "error_code": code if CODE_RE.fullmatch(code) else "SIGNER_INTERNAL_ERROR"}
|
||||||
|
|
||||||
|
def _dispatch(self, request: Mapping[str, Any], peer_uid: int) -> tuple[dict[str, Any], str, str | None]:
|
||||||
|
operation = self._operation(request.get("operation"))
|
||||||
|
if peer_uid not in self.policy.allowed_uids:
|
||||||
|
raise SignerBrokerServiceError("CALLER_UNAUTHORIZED")
|
||||||
|
if request.get("schema") != REQUEST_SCHEMA:
|
||||||
|
raise SignerBrokerServiceError("REQUEST_SCHEMA_INVALID")
|
||||||
|
if operation not in ALLOWED_OPERATIONS:
|
||||||
|
raise SignerBrokerServiceError("SIGNER_OPERATION_REJECTED")
|
||||||
|
if operation == "self_test":
|
||||||
|
if set(request) != {"schema", "operation"}:
|
||||||
|
raise SignerBrokerServiceError("REQUEST_FIELDS_REJECTED")
|
||||||
|
self.runner.self_test()
|
||||||
|
return {"schema": RESPONSE_SCHEMA, "status": "PASS", "operation": operation}, operation, None
|
||||||
|
if set(request) != {"schema", "operation", "payload_kind", "payload_sha256", "payload_base64"}:
|
||||||
|
raise SignerBrokerServiceError("REQUEST_FIELDS_REJECTED")
|
||||||
|
payload_kind = request.get("payload_kind")
|
||||||
|
encoded = request.get("payload_base64")
|
||||||
|
claimed_hash = request.get("payload_sha256")
|
||||||
|
if payload_kind not in ALLOWED_PAYLOAD_KINDS or not isinstance(encoded, str) or not isinstance(claimed_hash, str) or not SHA256_RE.fullmatch(claimed_hash):
|
||||||
|
raise SignerBrokerServiceError("SIGNER_PAYLOAD_INVALID")
|
||||||
|
if len(encoded) > self.policy.max_payload_bytes * 2 + 8:
|
||||||
|
raise SignerBrokerServiceError("SIGNER_PAYLOAD_INVALID")
|
||||||
|
try:
|
||||||
|
payload = base64.b64decode(encoded.encode("ascii"), validate=True)
|
||||||
|
except (ValueError, UnicodeError, binascii.Error) as exc:
|
||||||
|
raise SignerBrokerServiceError("SIGNER_PAYLOAD_INVALID") from exc
|
||||||
|
if not payload or len(payload) > self.policy.max_payload_bytes:
|
||||||
|
raise SignerBrokerServiceError("SIGNER_PAYLOAD_INVALID")
|
||||||
|
actual_hash = hashlib.sha256(payload).hexdigest()
|
||||||
|
if actual_hash != claimed_hash:
|
||||||
|
raise SignerBrokerServiceError("SIGNER_PAYLOAD_HASH_MISMATCH")
|
||||||
|
signature, key_id = self.runner.sign(payload_kind, payload)
|
||||||
|
if not isinstance(signature, bytes) or not signature or not isinstance(key_id, str) or not key_id.strip() or len(key_id) > 256:
|
||||||
|
raise SignerBrokerServiceError("SIGNER_OUTPUT_INVALID")
|
||||||
|
return {"schema": RESPONSE_SCHEMA, "status": "PASS", "operation": operation, "signature_base64": base64.b64encode(signature).decode("ascii"), "key_id": key_id.strip()}, operation, actual_hash
|
||||||
|
|
||||||
|
def handle_request(self, request: Mapping[str, Any], peer_uid: int) -> dict[str, Any]:
|
||||||
|
operation = self._operation(request.get("operation")) if isinstance(request, Mapping) else "INVALID"
|
||||||
|
payload_hash = request.get("payload_sha256") if isinstance(request, Mapping) else None
|
||||||
|
status = "FAIL"
|
||||||
|
error_code: str | None = None
|
||||||
|
try:
|
||||||
|
if not isinstance(request, Mapping):
|
||||||
|
raise SignerBrokerServiceError("REQUEST_OBJECT_REQUIRED")
|
||||||
|
response, operation, payload_hash = self._dispatch(request, peer_uid)
|
||||||
|
status = "PASS"
|
||||||
|
except SignerBrokerServiceError as exc:
|
||||||
|
error_code = exc.code
|
||||||
|
response = self._failure(exc.code)
|
||||||
|
except Exception:
|
||||||
|
error_code = "SIGNER_INTERNAL_ERROR"
|
||||||
|
response = self._failure(error_code)
|
||||||
|
try:
|
||||||
|
self._audit(peer_uid=peer_uid, operation=operation, status=status, error_code=error_code, payload_sha256=payload_hash if isinstance(payload_hash, str) else None)
|
||||||
|
except SignerBrokerServiceError as exc:
|
||||||
|
response = self._failure(exc.code)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def handle_json_line(self, raw: bytes, peer_uid: int) -> dict[str, Any]:
|
||||||
|
if len(raw) > MAX_REQUEST_BYTES:
|
||||||
|
return self.handle_request({"operation": "invalid"}, peer_uid) | {"error_code": "REQUEST_TOO_LARGE", "status": "FAIL"}
|
||||||
|
try:
|
||||||
|
request = json.loads(raw.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
return self.handle_request({"operation": "invalid"}, peer_uid) | {"error_code": "REQUEST_JSON_INVALID", "status": "FAIL"}
|
||||||
|
return self.handle_request(request, peer_uid)
|
||||||
|
|
||||||
|
|
||||||
|
class _UnixRequestHandler(socketserver.StreamRequestHandler):
|
||||||
|
def handle(self) -> None:
|
||||||
|
service: SignerBrokerService = self.server.service # type: ignore[attr-defined]
|
||||||
|
response = service.handle_json_line(self.rfile.readline(MAX_REQUEST_BYTES + 1), _peer_uid(self.request))
|
||||||
|
self.wfile.write((json.dumps(response, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8"))
|
||||||
|
self.wfile.flush()
|
||||||
|
|
||||||
|
|
||||||
|
class SignerBrokerUnixServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
|
||||||
|
daemon_threads = True
|
||||||
|
allow_reuse_address = True
|
||||||
|
|
||||||
|
def __init__(self, socket_path: Path, service: SignerBrokerService):
|
||||||
|
self.socket_path = Path(socket_path)
|
||||||
|
self.service = service
|
||||||
|
super().__init__(str(self.socket_path), _UnixRequestHandler)
|
||||||
|
|
||||||
|
def server_close(self) -> None:
|
||||||
|
super().server_close()
|
||||||
|
try:
|
||||||
|
if self.socket_path.exists() and stat.S_ISSOCK(self.socket_path.stat().st_mode):
|
||||||
|
self.socket_path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _peer_uid(channel: socket.socket) -> int:
|
||||||
|
if not hasattr(socket, "SO_PEERCRED"):
|
||||||
|
return -1
|
||||||
|
try:
|
||||||
|
_pid, uid, _gid = struct.unpack("3i", channel.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i")))
|
||||||
|
return int(uid)
|
||||||
|
except (OSError, struct.error):
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def build_server(policy: SignerBrokerPolicy, runner: SignerRunner | None = None) -> SignerBrokerUnixServer:
|
||||||
|
socket_path = policy.socket_path
|
||||||
|
socket_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if socket_path.exists():
|
||||||
|
if not stat.S_ISSOCK(socket_path.stat().st_mode):
|
||||||
|
raise SignerBrokerServiceError("SIGNER_BROKER_SOCKET_CONFLICT")
|
||||||
|
socket_path.unlink()
|
||||||
|
server = SignerBrokerUnixServer(socket_path, SignerBrokerService(policy, runner))
|
||||||
|
os.chmod(socket_path, 0o660)
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
def _parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description="AIOS fixed-function signer broker")
|
||||||
|
parser.add_argument("command", choices=("serve",))
|
||||||
|
parser.add_argument("--socket", default=str(DEFAULT_SOCKET_PATH))
|
||||||
|
parser.add_argument("--signer", default=str(DEFAULT_SIGNER_PATH))
|
||||||
|
parser.add_argument("--audit", default=str(DEFAULT_AUDIT_PATH))
|
||||||
|
parser.add_argument("--state-root", default=str(DEFAULT_STATE_ROOT))
|
||||||
|
parser.add_argument("--allowed-uid", action="append", type=int, required=True)
|
||||||
|
parser.add_argument("--timeout", type=float, default=60.0)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
args = _parser().parse_args(argv)
|
||||||
|
policy = SignerBrokerPolicy(socket_path=Path(args.socket), signer_path=Path(args.signer), audit_path=Path(args.audit), state_root=Path(args.state_root), allowed_uids=frozenset(args.allowed_uid), signer_timeout=args.timeout)
|
||||||
|
server = build_server(policy)
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
finally:
|
||||||
|
server.server_close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -31,6 +31,8 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Mapping, Sequence
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
|
from version_registry import configured_authoritative_git_dir, verify_authoritative_repository
|
||||||
|
|
||||||
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}$")
|
||||||
@@ -49,6 +51,11 @@ REQUIRED_V2R1C7_MEMBERS = {
|
|||||||
"payload/ARTIFACT-MANIFEST.json",
|
"payload/ARTIFACT-MANIFEST.json",
|
||||||
"payload/ROLLBACK-METADATA.json",
|
"payload/ROLLBACK-METADATA.json",
|
||||||
}
|
}
|
||||||
|
REQUIRED_V2R2C1_MEMBERS = {
|
||||||
|
"payload/versions/V2R2C1.json",
|
||||||
|
}
|
||||||
|
V2R2C1_TAG_OID = "07fc2746b688f5f243f29b39de39f32edc3dd9d2"
|
||||||
|
DEFAULT_AUTHORITATIVE_GIT_DIR = Path("/data/aios-gitops/repo.git")
|
||||||
|
|
||||||
class ReleaseBlocked(RuntimeError):
|
class ReleaseBlocked(RuntimeError):
|
||||||
pass
|
pass
|
||||||
@@ -72,6 +79,13 @@ class Binding:
|
|||||||
production_service: str
|
production_service: str
|
||||||
production_port: int
|
production_port: int
|
||||||
repository_identity: str = "aios/aios"
|
repository_identity: str = "aios/aios"
|
||||||
|
# Legacy V2R2C1 receipts intentionally omit tag_oid. Future release
|
||||||
|
# bindings must provide it explicitly and may use a versioned schema.
|
||||||
|
tag_oid: str | None = None
|
||||||
|
binding_schema: str | None = None
|
||||||
|
source_registry_main_oid: str | None = None
|
||||||
|
source_registry_path: str | None = None
|
||||||
|
source_registry_sha256: str | None = None
|
||||||
|
|
||||||
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):
|
||||||
@@ -90,6 +104,32 @@ class Binding:
|
|||||||
raise ValueError("invalid production port")
|
raise ValueError("invalid production port")
|
||||||
if self.repository_identity != "aios/aios":
|
if self.repository_identity != "aios/aios":
|
||||||
raise ValueError("unexpected repository identity")
|
raise ValueError("unexpected repository identity")
|
||||||
|
if self.tag_oid is not None and not OID_RE.fullmatch(self.tag_oid):
|
||||||
|
raise ValueError("invalid annotated tag object OID")
|
||||||
|
if self.binding_schema is not None and not self.binding_schema.startswith(
|
||||||
|
"aios.release-binding."
|
||||||
|
):
|
||||||
|
raise ValueError("invalid release binding schema")
|
||||||
|
provenance = (
|
||||||
|
self.source_registry_main_oid,
|
||||||
|
self.source_registry_sha256,
|
||||||
|
)
|
||||||
|
if any(item is not None for item in provenance):
|
||||||
|
if not all(item is not None for item in provenance):
|
||||||
|
raise ValueError("source registry provenance must be complete")
|
||||||
|
if not OID_RE.fullmatch(str(self.source_registry_main_oid)):
|
||||||
|
raise ValueError("invalid source registry main OID")
|
||||||
|
if not SHA_RE.fullmatch(str(self.source_registry_sha256)):
|
||||||
|
raise ValueError("invalid source registry SHA-256")
|
||||||
|
path = self.source_registry_path or f"versions/{self.version}.json"
|
||||||
|
candidate = Path(path)
|
||||||
|
if (
|
||||||
|
candidate.is_absolute()
|
||||||
|
or ".." in candidate.parts
|
||||||
|
or "\\" in path
|
||||||
|
or not path.endswith(".json")
|
||||||
|
):
|
||||||
|
raise ValueError("invalid source registry path")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tag(self) -> str:
|
def tag(self) -> str:
|
||||||
@@ -108,10 +148,22 @@ class Binding:
|
|||||||
"production_port": self.production_port,
|
"production_port": self.production_port,
|
||||||
"repository_identity": self.repository_identity,
|
"repository_identity": self.repository_identity,
|
||||||
}
|
}
|
||||||
|
if self.tag_oid is not None:
|
||||||
|
payload["tag_oid"] = self.tag_oid
|
||||||
|
payload["binding_schema"] = self.binding_schema or "aios.release-binding.v2"
|
||||||
|
if self.source_registry_main_oid is not None:
|
||||||
|
payload.update(
|
||||||
|
{
|
||||||
|
"source_registry_main_oid": self.source_registry_main_oid,
|
||||||
|
"source_registry_path": self.source_registry_path
|
||||||
|
or f"versions/{self.version}.json",
|
||||||
|
"source_registry_sha256": self.source_registry_sha256,
|
||||||
|
}
|
||||||
|
)
|
||||||
return hashlib.sha256(canonical_json(payload)).hexdigest()
|
return hashlib.sha256(canonical_json(payload)).hexdigest()
|
||||||
|
|
||||||
def request(self, operation: str) -> dict[str, Any]:
|
def request(self, operation: str) -> dict[str, Any]:
|
||||||
return {
|
request = {
|
||||||
"schema": "aios.production-executor-request.v1",
|
"schema": "aios.production-executor-request.v1",
|
||||||
"operation": operation,
|
"operation": operation,
|
||||||
"binding_fingerprint": self.fingerprint,
|
"binding_fingerprint": self.fingerprint,
|
||||||
@@ -127,6 +179,19 @@ class Binding:
|
|||||||
"production_port": self.production_port,
|
"production_port": self.production_port,
|
||||||
"repository_identity": self.repository_identity,
|
"repository_identity": self.repository_identity,
|
||||||
}
|
}
|
||||||
|
if self.tag_oid is not None:
|
||||||
|
request["tag_oid"] = self.tag_oid
|
||||||
|
request["binding_schema"] = self.binding_schema or "aios.release-binding.v2"
|
||||||
|
if self.source_registry_main_oid is not None:
|
||||||
|
request.update(
|
||||||
|
{
|
||||||
|
"source_registry_main_oid": self.source_registry_main_oid,
|
||||||
|
"source_registry_path": self.source_registry_path
|
||||||
|
or f"versions/{self.version}.json",
|
||||||
|
"source_registry_sha256": self.source_registry_sha256,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
def canonical_json(value: object) -> bytes:
|
def canonical_json(value: object) -> bytes:
|
||||||
@@ -168,6 +233,23 @@ def run_git(git_dir: Path, *args: str, check: bool = True) -> subprocess.Complet
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_git_bytes(
|
||||||
|
git_dir: Path,
|
||||||
|
*args: str,
|
||||||
|
check: bool = True,
|
||||||
|
) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
completed = subprocess.run(
|
||||||
|
["git", f"--git-dir={git_dir}", *args],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=30,
|
||||||
|
env={"PATH": os.environ.get("PATH", "")},
|
||||||
|
)
|
||||||
|
if check and completed.returncode != 0:
|
||||||
|
raise ReleaseBlocked(completed.stderr.decode("utf-8", "replace").strip())
|
||||||
|
return completed
|
||||||
|
|
||||||
|
|
||||||
def verify_git_lineage(git_dir: Path, binding: Binding) -> dict[str, Any]:
|
def verify_git_lineage(git_dir: Path, binding: Binding) -> dict[str, Any]:
|
||||||
authority_main = run_git(git_dir, "rev-parse", "refs/heads/main").stdout.strip()
|
authority_main = run_git(git_dir, "rev-parse", "refs/heads/main").stdout.strip()
|
||||||
if not OID_RE.fullmatch(authority_main):
|
if not OID_RE.fullmatch(authority_main):
|
||||||
@@ -209,7 +291,45 @@ def verify_git_lineage(git_dir: Path, binding: Binding) -> dict[str, Any]:
|
|||||||
if not target_in_evidence:
|
if not target_in_evidence:
|
||||||
raise ReleaseBlocked("approved target is not an ancestor of evidence closure")
|
raise ReleaseBlocked("approved target is not an ancestor of evidence closure")
|
||||||
|
|
||||||
return {
|
source_main = None
|
||||||
|
if binding.source_registry_main_oid is not None:
|
||||||
|
source_main = binding.source_registry_main_oid
|
||||||
|
source_type = run_git(git_dir, "cat-file", "-t", source_main).stdout.strip()
|
||||||
|
if source_type != "commit":
|
||||||
|
raise ReleaseBlocked("source_registry_main_oid is not a commit")
|
||||||
|
target_in_source_main = run_git(
|
||||||
|
git_dir,
|
||||||
|
"merge-base",
|
||||||
|
"--is-ancestor",
|
||||||
|
binding.target_oid,
|
||||||
|
source_main,
|
||||||
|
check=False,
|
||||||
|
).returncode == 0
|
||||||
|
if not target_in_source_main:
|
||||||
|
raise ManualOwnerReviewRequired(
|
||||||
|
"approved target is not an ancestor of source registry main"
|
||||||
|
)
|
||||||
|
expected_tag_oid = binding.tag_oid
|
||||||
|
if expected_tag_oid is None and binding.version == "V2R2C1":
|
||||||
|
expected_tag_oid = V2R2C1_TAG_OID
|
||||||
|
if expected_tag_oid is not None:
|
||||||
|
local_tag = run_git(
|
||||||
|
git_dir,
|
||||||
|
"rev-parse",
|
||||||
|
"--verify",
|
||||||
|
f"refs/tags/{binding.version}",
|
||||||
|
).stdout.strip()
|
||||||
|
if local_tag != expected_tag_oid:
|
||||||
|
raise ManualOwnerReviewRequired(
|
||||||
|
"future release binding tag object differs from local tag"
|
||||||
|
)
|
||||||
|
if run_git(git_dir, "cat-file", "-t", local_tag).stdout.strip() != "tag":
|
||||||
|
raise ReleaseBlocked("release tag is not annotated")
|
||||||
|
peeled = run_git(git_dir, "rev-parse", "--verify", f"{local_tag}^{{commit}}").stdout.strip()
|
||||||
|
if peeled != binding.target_oid:
|
||||||
|
raise ManualOwnerReviewRequired("release tag peeled target mismatch")
|
||||||
|
|
||||||
|
result = {
|
||||||
"authority_main_oid": authority_main,
|
"authority_main_oid": authority_main,
|
||||||
"target_oid": binding.target_oid,
|
"target_oid": binding.target_oid,
|
||||||
"target_is_ancestor_of_main": True,
|
"target_is_ancestor_of_main": True,
|
||||||
@@ -217,9 +337,231 @@ def verify_git_lineage(git_dir: Path, binding: Binding) -> dict[str, Any]:
|
|||||||
"evidence_object_type": evidence_type,
|
"evidence_object_type": evidence_type,
|
||||||
"target_is_ancestor_of_evidence": True,
|
"target_is_ancestor_of_evidence": True,
|
||||||
}
|
}
|
||||||
|
if source_main is not None:
|
||||||
|
result["source_registry_main_oid"] = source_main
|
||||||
|
result["target_is_ancestor_of_source_registry_main"] = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def verify_artifact(binding: Binding) -> dict[str, Any]:
|
def _source_registry_git_provenance(
|
||||||
|
git_dir: Path,
|
||||||
|
binding: Binding,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Read M:path and C:path independently of the artifact metadata."""
|
||||||
|
if binding.source_registry_main_oid is None:
|
||||||
|
return None
|
||||||
|
verify_authoritative_repository(git_dir)
|
||||||
|
source_main = binding.source_registry_main_oid
|
||||||
|
source_path = binding.source_registry_path or f"versions/{binding.version}.json"
|
||||||
|
authority_main = run_git(git_dir, "rev-parse", "--verify", "refs/heads/main").stdout.strip()
|
||||||
|
if run_git(git_dir, "cat-file", "-t", source_main).stdout.strip() != "commit":
|
||||||
|
raise ReleaseBlocked("source_registry_main_oid is not a commit")
|
||||||
|
if run_git(git_dir, "cat-file", "-t", authority_main).stdout.strip() != "commit":
|
||||||
|
raise ReleaseBlocked("Authority main OID is not a commit")
|
||||||
|
if run_git(
|
||||||
|
git_dir,
|
||||||
|
"merge-base",
|
||||||
|
"--is-ancestor",
|
||||||
|
source_main,
|
||||||
|
authority_main,
|
||||||
|
check=False,
|
||||||
|
).returncode != 0:
|
||||||
|
raise ManualOwnerReviewRequired(
|
||||||
|
"source registry main is not an ancestor of current Authority main"
|
||||||
|
)
|
||||||
|
source_blob = run_git_bytes(git_dir, "show", f"{source_main}:{source_path}").stdout
|
||||||
|
current_blob = run_git_bytes(git_dir, "show", f"{authority_main}:{source_path}").stdout
|
||||||
|
if source_blob != current_blob:
|
||||||
|
raise ManualOwnerReviewRequired(
|
||||||
|
"current Authority main source registry entry differs from pinned M"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
source = json.loads(source_blob.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise ReleaseBlocked("Git source registry entry is invalid") from exc
|
||||||
|
expected_tag_oid = binding.tag_oid
|
||||||
|
if expected_tag_oid is None and binding.version == "V2R2C1":
|
||||||
|
expected_tag_oid = V2R2C1_TAG_OID
|
||||||
|
expected = {
|
||||||
|
"version": binding.version,
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"tag_oid": expected_tag_oid,
|
||||||
|
"target_oid": binding.target_oid,
|
||||||
|
"peeled_target_oid": binding.target_oid,
|
||||||
|
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||||
|
"approval_plan_id": binding.approval_plan_id,
|
||||||
|
}
|
||||||
|
if not isinstance(source, dict) or source.get("schema") not in {
|
||||||
|
"aios.source-registry.v1",
|
||||||
|
"aios.production-registry.v2",
|
||||||
|
}:
|
||||||
|
raise ReleaseBlocked("Git source registry schema is invalid")
|
||||||
|
if expected_tag_oid is None or any(source.get(key) != value for key, value in expected.items()):
|
||||||
|
raise ReleaseBlocked("Git source registry identity mismatch")
|
||||||
|
if source.get("tag_object") != expected_tag_oid:
|
||||||
|
raise ReleaseBlocked("Git source registry tag object mismatch")
|
||||||
|
digest = hashlib.sha256(source_blob).hexdigest()
|
||||||
|
if digest != binding.source_registry_sha256:
|
||||||
|
raise ReleaseBlocked("Git source registry SHA-256 mismatch")
|
||||||
|
return {
|
||||||
|
"source_registry_main_oid": source_main,
|
||||||
|
"current_main_oid": authority_main,
|
||||||
|
"source_registry_path": source_path,
|
||||||
|
"source_registry_bytes": source_blob,
|
||||||
|
"source_registry_sha256": digest,
|
||||||
|
"source_registry_identity_verified": True,
|
||||||
|
"source_registry_main_converged": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_artifact_members(
|
||||||
|
path: Path,
|
||||||
|
required: set[str],
|
||||||
|
binding: Binding | None = None,
|
||||||
|
git_provenance: Mapping[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Verify archive safety and independently verify packaged release data."""
|
||||||
|
# Python tarfile may not have zstd support on the target host, so
|
||||||
|
# decompress into a private temporary tar and inspect it there.
|
||||||
|
with tempfile.TemporaryDirectory(prefix="aios-artifact-verify-") as temp_dir:
|
||||||
|
tar_path = Path(temp_dir) / "release.tar"
|
||||||
|
with tar_path.open("wb") as output:
|
||||||
|
completed = subprocess.run(
|
||||||
|
["zstd", "-dc", str(path)],
|
||||||
|
check=False,
|
||||||
|
stdout=output,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
timeout=60,
|
||||||
|
env={"PATH": os.environ.get("PATH", "")},
|
||||||
|
)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise ReleaseBlocked("artifact zstd decompression failed")
|
||||||
|
import tarfile
|
||||||
|
|
||||||
|
with tarfile.open(tar_path, "r:") as archive:
|
||||||
|
members = archive.getmembers()
|
||||||
|
names: set[str] = set()
|
||||||
|
for member in members:
|
||||||
|
candidate = Path(member.name)
|
||||||
|
if (
|
||||||
|
candidate.is_absolute()
|
||||||
|
or ".." in candidate.parts
|
||||||
|
or member.issym()
|
||||||
|
or member.islnk()
|
||||||
|
or not (member.isfile() or member.isdir())
|
||||||
|
):
|
||||||
|
raise ReleaseBlocked(f"unsafe artifact member: {member.name}")
|
||||||
|
if member.name in names:
|
||||||
|
raise ReleaseBlocked(f"duplicate artifact member: {member.name}")
|
||||||
|
names.add(member.name)
|
||||||
|
source_path = None
|
||||||
|
if binding is not None and binding.version != "V2R1C7":
|
||||||
|
source_path = binding.source_registry_path or f"versions/{binding.version}.json"
|
||||||
|
required_members = set(required)
|
||||||
|
if binding is not None and binding.source_registry_main_oid is not None:
|
||||||
|
required_members.add("payload/BUILD-METADATA.json")
|
||||||
|
if source_path is not None and binding is not None:
|
||||||
|
required_members.add(f"payload/{source_path}")
|
||||||
|
missing = sorted(required_members - names)
|
||||||
|
if missing:
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"artifact missing required members: " + ",".join(missing)
|
||||||
|
)
|
||||||
|
verification: dict[str, Any] = {}
|
||||||
|
if binding is not None and source_path is not None:
|
||||||
|
member_name = f"payload/{source_path}"
|
||||||
|
member = archive.getmember(member_name)
|
||||||
|
stream = archive.extractfile(member)
|
||||||
|
if stream is None:
|
||||||
|
raise ReleaseBlocked("source registry member is unreadable")
|
||||||
|
raw = stream.read()
|
||||||
|
try:
|
||||||
|
source = json.loads(raw.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise ReleaseBlocked("source registry member is invalid") from exc
|
||||||
|
canonical_source = (
|
||||||
|
json.dumps(source, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
if raw != canonical_source:
|
||||||
|
raise ReleaseBlocked("source registry member is not canonical JSON")
|
||||||
|
expected_tag_oid = binding.tag_oid
|
||||||
|
if expected_tag_oid is None and binding.version == "V2R2C1":
|
||||||
|
expected_tag_oid = V2R2C1_TAG_OID
|
||||||
|
expected = {
|
||||||
|
"version": binding.version,
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"tag_oid": expected_tag_oid,
|
||||||
|
"target_oid": binding.target_oid,
|
||||||
|
"peeled_target_oid": binding.target_oid,
|
||||||
|
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||||
|
"approval_plan_id": binding.approval_plan_id,
|
||||||
|
}
|
||||||
|
if expected_tag_oid is None:
|
||||||
|
raise ReleaseBlocked("future release binding tag object is required")
|
||||||
|
if not isinstance(source, dict) or any(
|
||||||
|
source.get(key) != value for key, value in expected.items()
|
||||||
|
):
|
||||||
|
raise ReleaseBlocked("packaged source registry identity mismatch")
|
||||||
|
if source.get("tag_object") != expected_tag_oid:
|
||||||
|
raise ReleaseBlocked("packaged source registry tag object mismatch")
|
||||||
|
source_digest = hashlib.sha256(raw).hexdigest()
|
||||||
|
if git_provenance is not None:
|
||||||
|
if raw != git_provenance["source_registry_bytes"]:
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"packaged source registry differs from Git M:path"
|
||||||
|
)
|
||||||
|
if source_digest != git_provenance["source_registry_sha256"]:
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"packaged source registry SHA-256 differs from Git M:path"
|
||||||
|
)
|
||||||
|
verification.update(
|
||||||
|
{
|
||||||
|
"source_registry_path": source_path,
|
||||||
|
"source_registry_sha256": source_digest,
|
||||||
|
"source_registry_identity_verified": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if binding.source_registry_sha256 is not None:
|
||||||
|
if source_digest != binding.source_registry_sha256:
|
||||||
|
raise ReleaseBlocked("packaged source registry SHA-256 mismatch")
|
||||||
|
metadata_member = archive.getmember("payload/BUILD-METADATA.json")
|
||||||
|
metadata_stream = archive.extractfile(metadata_member)
|
||||||
|
if metadata_stream is None:
|
||||||
|
raise ReleaseBlocked("BUILD-METADATA.json is unreadable")
|
||||||
|
try:
|
||||||
|
metadata = json.loads(metadata_stream.read().decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise ReleaseBlocked("BUILD-METADATA.json is invalid") from exc
|
||||||
|
metadata_expected = {
|
||||||
|
"release_target_oid": binding.target_oid,
|
||||||
|
"source_registry_main_oid": binding.source_registry_main_oid,
|
||||||
|
"source_registry_path": source_path,
|
||||||
|
"source_registry_sha256": binding.source_registry_sha256,
|
||||||
|
}
|
||||||
|
if not isinstance(metadata, dict) or any(
|
||||||
|
metadata.get(key) != value
|
||||||
|
for key, value in metadata_expected.items()
|
||||||
|
):
|
||||||
|
raise ReleaseBlocked("BUILD-METADATA source provenance mismatch")
|
||||||
|
verification["build_metadata_provenance_verified"] = True
|
||||||
|
if git_provenance is not None:
|
||||||
|
verification.update(
|
||||||
|
{
|
||||||
|
"git_source_registry_member_verified": True,
|
||||||
|
"git_source_registry_main_converged": True,
|
||||||
|
"git_source_registry_sha256": git_provenance[
|
||||||
|
"source_registry_sha256"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return verification
|
||||||
|
|
||||||
|
|
||||||
|
def verify_artifact(
|
||||||
|
binding: Binding,
|
||||||
|
*,
|
||||||
|
repository: Path | str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
path = Path(binding.artifact_path)
|
path = Path(binding.artifact_path)
|
||||||
if path.stat().st_size != binding.artifact_size:
|
if path.stat().st_size != binding.artifact_size:
|
||||||
raise ReleaseBlocked("artifact size mismatch")
|
raise ReleaseBlocked("artifact size mismatch")
|
||||||
@@ -227,46 +569,32 @@ def verify_artifact(binding: Binding) -> dict[str, Any]:
|
|||||||
if actual != binding.artifact_sha256:
|
if actual != binding.artifact_sha256:
|
||||||
raise ReleaseBlocked("artifact SHA-256 mismatch")
|
raise ReleaseBlocked("artifact SHA-256 mismatch")
|
||||||
|
|
||||||
if binding.version == "V2R1C7":
|
required = {
|
||||||
# Python tarfile may not have zstd support on the target host, so
|
"V2R1C7": REQUIRED_V2R1C7_MEMBERS,
|
||||||
# decompress into a private temporary tar and inspect it there.
|
"V2R2C1": REQUIRED_V2R2C1_MEMBERS,
|
||||||
with tempfile.TemporaryDirectory(prefix="aios-artifact-verify-") as temp_dir:
|
}.get(binding.version)
|
||||||
tar_path = Path(temp_dir) / "release.tar"
|
member_verification: dict[str, Any] = {}
|
||||||
with tar_path.open("wb") as output:
|
git_provenance = None
|
||||||
completed = subprocess.run(
|
if binding.source_registry_main_oid is not None:
|
||||||
["zstd", "-dc", str(path)],
|
git_provenance = _source_registry_git_provenance(
|
||||||
check=False,
|
Path(repository) if repository is not None else Path(
|
||||||
stdout=output,
|
configured_authoritative_git_dir()
|
||||||
stderr=subprocess.PIPE,
|
),
|
||||||
timeout=60,
|
binding,
|
||||||
env={"PATH": os.environ.get("PATH", "")},
|
)
|
||||||
)
|
if required or binding.source_registry_main_oid is not None:
|
||||||
if completed.returncode != 0:
|
member_verification = _verify_artifact_members(
|
||||||
raise ReleaseBlocked("artifact zstd decompression failed")
|
path,
|
||||||
import tarfile
|
required or set(),
|
||||||
with tarfile.open(tar_path, "r:") as archive:
|
binding,
|
||||||
names: set[str] = set()
|
git_provenance,
|
||||||
for member in archive.getmembers():
|
)
|
||||||
candidate = Path(member.name)
|
|
||||||
if (
|
|
||||||
candidate.is_absolute()
|
|
||||||
or ".." in candidate.parts
|
|
||||||
or member.issym()
|
|
||||||
or member.islnk()
|
|
||||||
or not (member.isfile() or member.isdir())
|
|
||||||
):
|
|
||||||
raise ReleaseBlocked(f"unsafe artifact member: {member.name}")
|
|
||||||
names.add(member.name)
|
|
||||||
missing = sorted(REQUIRED_V2R1C7_MEMBERS - names)
|
|
||||||
if missing:
|
|
||||||
raise ReleaseBlocked(
|
|
||||||
"artifact missing required members: " + ",".join(missing)
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"artifact_path": str(path),
|
"artifact_path": str(path),
|
||||||
"artifact_sha256": actual,
|
"artifact_sha256": actual,
|
||||||
"artifact_size": binding.artifact_size,
|
"artifact_size": binding.artifact_size,
|
||||||
"verified": True,
|
"verified": True,
|
||||||
|
**member_verification,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -282,6 +610,18 @@ 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.tag_oid is not None:
|
||||||
|
expected["tag_oid"] = binding.tag_oid
|
||||||
|
expected["binding_schema"] = binding.binding_schema or "aios.release-binding.v2"
|
||||||
|
if binding.source_registry_main_oid is not None:
|
||||||
|
expected.update(
|
||||||
|
{
|
||||||
|
"source_registry_main_oid": binding.source_registry_main_oid,
|
||||||
|
"source_registry_path": binding.source_registry_path
|
||||||
|
or f"versions/{binding.version}.json",
|
||||||
|
"source_registry_sha256": binding.source_registry_sha256,
|
||||||
|
}
|
||||||
|
)
|
||||||
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}")
|
||||||
@@ -433,13 +773,29 @@ def tag_step(command: Sequence[str], binding: Binding) -> dict[str, Any]:
|
|||||||
tag_oid = probe.get("tag_oid")
|
tag_oid = probe.get("tag_oid")
|
||||||
if not isinstance(tag_oid, str) or not OID_RE.fullmatch(tag_oid):
|
if not isinstance(tag_oid, str) or not OID_RE.fullmatch(tag_oid):
|
||||||
raise ReleaseBlocked("verified annotated tag object OID missing")
|
raise ReleaseBlocked("verified annotated tag object OID missing")
|
||||||
|
if binding.tag_oid is not None and tag_oid != binding.tag_oid:
|
||||||
|
raise ManualOwnerReviewRequired(
|
||||||
|
"same-name tag object differs from the future release binding"
|
||||||
|
)
|
||||||
return dict(probe)
|
return dict(probe)
|
||||||
|
|
||||||
|
|
||||||
def deploy_step(command: Sequence[str], binding: Binding) -> dict[str, Any]:
|
def deploy_step(command: Sequence[str], binding: Binding) -> dict[str, Any]:
|
||||||
|
def require_runtime_registry(result: Mapping[str, Any]) -> None:
|
||||||
|
if binding.source_registry_main_oid is None:
|
||||||
|
return
|
||||||
|
if (
|
||||||
|
result.get("runtime_bundle_contains_current_version") is not True
|
||||||
|
or result.get("runtime_registry_converged") is not True
|
||||||
|
):
|
||||||
|
raise ReconciliationRequired(
|
||||||
|
"runtime bundle/runtime registry convergence was not independently verified"
|
||||||
|
)
|
||||||
|
|
||||||
probe = run_adapter(command, binding.request("probe"))
|
probe = run_adapter(command, binding.request("probe"))
|
||||||
require_exact_response(probe, binding)
|
require_exact_response(probe, binding)
|
||||||
if probe.get("status") == "ACTIVE_VERIFIED":
|
if probe.get("status") == "ACTIVE_VERIFIED":
|
||||||
|
require_runtime_registry(probe)
|
||||||
return dict(probe)
|
return dict(probe)
|
||||||
if probe.get("status") == "CONFLICT":
|
if probe.get("status") == "CONFLICT":
|
||||||
raise ManualOwnerReviewRequired("production runtime has conflicting binding")
|
raise ManualOwnerReviewRequired("production runtime has conflicting binding")
|
||||||
@@ -458,9 +814,30 @@ def deploy_step(command: Sequence[str], binding: Binding) -> dict[str, Any]:
|
|||||||
or probe.get("production_deployed") is not True
|
or probe.get("production_deployed") is not True
|
||||||
):
|
):
|
||||||
raise ReconciliationRequired("production health contract is not ACTIVE")
|
raise ReconciliationRequired("production health contract is not ACTIVE")
|
||||||
|
require_runtime_registry(probe)
|
||||||
return dict(probe)
|
return dict(probe)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_post_deploy_source_registry(
|
||||||
|
repository: Path,
|
||||||
|
binding: Binding,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Re-read authoritative Git after runtime deployment and before closure."""
|
||||||
|
if binding.source_registry_main_oid is None:
|
||||||
|
return {
|
||||||
|
"source_registry_converged": "not_required",
|
||||||
|
"source_registry_check": "not_required_for_legacy_binding",
|
||||||
|
}
|
||||||
|
provenance = _source_registry_git_provenance(repository, binding)
|
||||||
|
if provenance is None:
|
||||||
|
raise ReconciliationRequired("authoritative source registry was not checked")
|
||||||
|
return {
|
||||||
|
**provenance,
|
||||||
|
"source_registry_converged": True,
|
||||||
|
"source_registry_check": "post_deploy_authoritative_git_read",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def finalize_step(
|
def finalize_step(
|
||||||
command: Sequence[str],
|
command: Sequence[str],
|
||||||
binding: Binding,
|
binding: Binding,
|
||||||
@@ -501,7 +878,7 @@ def execute(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
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, repository=repository)
|
||||||
state = DurableState(state_root, binding)
|
state = DurableState(state_root, binding)
|
||||||
with FileLock(state_root / "executor.lock"):
|
with FileLock(state_root / "executor.lock"):
|
||||||
state.record(
|
state.record(
|
||||||
@@ -518,6 +895,13 @@ def execute(
|
|||||||
runtime = deploy_step(deploy_command, binding)
|
runtime = deploy_step(deploy_command, binding)
|
||||||
state.record("RUNTIME_VERIFIED", "runtime", runtime)
|
state.record("RUNTIME_VERIFIED", "runtime", runtime)
|
||||||
|
|
||||||
|
# The runtime adapter proves only runtime facts. Re-read M:path
|
||||||
|
# and C:path from authoritative Git immediately before Registry
|
||||||
|
# and Baseline closure so a post-preflight main change cannot be
|
||||||
|
# mistaken for source convergence.
|
||||||
|
source_registry = verify_post_deploy_source_registry(repository, binding)
|
||||||
|
state.record("SOURCE_REGISTRY_VERIFIED", "source_registry", source_registry)
|
||||||
|
|
||||||
registry = finalize_step(
|
registry = finalize_step(
|
||||||
registry_command,
|
registry_command,
|
||||||
binding,
|
binding,
|
||||||
@@ -547,10 +931,21 @@ def execute(
|
|||||||
require_exact_response(value, binding)
|
require_exact_response(value, binding)
|
||||||
if final_runtime.get("status") != "ACTIVE_VERIFIED":
|
if final_runtime.get("status") != "ACTIVE_VERIFIED":
|
||||||
raise ReconciliationRequired("final runtime readback failed")
|
raise ReconciliationRequired("final runtime readback failed")
|
||||||
|
if binding.source_registry_main_oid is not None and not all(
|
||||||
|
final_runtime.get(name) is True
|
||||||
|
for name in (
|
||||||
|
"runtime_bundle_contains_current_version",
|
||||||
|
"runtime_registry_converged",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ReconciliationRequired(
|
||||||
|
"final runtime registry convergence readback failed"
|
||||||
|
)
|
||||||
if final_registry.get("status") != "VERIFIED":
|
if final_registry.get("status") != "VERIFIED":
|
||||||
raise ReconciliationRequired("final Registry readback failed")
|
raise ReconciliationRequired("final Registry readback failed")
|
||||||
if final_baseline.get("status") != "VERIFIED":
|
if final_baseline.get("status") != "VERIFIED":
|
||||||
raise ReconciliationRequired("final Baseline readback failed")
|
raise ReconciliationRequired("final Baseline readback failed")
|
||||||
|
final_source_registry = verify_post_deploy_source_registry(repository, binding)
|
||||||
final = {
|
final = {
|
||||||
"status": "PRODUCTION_ACCEPTED",
|
"status": "PRODUCTION_ACCEPTED",
|
||||||
"version": binding.version,
|
"version": binding.version,
|
||||||
@@ -561,6 +956,7 @@ def execute(
|
|||||||
"tag_oid": tag["tag_oid"],
|
"tag_oid": tag["tag_oid"],
|
||||||
"tag": final_tag,
|
"tag": final_tag,
|
||||||
"runtime": final_runtime,
|
"runtime": final_runtime,
|
||||||
|
"source_registry": final_source_registry,
|
||||||
"registry": final_registry,
|
"registry": final_registry,
|
||||||
"baseline": final_baseline,
|
"baseline": final_baseline,
|
||||||
"force_push_used": False,
|
"force_push_used": False,
|
||||||
@@ -600,6 +996,11 @@ def parser() -> argparse.ArgumentParser:
|
|||||||
p.add_argument("--evidence-closure-oid", required=True)
|
p.add_argument("--evidence-closure-oid", required=True)
|
||||||
p.add_argument("--artifact-sha256", required=True)
|
p.add_argument("--artifact-sha256", required=True)
|
||||||
p.add_argument("--approval-plan-id", required=True)
|
p.add_argument("--approval-plan-id", required=True)
|
||||||
|
p.add_argument("--tag-oid")
|
||||||
|
p.add_argument("--binding-schema")
|
||||||
|
p.add_argument("--source-registry-main-oid")
|
||||||
|
p.add_argument("--source-registry-path")
|
||||||
|
p.add_argument("--source-registry-sha256")
|
||||||
p.add_argument("--production-service", required=True)
|
p.add_argument("--production-service", required=True)
|
||||||
p.add_argument("--production-port", required=True, 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")
|
||||||
@@ -623,6 +1024,11 @@ def main() -> int:
|
|||||||
production_service=args.production_service,
|
production_service=args.production_service,
|
||||||
production_port=args.production_port,
|
production_port=args.production_port,
|
||||||
repository_identity=args.repository_identity,
|
repository_identity=args.repository_identity,
|
||||||
|
tag_oid=args.tag_oid,
|
||||||
|
binding_schema=args.binding_schema,
|
||||||
|
source_registry_main_oid=args.source_registry_main_oid,
|
||||||
|
source_registry_path=args.source_registry_path,
|
||||||
|
source_registry_sha256=args.source_registry_sha256,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
result = execute(
|
result = execute(
|
||||||
|
|||||||
@@ -964,7 +964,6 @@ 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",
|
||||||
@@ -977,7 +976,6 @@ 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"]),
|
||||||
@@ -990,7 +988,6 @@ 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,
|
||||||
|
|||||||
@@ -64,6 +64,40 @@ class PublicationError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PublicationValueError(PublicationError, ValueError):
|
||||||
|
def __init__(self, code: str, message: str):
|
||||||
|
self.code = code
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidReleaseVersion(PublicationValueError):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("INVALID_RELEASE_VERSION", "invalid release version")
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaConfigurationError(PublicationValueError):
|
||||||
|
def __init__(self, code: str):
|
||||||
|
super().__init__(code, code)
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaUnavailableError(PublicationError):
|
||||||
|
def __init__(self, code: str = "GITEA_UNAVAILABLE"):
|
||||||
|
self.code = code
|
||||||
|
super().__init__(code)
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaTagNotFound(PublicationError):
|
||||||
|
def __init__(self):
|
||||||
|
self.code = "TAG_NOT_FOUND"
|
||||||
|
super().__init__(self.code)
|
||||||
|
|
||||||
|
|
||||||
|
class TagTargetMismatch(PublicationError):
|
||||||
|
def __init__(self):
|
||||||
|
self.code = "TAG_TARGET_MISMATCH"
|
||||||
|
super().__init__(self.code)
|
||||||
|
|
||||||
|
|
||||||
class PublicationConflict(PublicationError):
|
class PublicationConflict(PublicationError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -85,7 +119,7 @@ class PublicationManualOwnerReviewRequired(
|
|||||||
def validate_version(version: str) -> str:
|
def validate_version(version: str) -> str:
|
||||||
value = str(version or "")
|
value = str(version or "")
|
||||||
if not VERSION_RE.fullmatch(value):
|
if not VERSION_RE.fullmatch(value):
|
||||||
raise ValueError("invalid release version")
|
raise InvalidReleaseVersion()
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
@@ -402,6 +436,40 @@ class GitTagBackend:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GiteaClientConfig:
|
||||||
|
api_url: str
|
||||||
|
repository: str
|
||||||
|
token_file: Path
|
||||||
|
remote: str = "gitea"
|
||||||
|
token_line: int = 1
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_authoritative_environment(cls, environ: dict[str, str] | None = None) -> "GiteaClientConfig":
|
||||||
|
values = os.environ if environ is None else environ
|
||||||
|
api_url = str(values.get("AIOS_GITOPS_GITEA_API_URL", "")).strip()
|
||||||
|
repository = str(values.get("AIOS_GITOPS_GITEA_REPOSITORY", "")).strip()
|
||||||
|
token_ref = str(values.get("AIOS_GITOPS_GITEA_TOKEN_FILE", "")).strip()
|
||||||
|
if not api_url:
|
||||||
|
raise GiteaConfigurationError("GITEA_API_URL_MISSING")
|
||||||
|
if not api_url.startswith(("https://", "http://")):
|
||||||
|
raise GiteaConfigurationError("GITEA_API_URL_INVALID")
|
||||||
|
if not repository:
|
||||||
|
raise GiteaConfigurationError("GITEA_REPOSITORY_MISSING")
|
||||||
|
if repository.count("/") != 1 or repository.startswith("/"):
|
||||||
|
raise GiteaConfigurationError("GITEA_REPOSITORY_INVALID")
|
||||||
|
if not token_ref:
|
||||||
|
raise GiteaConfigurationError("GITEA_TOKEN_REFERENCE_MISSING")
|
||||||
|
token_file = Path(token_ref)
|
||||||
|
if not token_file.is_absolute():
|
||||||
|
raise GiteaConfigurationError("GITEA_TOKEN_REFERENCE_INVALID")
|
||||||
|
remote = str(values.get("AIOS_GITOPS_GITEA_REMOTE", "gitea")).strip() or "gitea"
|
||||||
|
token_line = int(str(values.get("AIOS_GITOPS_GITEA_TOKEN_LINE", "1")))
|
||||||
|
if token_line < 1:
|
||||||
|
raise GiteaConfigurationError("GITEA_TOKEN_REFERENCE_INVALID")
|
||||||
|
return cls(api_url, repository, token_file, remote, token_line)
|
||||||
|
|
||||||
|
|
||||||
class GiteaTagClient:
|
class GiteaTagClient:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -411,23 +479,37 @@ class GiteaTagClient:
|
|||||||
timeout: float = 15,
|
timeout: float = 15,
|
||||||
token_line: int = 1,
|
token_line: int = 1,
|
||||||
):
|
):
|
||||||
self.api_url = api_url.rstrip("/")
|
self.api_url = str(api_url or "").rstrip("/")
|
||||||
self.repository = repository.strip("/")
|
self.repository = str(repository or "").strip("/")
|
||||||
self.token_file = token_file
|
self.token_file = Path(token_file)
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.token_line = token_line
|
self.token_line = token_line
|
||||||
|
if not self.api_url:
|
||||||
|
raise GiteaConfigurationError("GITEA_API_URL_MISSING")
|
||||||
if not self.api_url.startswith(("https://", "http://")):
|
if not self.api_url.startswith(("https://", "http://")):
|
||||||
raise ValueError("Gitea API URL must be HTTP(S)")
|
raise GiteaConfigurationError("GITEA_API_URL_INVALID")
|
||||||
|
if not self.repository:
|
||||||
|
raise GiteaConfigurationError("GITEA_REPOSITORY_MISSING")
|
||||||
if self.repository.count("/") != 1:
|
if self.repository.count("/") != 1:
|
||||||
raise ValueError("Gitea repository must be owner/name")
|
raise GiteaConfigurationError("GITEA_REPOSITORY_INVALID")
|
||||||
|
if not str(token_file) or not self.token_file.is_absolute():
|
||||||
|
raise GiteaConfigurationError("GITEA_TOKEN_REFERENCE_MISSING")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_authoritative_environment(cls, environ: dict[str, str] | None = None) -> "GiteaTagClient":
|
||||||
|
config = GiteaClientConfig.from_authoritative_environment(environ)
|
||||||
|
return cls(config.api_url, config.repository, config.token_file, token_line=config.token_line)
|
||||||
|
|
||||||
def _get(self, path: str) -> Any:
|
def _get(self, path: str) -> Any:
|
||||||
lines = self.token_file.read_text(encoding="utf-8").splitlines()
|
try:
|
||||||
|
lines = self.token_file.read_text(encoding="utf-8").splitlines()
|
||||||
|
except (OSError, UnicodeError) as exc:
|
||||||
|
raise GiteaConfigurationError("GITEA_TOKEN_REFERENCE_UNAVAILABLE") from exc
|
||||||
if self.token_line < 1 or self.token_line > len(lines):
|
if self.token_line < 1 or self.token_line > len(lines):
|
||||||
raise RuntimeError("Gitea credential reference has no configured token line")
|
raise GiteaConfigurationError("GITEA_TOKEN_REFERENCE_INVALID")
|
||||||
token = lines[self.token_line - 1].strip()
|
token = lines[self.token_line - 1].strip()
|
||||||
if not token:
|
if not token:
|
||||||
raise RuntimeError("Gitea token file is empty")
|
raise GiteaConfigurationError("GITEA_TOKEN_REFERENCE_INVALID")
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
f"{self.api_url}{path}",
|
f"{self.api_url}{path}",
|
||||||
headers={
|
headers={
|
||||||
@@ -439,10 +521,12 @@ class GiteaTagClient:
|
|||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||||
return json.loads(response.read().decode("utf-8"))
|
return json.loads(response.read().decode("utf-8"))
|
||||||
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
status = getattr(exc, "code", None)
|
if exc.code == 404:
|
||||||
detail = f" HTTP {status}" if status else ""
|
raise GiteaTagNotFound() from exc
|
||||||
raise RuntimeError(f"Gitea API unavailable{detail}") from exc
|
raise GiteaUnavailableError() from exc
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||||
|
raise GiteaUnavailableError() from exc
|
||||||
|
|
||||||
def repository_identity(self) -> str:
|
def repository_identity(self) -> str:
|
||||||
repository = self._get(f"/repos/{self.repository}")
|
repository = self._get(f"/repos/{self.repository}")
|
||||||
@@ -950,6 +1034,12 @@ class TagPublisher:
|
|||||||
listing = state.get("gitea_listing_visible") is True
|
listing = state.get("gitea_listing_visible") is True
|
||||||
expected_tag_oid = state.get("tag_oid")
|
expected_tag_oid = state.get("tag_oid")
|
||||||
registry_tag_oid = registry.get("tag_oid") or registry.get("tag_object")
|
registry_tag_oid = registry.get("tag_oid") or registry.get("tag_object")
|
||||||
|
target_mismatch = any(
|
||||||
|
isinstance(value, tuple)
|
||||||
|
and len(value) > 1
|
||||||
|
and value[1] not in (None, request.target_oid)
|
||||||
|
for value in (remote, api)
|
||||||
|
)
|
||||||
checks = {
|
checks = {
|
||||||
"fingerprint": all(
|
"fingerprint": all(
|
||||||
baseline.get(field) == getattr(request, field)
|
baseline.get(field) == getattr(request, field)
|
||||||
@@ -974,7 +1064,7 @@ class TagPublisher:
|
|||||||
and registry.get("enabled") is True,
|
and registry.get("enabled") is True,
|
||||||
}
|
}
|
||||||
status = "PASS" if all(checks.values()) else "FAIL"
|
status = "PASS" if all(checks.values()) else "FAIL"
|
||||||
return {
|
result = {
|
||||||
"version": request.version,
|
"version": request.version,
|
||||||
"status": status,
|
"status": status,
|
||||||
"checks": checks,
|
"checks": checks,
|
||||||
@@ -986,8 +1076,19 @@ class TagPublisher:
|
|||||||
"gitea_listing_visible": listing,
|
"gitea_listing_visible": listing,
|
||||||
"repository_identity": repository_identity,
|
"repository_identity": repository_identity,
|
||||||
}
|
}
|
||||||
|
if target_mismatch:
|
||||||
|
result["fail_closed"] = True
|
||||||
|
result["error"] = TagTargetMismatch().code
|
||||||
|
return result
|
||||||
except PublicationManualOwnerReviewRequired:
|
except PublicationManualOwnerReviewRequired:
|
||||||
raise
|
raise
|
||||||
|
except PublicationError as exc:
|
||||||
|
return {
|
||||||
|
"version": request.version,
|
||||||
|
"status": "UNKNOWN",
|
||||||
|
"fail_closed": True,
|
||||||
|
"error": str(getattr(exc, "code", exc)),
|
||||||
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {
|
return {
|
||||||
"version": request.version,
|
"version": request.version,
|
||||||
|
|||||||
@@ -29,9 +29,18 @@ from gitea_tag_publication import (
|
|||||||
PublicationRequest,
|
PublicationRequest,
|
||||||
TagPublisher,
|
TagPublisher,
|
||||||
)
|
)
|
||||||
|
from version_registry import (
|
||||||
|
RegistryConvergenceError,
|
||||||
|
assert_source_registry_convergence,
|
||||||
|
resolve_source_registry_main_oid,
|
||||||
|
source_registry_snapshot,
|
||||||
|
verify_authoritative_repository,
|
||||||
|
verify_source_registry_git_identity,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
VERSION = "V2R2C1"
|
VERSION = "V2R2C1"
|
||||||
|
TAG_OID = "07fc2746b688f5f243f29b39de39f32edc3dd9d2"
|
||||||
TARGET_OID = "998d30304787e31e5ae607c0bd86b5edb2b0e5ad"
|
TARGET_OID = "998d30304787e31e5ae607c0bd86b5edb2b0e5ad"
|
||||||
EVIDENCE_CLOSURE_OID = "a8b906d1bc24088c19647e98de6a679268f6d0a2"
|
EVIDENCE_CLOSURE_OID = "a8b906d1bc24088c19647e98de6a679268f6d0a2"
|
||||||
APPROVAL_PLAN_ID = "sha256:c0df52008ae7f64cec5ee2d28947aa7b892066c670ab8295e4549140b1a7500e"
|
APPROVAL_PLAN_ID = "sha256:c0df52008ae7f64cec5ee2d28947aa7b892066c670ab8295e4549140b1a7500e"
|
||||||
@@ -79,6 +88,7 @@ def atomic_json(path: Path, value: Mapping[str, Any], mode: int = 0o600) -> None
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ReleaseBinding:
|
class ReleaseBinding:
|
||||||
version: str = VERSION
|
version: str = VERSION
|
||||||
|
tag_oid: str = TAG_OID
|
||||||
target_oid: str = TARGET_OID
|
target_oid: str = TARGET_OID
|
||||||
evidence_closure_oid: str = EVIDENCE_CLOSURE_OID
|
evidence_closure_oid: str = EVIDENCE_CLOSURE_OID
|
||||||
approval_plan_id: str = APPROVAL_PLAN_ID
|
approval_plan_id: str = APPROVAL_PLAN_ID
|
||||||
@@ -89,6 +99,8 @@ class ReleaseBinding:
|
|||||||
def validate(self) -> None:
|
def validate(self) -> None:
|
||||||
if self.version != VERSION or self.repository != REPOSITORY:
|
if self.version != VERSION or self.repository != REPOSITORY:
|
||||||
raise ManualOwnerReviewRequired("release binding is outside V2R2C1 scope")
|
raise ManualOwnerReviewRequired("release binding is outside V2R2C1 scope")
|
||||||
|
if not OID_RE.fullmatch(self.tag_oid) or self.tag_oid != TAG_OID:
|
||||||
|
raise ManualOwnerReviewRequired("V2R2C1 tag_oid mismatch")
|
||||||
if not OID_RE.fullmatch(self.target_oid) or self.target_oid != TARGET_OID:
|
if not OID_RE.fullmatch(self.target_oid) or self.target_oid != TARGET_OID:
|
||||||
raise ManualOwnerReviewRequired("V2R2C1 target_oid mismatch")
|
raise ManualOwnerReviewRequired("V2R2C1 target_oid mismatch")
|
||||||
if not OID_RE.fullmatch(self.evidence_closure_oid) or self.evidence_closure_oid != EVIDENCE_CLOSURE_OID:
|
if not OID_RE.fullmatch(self.evidence_closure_oid) or self.evidence_closure_oid != EVIDENCE_CLOSURE_OID:
|
||||||
@@ -192,19 +204,48 @@ def _ancestor(git_dir: Path, ancestor: str, descendant: str) -> bool:
|
|||||||
).returncode == 0
|
).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
def verify_lineage(git_dir: Path, binding: ReleaseBinding) -> dict[str, Any]:
|
def verify_lineage(
|
||||||
|
git_dir: Path,
|
||||||
|
binding: ReleaseBinding,
|
||||||
|
*,
|
||||||
|
authority_main_oid: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
binding.validate()
|
binding.validate()
|
||||||
|
main_oid = authority_main_oid or _git(git_dir, "rev-parse", "refs/heads/main")
|
||||||
|
if not OID_RE.fullmatch(main_oid) or _git(git_dir, "cat-file", "-t", main_oid) != "commit":
|
||||||
|
raise ReleaseBlocked("authoritative main OID is not a commit")
|
||||||
if _git(git_dir, "cat-file", "-t", binding.target_oid) != "commit":
|
if _git(git_dir, "cat-file", "-t", binding.target_oid) != "commit":
|
||||||
raise ReleaseBlocked("approved target_oid is not a commit")
|
raise ReleaseBlocked("approved target_oid is not a commit")
|
||||||
if _git(git_dir, "cat-file", "-t", binding.evidence_closure_oid) != "commit":
|
if _git(git_dir, "cat-file", "-t", binding.evidence_closure_oid) != "commit":
|
||||||
raise ReleaseBlocked("evidence closure is not a commit")
|
raise ReleaseBlocked("evidence closure is not a commit")
|
||||||
if not _ancestor(git_dir, binding.target_oid, binding.evidence_closure_oid):
|
if not _ancestor(git_dir, binding.target_oid, binding.evidence_closure_oid):
|
||||||
raise ManualOwnerReviewRequired("approved target is not an ancestor of evidence closure")
|
raise ManualOwnerReviewRequired("approved target is not an ancestor of evidence closure")
|
||||||
|
if not _ancestor(git_dir, binding.target_oid, main_oid):
|
||||||
|
raise ManualOwnerReviewRequired("approved target is not an ancestor of source registry main")
|
||||||
return {
|
return {
|
||||||
"target_oid": binding.target_oid,
|
"target_oid": binding.target_oid,
|
||||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||||
"target_is_ancestor_of_evidence": True,
|
"target_is_ancestor_of_evidence": True,
|
||||||
"main_oid": _git(git_dir, "rev-parse", "refs/heads/main"),
|
"main_oid": main_oid,
|
||||||
|
"target_is_ancestor_of_main": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def verify_local_annotated_tag(git_dir: Path, binding: ReleaseBinding) -> dict[str, Any]:
|
||||||
|
"""Verify the local authoritative tag object without creating or rewriting it."""
|
||||||
|
tag_oid = _git(git_dir, "rev-parse", "--verify", f"refs/tags/{binding.version}")
|
||||||
|
if tag_oid != binding.tag_oid:
|
||||||
|
raise ManualOwnerReviewRequired("local annotated tag object mismatch")
|
||||||
|
if _git(git_dir, "cat-file", "-t", tag_oid) != "tag":
|
||||||
|
raise ReleaseBlocked("release tag is not annotated")
|
||||||
|
peeled = _git(git_dir, "rev-parse", "--verify", f"{tag_oid}^{{commit}}")
|
||||||
|
if peeled != binding.target_oid:
|
||||||
|
raise ManualOwnerReviewRequired("local annotated tag target mismatch")
|
||||||
|
return {
|
||||||
|
"tag_oid": tag_oid,
|
||||||
|
"tag_object_type": "tag",
|
||||||
|
"peeled_target_oid": peeled,
|
||||||
|
"target_oid": binding.target_oid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -248,9 +289,41 @@ def build_production_artifact(
|
|||||||
binding: ReleaseBinding,
|
binding: ReleaseBinding,
|
||||||
output_root: Path,
|
output_root: Path,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build a deterministic executable V2R2C1 health artifact from target_oid."""
|
"""Build a deterministic artifact from composite immutable provenance.
|
||||||
|
|
||||||
|
Release payload files are read from target ``T``. The source registry is
|
||||||
|
read once from the pinned main/convergence commit ``M`` and its exact blob
|
||||||
|
bytes are reused for packaging and all recorded digests.
|
||||||
|
"""
|
||||||
binding.validate()
|
binding.validate()
|
||||||
verify_lineage(git_dir, binding)
|
verify_authoritative_repository(git_dir)
|
||||||
|
source_registry_main_oid = resolve_source_registry_main_oid(
|
||||||
|
git_dir,
|
||||||
|
main_ref="refs/heads/main",
|
||||||
|
)
|
||||||
|
verify_lineage(git_dir, binding, authority_main_oid=source_registry_main_oid)
|
||||||
|
tag_lineage = verify_local_annotated_tag(git_dir, binding)
|
||||||
|
try:
|
||||||
|
source_snapshot = source_registry_snapshot(
|
||||||
|
git_dir,
|
||||||
|
VERSION,
|
||||||
|
source_registry_main_oid=source_registry_main_oid,
|
||||||
|
expected_tag_oid=binding.tag_oid,
|
||||||
|
expected_target_oid=binding.target_oid,
|
||||||
|
expected_evidence_closure_oid=binding.evidence_closure_oid,
|
||||||
|
expected_approval_plan_id=binding.approval_plan_id,
|
||||||
|
)
|
||||||
|
source_registry = source_snapshot["record"]
|
||||||
|
source_registry_bytes = source_snapshot["bytes"]
|
||||||
|
verify_source_registry_git_identity(git_dir, source_registry)
|
||||||
|
except RegistryConvergenceError as exc:
|
||||||
|
raise ReleaseBlocked(f"SOURCE_REGISTRY_NOT_CONVERGED:{exc}") from exc
|
||||||
|
if source_snapshot["source_registry_main_oid"] != source_registry_main_oid:
|
||||||
|
raise ReconciliationRequired("source registry main OID changed during build")
|
||||||
|
if not _ancestor(git_dir, binding.target_oid, source_registry_main_oid):
|
||||||
|
raise ManualOwnerReviewRequired("release target is not an ancestor of source registry main")
|
||||||
|
source_registry_path = source_snapshot["source_registry_path"]
|
||||||
|
source_registry_sha256 = source_snapshot["source_registry_sha256"]
|
||||||
epoch = _commit_epoch(git_dir, binding.target_oid)
|
epoch = _commit_epoch(git_dir, binding.target_oid)
|
||||||
output_root.mkdir(parents=True, exist_ok=True)
|
output_root.mkdir(parents=True, exist_ok=True)
|
||||||
stem = f"aios-{VERSION}-{binding.target_oid[:12]}"
|
stem = f"aios-{VERSION}-{binding.target_oid[:12]}"
|
||||||
@@ -261,11 +334,15 @@ def build_production_artifact(
|
|||||||
source_paths = {
|
source_paths = {
|
||||||
"active-runtime/app/deployment_state.py": "active-runtime/app/deployment_state.py",
|
"active-runtime/app/deployment_state.py": "active-runtime/app/deployment_state.py",
|
||||||
"active-runtime/app/v2r1c6_privacy_tools.py": "active-runtime/app/v2r1c6_privacy_tools.py",
|
"active-runtime/app/v2r1c6_privacy_tools.py": "active-runtime/app/v2r1c6_privacy_tools.py",
|
||||||
|
"versions/V2R2C1.json": "versions/V2R2C1.json",
|
||||||
}
|
}
|
||||||
for destination, source in source_paths.items():
|
for destination, source in source_paths.items():
|
||||||
target = root / destination
|
target = root / destination
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
target.write_bytes(_payload_file(git_dir, binding.target_oid, source))
|
if destination == source_registry_path:
|
||||||
|
target.write_bytes(source_registry_bytes)
|
||||||
|
else:
|
||||||
|
target.write_bytes(_payload_file(git_dir, binding.target_oid, source))
|
||||||
generated: dict[str, str] = {
|
generated: dict[str, str] = {
|
||||||
"VERSION": VERSION + "\n",
|
"VERSION": VERSION + "\n",
|
||||||
"active-runtime/app/v2r2c1_health_service.py": (
|
"active-runtime/app/v2r2c1_health_service.py": (
|
||||||
@@ -307,16 +384,6 @@ def build_production_artifact(
|
|||||||
"with urllib.request.urlopen(url, timeout=5) as r: print(json.dumps(json.load(r), sort_keys=True))\n"
|
"with urllib.request.urlopen(url, timeout=5) as r: print(json.dumps(json.load(r), sort_keys=True))\n"
|
||||||
),
|
),
|
||||||
"scripts/rollback_v2r2c1_release.sh": "#!/bin/sh\nset -eu\nprintf '%s\\n' 'rollback requires governed owner-approved executor'\nexit 2\n",
|
"scripts/rollback_v2r2c1_release.sh": "#!/bin/sh\nset -eu\nprintf '%s\\n' 'rollback requires governed owner-approved executor'\nexit 2\n",
|
||||||
"release-manifests/V2R2C1.json": json.dumps({
|
|
||||||
"schema": "aios.release-source.v2",
|
|
||||||
"version": VERSION,
|
|
||||||
"repository": REPOSITORY,
|
|
||||||
"target_oid": binding.target_oid,
|
|
||||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
|
||||||
"production_deployed": False,
|
|
||||||
"deployment_state": "STAGING",
|
|
||||||
"artifact_kind": "executable-production-health",
|
|
||||||
}, sort_keys=True, separators=(",", ":")) + "\n",
|
|
||||||
}
|
}
|
||||||
for rel, text in generated.items():
|
for rel, text in generated.items():
|
||||||
target = root / rel
|
target = root / rel
|
||||||
@@ -328,6 +395,10 @@ def build_production_artifact(
|
|||||||
"schema": "aios.release-artifact-build.v3",
|
"schema": "aios.release-artifact-build.v3",
|
||||||
"version": VERSION,
|
"version": VERSION,
|
||||||
"repository": REPOSITORY,
|
"repository": REPOSITORY,
|
||||||
|
"release_target_oid": binding.target_oid,
|
||||||
|
"source_registry_main_oid": source_registry_main_oid,
|
||||||
|
"source_registry_path": source_registry_path,
|
||||||
|
"source_registry_sha256": source_registry_sha256,
|
||||||
"source_head_oid": binding.target_oid,
|
"source_head_oid": binding.target_oid,
|
||||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||||
"source_date_epoch": epoch,
|
"source_date_epoch": epoch,
|
||||||
@@ -337,6 +408,31 @@ def build_production_artifact(
|
|||||||
"tag_created": False,
|
"tag_created": False,
|
||||||
"baseline_changed": False,
|
"baseline_changed": False,
|
||||||
"secrets_included": False,
|
"secrets_included": False,
|
||||||
|
"source_registry": {
|
||||||
|
"path": source_registry_path,
|
||||||
|
"version": source_registry["version"],
|
||||||
|
"tag_oid": source_registry["tag_oid"],
|
||||||
|
"target_oid": source_registry["target_oid"],
|
||||||
|
"peeled_target_oid": source_registry["peeled_target_oid"],
|
||||||
|
"evidence_closure_oid": source_registry["evidence_closure_oid"],
|
||||||
|
"approval_plan_id": source_registry["approval_plan_id"],
|
||||||
|
"source_registry_main_oid": source_registry_main_oid,
|
||||||
|
"source_registry_sha256": source_registry_sha256,
|
||||||
|
},
|
||||||
|
"provenance": {
|
||||||
|
"release_payload_source": {
|
||||||
|
"commit_oid": binding.target_oid,
|
||||||
|
"role": "release_target",
|
||||||
|
},
|
||||||
|
"source_registry_source": {
|
||||||
|
"commit_oid": source_registry_main_oid,
|
||||||
|
"path": source_registry_path,
|
||||||
|
"sha256": source_registry_sha256,
|
||||||
|
"role": "source_registry_convergence",
|
||||||
|
},
|
||||||
|
"release_target_is_ancestor_of_source_registry_main": True,
|
||||||
|
"local_annotated_tag": tag_lineage,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
(root / "BUILD-METADATA.json").write_bytes(canonical(metadata))
|
(root / "BUILD-METADATA.json").write_bytes(canonical(metadata))
|
||||||
(root / "ROLLBACK-METADATA.json").write_bytes(canonical(json.loads((root / "deploy/aios-v2/v2r2c1-rollback.json").read_text(encoding="utf-8"))))
|
(root / "ROLLBACK-METADATA.json").write_bytes(canonical(json.loads((root / "deploy/aios-v2/v2r2c1-rollback.json").read_text(encoding="utf-8"))))
|
||||||
@@ -344,6 +440,10 @@ def build_production_artifact(
|
|||||||
(root / "ARTIFACT-MANIFEST.json").write_bytes(canonical({
|
(root / "ARTIFACT-MANIFEST.json").write_bytes(canonical({
|
||||||
"schema": "aios.release-artifact-manifest.v3",
|
"schema": "aios.release-artifact-manifest.v3",
|
||||||
"version": VERSION,
|
"version": VERSION,
|
||||||
|
"release_target_oid": binding.target_oid,
|
||||||
|
"source_registry_main_oid": source_registry_main_oid,
|
||||||
|
"source_registry_path": source_registry_path,
|
||||||
|
"source_registry_sha256": source_registry_sha256,
|
||||||
"source_head_oid": binding.target_oid,
|
"source_head_oid": binding.target_oid,
|
||||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||||
"repository": REPOSITORY,
|
"repository": REPOSITORY,
|
||||||
@@ -375,6 +475,10 @@ def build_production_artifact(
|
|||||||
"schema": "aios.release-artifact-receipt.v3",
|
"schema": "aios.release-artifact-receipt.v3",
|
||||||
"version": VERSION,
|
"version": VERSION,
|
||||||
"repository": REPOSITORY,
|
"repository": REPOSITORY,
|
||||||
|
"release_target_oid": binding.target_oid,
|
||||||
|
"source_registry_main_oid": source_registry_main_oid,
|
||||||
|
"source_registry_path": source_registry_path,
|
||||||
|
"source_registry_sha256": source_registry_sha256,
|
||||||
"source_head_oid": binding.target_oid,
|
"source_head_oid": binding.target_oid,
|
||||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||||
"artifact": str(output),
|
"artifact": str(output),
|
||||||
@@ -385,6 +489,14 @@ def build_production_artifact(
|
|||||||
"production_deployed": False,
|
"production_deployed": False,
|
||||||
"tag_created": False,
|
"tag_created": False,
|
||||||
"baseline_changed": False,
|
"baseline_changed": False,
|
||||||
|
"source_registry_path": source_registry_path,
|
||||||
|
"source_registry_identity": source_registry,
|
||||||
|
"provenance": {
|
||||||
|
"release_payload_source": binding.target_oid,
|
||||||
|
"source_registry_source": source_registry_main_oid,
|
||||||
|
"release_target_is_ancestor_of_source_registry_main": True,
|
||||||
|
"local_annotated_tag": tag_lineage,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
atomic_json(output.with_suffix(output.suffix + ".receipt.json"), receipt, mode=0o640)
|
atomic_json(output.with_suffix(output.suffix + ".receipt.json"), receipt, mode=0o640)
|
||||||
return receipt
|
return receipt
|
||||||
@@ -458,6 +570,12 @@ class GovernedProductionReleaseExecutor:
|
|||||||
self.state.fail("RECONCILIATION_REQUIRED", str(exc))
|
self.state.fail("RECONCILIATION_REQUIRED", str(exc))
|
||||||
raise ReconciliationRequired(str(exc)) from exc
|
raise ReconciliationRequired(str(exc)) from exc
|
||||||
result = dict(verified)
|
result = dict(verified)
|
||||||
|
if result.get("tag_oid") != self.binding.tag_oid:
|
||||||
|
self.state.fail(
|
||||||
|
"MANUAL_OWNER_REVIEW_REQUIRED",
|
||||||
|
"same-name tag object differs from the approved V2R2C1 tag",
|
||||||
|
)
|
||||||
|
raise ManualOwnerReviewRequired("approved tag object mismatch")
|
||||||
self.state.record("TAG_VERIFIED", "tag", result)
|
self.state.record("TAG_VERIFIED", "tag", result)
|
||||||
return {"status": "REMOTE_VERIFIED", **result}
|
return {"status": "REMOTE_VERIFIED", **result}
|
||||||
|
|
||||||
@@ -478,6 +596,8 @@ class GovernedProductionReleaseExecutor:
|
|||||||
tag_state = self.state.value.get("completed", {}).get("tag")
|
tag_state = self.state.value.get("completed", {}).get("tag")
|
||||||
if not isinstance(tag_state, Mapping) or tag_state.get("state") not in {"GITEA_API_CONFIRMED", "REMOTE_VERIFIED"}:
|
if not isinstance(tag_state, Mapping) or tag_state.get("state") not in {"GITEA_API_CONFIRMED", "REMOTE_VERIFIED"}:
|
||||||
raise ReleaseBlocked("REMOTE_TAG_PROOF_REQUIRED")
|
raise ReleaseBlocked("REMOTE_TAG_PROOF_REQUIRED")
|
||||||
|
if tag_state.get("tag_oid") != self.binding.tag_oid:
|
||||||
|
raise ManualOwnerReviewRequired("approved tag object mismatch")
|
||||||
artifact = self.state.value.get("completed", {}).get("production_artifact")
|
artifact = self.state.value.get("completed", {}).get("production_artifact")
|
||||||
if not isinstance(artifact, Mapping):
|
if not isinstance(artifact, Mapping):
|
||||||
artifact = self.build_artifact(request)
|
artifact = self.build_artifact(request)
|
||||||
@@ -488,6 +608,13 @@ class GovernedProductionReleaseExecutor:
|
|||||||
"artifact_size": int(artifact["artifact_size"]),
|
"artifact_size": int(artifact["artifact_size"]),
|
||||||
"artifact_sha256": artifact["artifact_sha256"],
|
"artifact_sha256": artifact["artifact_sha256"],
|
||||||
}
|
}
|
||||||
|
for name in (
|
||||||
|
"source_registry_main_oid",
|
||||||
|
"source_registry_path",
|
||||||
|
"source_registry_sha256",
|
||||||
|
):
|
||||||
|
if name in artifact:
|
||||||
|
value[name] = artifact[name]
|
||||||
# The production adapter is privileged and is run through the root
|
# The production adapter is privileged and is run through the root
|
||||||
# approved-release service boundary by the caller. This handler is
|
# approved-release service boundary by the caller. This handler is
|
||||||
# still useful for a root-owned staging invocation and remains
|
# still useful for a root-owned staging invocation and remains
|
||||||
@@ -507,6 +634,21 @@ class GovernedProductionReleaseExecutor:
|
|||||||
result["production_health"] = _health(self.config.health_url, self.binding, self.state.value.get("completed", {}).get("production_artifact", {}))
|
result["production_health"] = _health(self.config.health_url, self.binding, self.state.value.get("completed", {}).get("production_artifact", {}))
|
||||||
result["registry"] = _read_json(Path(self.config.registry_root) / f"{VERSION}.json")
|
result["registry"] = _read_json(Path(self.config.registry_root) / f"{VERSION}.json")
|
||||||
result["baseline"] = _read_json(Path(self.config.baseline_path))
|
result["baseline"] = _read_json(Path(self.config.baseline_path))
|
||||||
|
try:
|
||||||
|
assert_source = Path(self.config.registry_root) / f"{VERSION}.json"
|
||||||
|
if not assert_source.is_file():
|
||||||
|
raise RegistryConvergenceError("runtime_registry_entry_missing")
|
||||||
|
assert_source_registry_convergence(
|
||||||
|
assert_source,
|
||||||
|
repo_git_dir=self.config.authoritative_git_dir,
|
||||||
|
)
|
||||||
|
source_check = {"status": "PASS", "path": str(assert_source)}
|
||||||
|
except RegistryConvergenceError as exc:
|
||||||
|
source_check = {"status": "FAIL", "error": str(exc)}
|
||||||
|
result["source_registry"] = source_check
|
||||||
|
result.setdefault("checks", {})["source_registry"] = source_check["status"] == "PASS"
|
||||||
|
if result.get("status") == "PASS" and source_check["status"] != "PASS":
|
||||||
|
result["status"] = "FAIL"
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def acceptance(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
def acceptance(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -1,456 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
||||||
@@ -7,7 +7,7 @@ Registry and authoritative runtime-anchor Baseline only.
|
|||||||
Registry is finalized before Baseline. Baseline apply independently verifies:
|
Registry is finalized before Baseline. Baseline apply independently verifies:
|
||||||
- exact Authority annotated tag and peeled target
|
- exact Authority annotated tag and peeled target
|
||||||
- exact ACTIVE production health
|
- exact ACTIVE production health
|
||||||
- exact V2R1C7 Registry record
|
- exact runtime Registry record and source-registry convergence
|
||||||
- exact previous Baseline file SHA-256 (CAS)
|
- exact previous Baseline file SHA-256 (CAS)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -25,6 +25,7 @@ import time
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from version_registry import assert_source_registry_convergence
|
||||||
from typing import Any, Mapping
|
from typing import Any, Mapping
|
||||||
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
@@ -97,11 +98,40 @@ def load_request() -> dict[str, Any]:
|
|||||||
raise ValueError("approval_plan_id_invalid")
|
raise ValueError("approval_plan_id_invalid")
|
||||||
if "tag_oid" in value and not OID_RE.fullmatch(str(value["tag_oid"])):
|
if "tag_oid" in value and not OID_RE.fullmatch(str(value["tag_oid"])):
|
||||||
raise ValueError("tag_oid_invalid")
|
raise ValueError("tag_oid_invalid")
|
||||||
|
if "source_registry_main_oid" in value and not OID_RE.fullmatch(
|
||||||
|
str(value["source_registry_main_oid"])
|
||||||
|
):
|
||||||
|
raise ValueError("source_registry_main_oid_invalid")
|
||||||
|
if "source_registry_sha256" in value and not SHA_RE.fullmatch(
|
||||||
|
str(value["source_registry_sha256"])
|
||||||
|
):
|
||||||
|
raise ValueError("source_registry_sha256_invalid")
|
||||||
|
if "source_registry_path" in value:
|
||||||
|
source_path = str(value["source_registry_path"])
|
||||||
|
if (
|
||||||
|
not source_path.startswith("versions/")
|
||||||
|
or ".." in Path(source_path).parts
|
||||||
|
or "\\" in source_path
|
||||||
|
or not source_path.endswith(".json")
|
||||||
|
):
|
||||||
|
raise ValueError("source_registry_path_invalid")
|
||||||
|
provenance = tuple(
|
||||||
|
value.get(name)
|
||||||
|
for name in (
|
||||||
|
"source_registry_main_oid",
|
||||||
|
"source_registry_path",
|
||||||
|
"source_registry_sha256",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if any(item is not None for item in provenance) and not all(
|
||||||
|
item is not None for item in provenance
|
||||||
|
):
|
||||||
|
raise ValueError("source_registry_provenance_incomplete")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def exact_response(value: Mapping[str, Any], status: str, **extra: Any) -> dict[str, Any]:
|
def exact_response(value: Mapping[str, Any], status: str, **extra: Any) -> dict[str, Any]:
|
||||||
return {
|
result = {
|
||||||
"status": status,
|
"status": status,
|
||||||
"version": value["version"],
|
"version": value["version"],
|
||||||
"target_oid": value["target_oid"],
|
"target_oid": value["target_oid"],
|
||||||
@@ -110,6 +140,14 @@ def exact_response(value: Mapping[str, Any], status: str, **extra: Any) -> dict[
|
|||||||
"approval_plan_id": value["approval_plan_id"],
|
"approval_plan_id": value["approval_plan_id"],
|
||||||
**extra,
|
**extra,
|
||||||
}
|
}
|
||||||
|
for name in (
|
||||||
|
"source_registry_main_oid",
|
||||||
|
"source_registry_path",
|
||||||
|
"source_registry_sha256",
|
||||||
|
):
|
||||||
|
if name in value:
|
||||||
|
result[name] = value[name]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def git(repository: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
def git(repository: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||||
@@ -154,7 +192,7 @@ def health(url: str, value: Mapping[str, Any]) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def registry_record(value: Mapping[str, Any], tag_oid: str, production_health: Mapping[str, Any]) -> dict[str, Any]:
|
def registry_record(value: Mapping[str, Any], tag_oid: str, production_health: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
return {
|
result = {
|
||||||
"schema": "aios.production-registry.v2",
|
"schema": "aios.production-registry.v2",
|
||||||
"version": value["version"],
|
"version": value["version"],
|
||||||
"repository": "aios/aios",
|
"repository": "aios/aios",
|
||||||
@@ -175,10 +213,18 @@ def registry_record(value: Mapping[str, Any], tag_oid: str, production_health: M
|
|||||||
"deployment_state": production_health["deployment_state"],
|
"deployment_state": production_health["deployment_state"],
|
||||||
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
}
|
}
|
||||||
|
for name in (
|
||||||
|
"source_registry_main_oid",
|
||||||
|
"source_registry_path",
|
||||||
|
"source_registry_sha256",
|
||||||
|
):
|
||||||
|
if name in value:
|
||||||
|
result[name] = value[name]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def registry_matches(record: Mapping[str, Any], value: Mapping[str, Any], tag_oid: str) -> bool:
|
def registry_matches(record: Mapping[str, Any], value: Mapping[str, Any], tag_oid: str) -> bool:
|
||||||
return all(
|
matches = all(
|
||||||
record.get(key) == expected
|
record.get(key) == expected
|
||||||
for key, expected in {
|
for key, expected in {
|
||||||
"version": value["version"],
|
"version": value["version"],
|
||||||
@@ -192,11 +238,16 @@ def registry_matches(record: Mapping[str, Any], value: Mapping[str, Any], tag_oi
|
|||||||
"production_deployed": True,
|
"production_deployed": True,
|
||||||
}.items()
|
}.items()
|
||||||
) and record.get("registered") is True and record.get("enabled") is True
|
) and record.get("registered") is True and record.get("enabled") is True
|
||||||
|
return matches and all(
|
||||||
|
record.get(name) == value.get(name)
|
||||||
|
for name in ("source_registry_main_oid", "source_registry_path", "source_registry_sha256")
|
||||||
|
if name in value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def baseline_matches(record: Mapping[str, Any], value: Mapping[str, Any], tag_oid: str) -> bool:
|
def baseline_matches(record: Mapping[str, Any], value: Mapping[str, Any], tag_oid: str) -> bool:
|
||||||
current = record.get("current_baseline") or record.get("version")
|
current = record.get("current_baseline") or record.get("version")
|
||||||
return current == value["version"] and all(
|
matches = current == value["version"] and all(
|
||||||
record.get(key) == expected
|
record.get(key) == expected
|
||||||
for key, expected in {
|
for key, expected in {
|
||||||
"target_oid": value["target_oid"],
|
"target_oid": value["target_oid"],
|
||||||
@@ -209,6 +260,11 @@ def baseline_matches(record: Mapping[str, Any], value: Mapping[str, Any], tag_oi
|
|||||||
"status": "PRODUCTION_DEPLOYED",
|
"status": "PRODUCTION_DEPLOYED",
|
||||||
}.items()
|
}.items()
|
||||||
)
|
)
|
||||||
|
return matches and all(
|
||||||
|
record.get(name) == value.get(name)
|
||||||
|
for name in ("source_registry_main_oid", "source_registry_path", "source_registry_sha256")
|
||||||
|
if name in value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def registry_probe(args: argparse.Namespace, value: Mapping[str, Any]) -> dict[str, Any]:
|
def registry_probe(args: argparse.Namespace, value: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
@@ -305,6 +361,11 @@ def baseline_apply(args: argparse.Namespace, value: Mapping[str, Any]) -> dict[s
|
|||||||
int(previous.get("generation", 0) or 0),
|
int(previous.get("generation", 0) or 0),
|
||||||
int(previous.get("authority_generation", 0) or 0),
|
int(previous.get("authority_generation", 0) or 0),
|
||||||
) + 1
|
) + 1
|
||||||
|
assert_source_registry_convergence(
|
||||||
|
registry_path,
|
||||||
|
repo_git_dir=args.repository,
|
||||||
|
source_registry_main_oid=value.get("source_registry_main_oid"),
|
||||||
|
)
|
||||||
record = {
|
record = {
|
||||||
"schema": "aios.production-baseline.v2",
|
"schema": "aios.production-baseline.v2",
|
||||||
"source_identity": "aios-gitops-runtime-anchor-baseline-authority-v1",
|
"source_identity": "aios-gitops-runtime-anchor-baseline-authority-v1",
|
||||||
@@ -336,6 +397,13 @@ def baseline_apply(args: argparse.Namespace, value: Mapping[str, Any]) -> dict[s
|
|||||||
"promoted_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
"promoted_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
"promoted_by": "aios-approved-production-release-executor",
|
"promoted_by": "aios-approved-production-release-executor",
|
||||||
}
|
}
|
||||||
|
for name in (
|
||||||
|
"source_registry_main_oid",
|
||||||
|
"source_registry_path",
|
||||||
|
"source_registry_sha256",
|
||||||
|
):
|
||||||
|
if name in value:
|
||||||
|
record[name] = value[name]
|
||||||
atomic_json(args.baseline_path, record)
|
atomic_json(args.baseline_path, record)
|
||||||
return baseline_probe(args, value)
|
return baseline_probe(args, value)
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ 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)
|
||||||
@@ -44,9 +43,6 @@ 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,
|
||||||
@@ -63,11 +59,7 @@ 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:
|
return self.orchestrator.execute(payload)
|
||||||
# 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, actor=actor, trace=trace)
|
|
||||||
|
|
||||||
|
|
||||||
class OpenClawSearchClient(SearchGatewayAgentAdapter):
|
class OpenClawSearchClient(SearchGatewayAgentAdapter):
|
||||||
|
|||||||
@@ -13,18 +13,10 @@ 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:
|
||||||
@@ -164,25 +156,13 @@ class SearchPolicyGuard:
|
|||||||
|
|
||||||
|
|
||||||
class SearchGateway:
|
class SearchGateway:
|
||||||
def __init__(self, providers: list[SearchProvider] | None = None, *, governance_store: GovernanceStore | None = None):
|
def __init__(self, providers: list[SearchProvider] | 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, *, actor: GovernanceActor | dict[str, Any] | None = None, trace: TraceContext | None = None) -> SearchResponse:
|
def search(self, request: SearchRequest) -> 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)
|
||||||
@@ -204,60 +184,14 @@ 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 GovernanceError:
|
except Exception as exc:
|
||||||
raise
|
attempts.append(SearchProviderAttempt(provider=getattr(provider, "name", "unknown"), status="failed", reason=str(exc)))
|
||||||
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,29 +7,27 @@ try:
|
|||||||
except Exception:
|
except Exception:
|
||||||
APIRouter = None
|
APIRouter = None
|
||||||
|
|
||||||
from .search_gateway import SearchRequest
|
from .search_gateway import SearchGateway, 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
|
||||||
|
|
||||||
|
|
||||||
orchestrator = SearchGatewayOrchestrator()
|
gateway = SearchGateway()
|
||||||
dashboard = SearchGatewayDashboard()
|
dashboard = SearchGatewayDashboard()
|
||||||
|
|
||||||
|
|
||||||
def search_gateway_request(payload: dict[str, Any], *, actor: GovernanceActor | dict[str, Any] | None = None, trace: TraceContext | None = None) -> dict[str, Any]:
|
def search_gateway_request(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
return orchestrator.execute(payload, actor=actor, trace=trace)
|
request = SearchRequest(**payload)
|
||||||
|
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(orchestrator.gateway.providers),
|
"providers_configured": len(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,49 +3,24 @@
|
|||||||
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, *, governance_store: GovernanceStore | None = None):
|
def __init__(self):
|
||||||
self.registry = SearchProviderRegistry()
|
self.registry = SearchProviderRegistry()
|
||||||
self.audit = SearchAuditStore()
|
self.audit = SearchAuditStore()
|
||||||
self.events = SearchEventBridge()
|
self.events = SearchEventBridge()
|
||||||
self.governance = governance_store or GovernanceStore(
|
self.gateway = SearchGateway(self.registry.ordered())
|
||||||
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], *, actor: GovernanceActor | dict[str, Any] | None = None, trace: TraceContext | None = None) -> dict[str, Any]:
|
def execute(self, payload: dict[str, Any]) -> 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, actor=actor, trace=trace)
|
response = self.gateway.search(request)
|
||||||
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)
|
||||||
@@ -60,7 +35,6 @@ class SearchGatewayOrchestrator:
|
|||||||
"registry": self.registry.status(),
|
"registry": self.registry.status(),
|
||||||
"audit": "enabled",
|
"audit": "enabled",
|
||||||
"events": "enabled",
|
"events": "enabled",
|
||||||
"governance": "required",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Mapping, Sequence
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
|
from aios_signer_broker import SignerBroker, SignerBrokerError, UnixSignerBroker
|
||||||
|
|
||||||
|
|
||||||
_SIGNER_FLAGS = (
|
_SIGNER_FLAGS = (
|
||||||
"--namespace",
|
"--namespace",
|
||||||
@@ -155,6 +157,36 @@ def _sign(
|
|||||||
raise SignerContractError("signer_output_empty")
|
raise SignerContractError("signer_output_empty")
|
||||||
|
|
||||||
|
|
||||||
|
def run_signer_self_test(broker: SignerBroker | None = None) -> dict[str, str]:
|
||||||
|
active = broker or UnixSignerBroker()
|
||||||
|
try:
|
||||||
|
active.self_test()
|
||||||
|
except SignerBrokerError as exc:
|
||||||
|
raise SignerContractError(exc.code) from exc
|
||||||
|
return {"status": "PASS", "contract": "aios.signer-broker.v1"}
|
||||||
|
|
||||||
|
|
||||||
|
def _sign_with_broker(
|
||||||
|
broker: SignerBroker,
|
||||||
|
payload_kind: str,
|
||||||
|
input_path: Path,
|
||||||
|
signature_path: Path,
|
||||||
|
key_id_path: Path,
|
||||||
|
) -> None:
|
||||||
|
if signature_path.exists() or key_id_path.exists():
|
||||||
|
raise SignerContractError("contract_output_exists")
|
||||||
|
try:
|
||||||
|
signature, key_id = broker.sign(payload_kind, input_path.read_bytes())
|
||||||
|
except SignerBrokerError as exc:
|
||||||
|
raise SignerContractError(exc.code) from exc
|
||||||
|
if not signature or not key_id.strip():
|
||||||
|
raise SignerContractError("signer_output_empty")
|
||||||
|
signature_path.write_bytes(signature)
|
||||||
|
key_id_path.write_text(key_id.strip() + "\n", encoding="utf-8")
|
||||||
|
os.chmod(signature_path, 0o600)
|
||||||
|
os.chmod(key_id_path, 0o600)
|
||||||
|
|
||||||
|
|
||||||
def _manifest(grant_dir: Path, names: Sequence[str]) -> None:
|
def _manifest(grant_dir: Path, names: Sequence[str]) -> None:
|
||||||
rows = []
|
rows = []
|
||||||
for name in names:
|
for name in names:
|
||||||
@@ -284,6 +316,8 @@ def run_compatible_signer(
|
|||||||
signer_command: Sequence[str],
|
signer_command: Sequence[str],
|
||||||
grant_dir: Path,
|
grant_dir: Path,
|
||||||
result_path: Path,
|
result_path: Path,
|
||||||
|
*,
|
||||||
|
broker: SignerBroker | None = None,
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
try:
|
try:
|
||||||
if not _supports_deployed_signer(signer_command):
|
if not _supports_deployed_signer(signer_command):
|
||||||
@@ -318,22 +352,21 @@ def run_compatible_signer(
|
|||||||
"signing_key_identity": signer,
|
"signing_key_identity": signer,
|
||||||
}
|
}
|
||||||
prepared = _prepare(grant_dir)
|
prepared = _prepare(grant_dir)
|
||||||
config_path = Path(
|
active_broker = broker or UnixSignerBroker()
|
||||||
os.environ.get("AIOS_GRANT_ROOT_CONFIG", "/etc/aios/grant-root/config.json")
|
try:
|
||||||
)
|
active_broker.self_test()
|
||||||
config = _read(config_path)
|
except SignerBrokerError as exc:
|
||||||
grant_namespace = str(config["grant_namespace"])
|
raise SignerContractError(exc.code) from exc
|
||||||
approval_namespace = str(config["approval_namespace"])
|
_sign_with_broker(
|
||||||
_sign(
|
active_broker,
|
||||||
signer_command,
|
"approval_plan",
|
||||||
approval_namespace,
|
|
||||||
grant_dir / "approval-plan.canonical.json",
|
grant_dir / "approval-plan.canonical.json",
|
||||||
grant_dir / "approval-plan.sig",
|
grant_dir / "approval-plan.sig",
|
||||||
grant_dir / "approval-plan.key-id",
|
grant_dir / "approval-plan.key-id",
|
||||||
)
|
)
|
||||||
_sign(
|
_sign_with_broker(
|
||||||
signer_command,
|
active_broker,
|
||||||
grant_namespace,
|
"grant_payload",
|
||||||
grant_dir / "grant-payload.canonical.json",
|
grant_dir / "grant-payload.canonical.json",
|
||||||
grant_dir / "signed-grant.sig",
|
grant_dir / "signed-grant.sig",
|
||||||
grant_dir / "signed-grant.key-id",
|
grant_dir / "signed-grant.key-id",
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -69,54 +68,28 @@ 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 _authorized_task(self, task_id: str, actor: str, action: str) -> TaskRecord:
|
def create_task(self, body: dict[str, Any], idempotency_key: str | None = None) -> dict[str, Any]:
|
||||||
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", {})
|
||||||
declared_actor = issuer.get("id", "anon") if isinstance(issuer, dict) else "anon"
|
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)
|
||||||
context = self.policy.actor_context(actor)
|
return [task.to_dict(include_task=False) for task in self.store.list_tasks()]
|
||||||
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)
|
self.policy.assert_allowed("read", actor, task=self.store.get_task(task_id))
|
||||||
context = self.policy.actor_context(actor)
|
return self.store.get_task(task_id).to_dict()
|
||||||
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:
|
||||||
@@ -190,8 +163,9 @@ class TaskPlaneService:
|
|||||||
if not worker_id:
|
if not worker_id:
|
||||||
raise ValidationError("worker_id required")
|
raise ValidationError("worker_id required")
|
||||||
|
|
||||||
task = self._authorized_task(task_id, actor, "claim")
|
task = self.store.get_task(task_id)
|
||||||
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
|
||||||
)
|
)
|
||||||
@@ -210,9 +184,10 @@ 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._authorized_task(task_id, actor, "heartbeat")
|
task = self.store.get_task(task_id)
|
||||||
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(
|
||||||
@@ -231,9 +206,10 @@ 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._authorized_task(task_id, actor, "progress")
|
task = self.store.get_task(task_id)
|
||||||
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(
|
||||||
@@ -258,7 +234,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._authorized_task(task_id, actor, "result")
|
task = self.store.get_task(task_id)
|
||||||
|
|
||||||
status = body.get("status", "COMPLETED")
|
status = body.get("status", "COMPLETED")
|
||||||
outputs = body.get("outputs", [])
|
outputs = body.get("outputs", [])
|
||||||
@@ -271,6 +247,7 @@ 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":
|
||||||
@@ -349,7 +326,8 @@ 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._authorized_task(task_id, actor, "fail")
|
task = self.store.get_task(task_id)
|
||||||
|
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")
|
||||||
@@ -361,7 +339,8 @@ 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._authorized_task(task_id, actor, "cancel")
|
task = self.store.get_task(task_id)
|
||||||
|
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
|
||||||
)
|
)
|
||||||
@@ -370,7 +349,8 @@ 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._authorized_task(task_id, actor, "delegate")
|
parent = self.store.get_task(task_id)
|
||||||
|
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"])
|
||||||
@@ -384,31 +364,25 @@ 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._authorized_task(task_id, actor, "audit")
|
self.policy.assert_allowed("audit", actor, task=self.store.get_task(task_id))
|
||||||
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._authorized_task(task_id, actor, "audit")
|
self.policy.assert_allowed("audit", actor, task=self.store.get_task(task_id))
|
||||||
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._authorized_task(task_id, actor, "read")
|
self.policy.assert_allowed("read", actor, task=self.store.get_task(task_id))
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
@@ -532,11 +506,7 @@ 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(
|
return 201, self.service.create_task(body or {}, headers.get("idempotency-key"))
|
||||||
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,8 +106,3 @@ 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)
|
|
||||||
|
|||||||
@@ -1,911 +0,0 @@
|
|||||||
"""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,8 +13,6 @@ 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",
|
||||||
@@ -68,12 +66,6 @@ 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",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -245,12 +237,6 @@ 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)
|
||||||
@@ -325,17 +311,10 @@ 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", [])),
|
||||||
@@ -369,12 +348,6 @@ 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,11 +3,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Callable, Mapping
|
from typing import Callable
|
||||||
|
|
||||||
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}
|
||||||
@@ -94,10 +93,6 @@ 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,
|
||||||
@@ -114,26 +109,12 @@ 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,203 +74,3 @@ 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
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
"""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,7 +32,6 @@ 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]
|
||||||
@@ -135,12 +134,6 @@ 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,
|
||||||
@@ -279,21 +272,6 @@ 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",
|
||||||
{
|
{
|
||||||
@@ -321,14 +299,6 @@ 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(
|
||||||
@@ -377,42 +347,6 @@ 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(
|
||||||
@@ -455,7 +389,6 @@ 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:
|
||||||
@@ -464,11 +397,7 @@ class TaskPlaneStore:
|
|||||||
(idempotency_key,),
|
(idempotency_key,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if existing:
|
if existing:
|
||||||
existing_plan = TaskPlan.from_dict(
|
if _canonical(json.loads(existing["task_json"])) != _canonical(task_json):
|
||||||
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
|
||||||
@@ -483,18 +412,14 @@ class TaskPlaneStore:
|
|||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO tasks
|
INSERT INTO tasks
|
||||||
(task_id, revision, status, task_json, task_type,
|
(task_id, revision, status, task_json, task_type, root_task_id, parent_task_id, correlation_id, idempotency_key, created_at, updated_at, failure_reason)
|
||||||
scope_type, tenant_id, user_id, project_id, conversation_id, run_id,
|
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
||||||
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,
|
||||||
@@ -526,30 +451,21 @@ class TaskPlaneStore:
|
|||||||
)
|
)
|
||||||
return self.get_task(task.task_id)
|
return self.get_task(task.task_id)
|
||||||
|
|
||||||
def get_task(
|
def get_task(self, task_id: str) -> TaskRecord:
|
||||||
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(sql, (task_id, *scope_params)).fetchone()
|
row = self.conn.execute(
|
||||||
|
"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(
|
def list_tasks(self) -> list[TaskRecord]:
|
||||||
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(sql, scope_params).fetchall()
|
rows = self.conn.execute(
|
||||||
|
"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:
|
||||||
|
|||||||
@@ -1,759 +0,0 @@
|
|||||||
"""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",
|
|
||||||
]
|
|
||||||
@@ -7,6 +7,7 @@ previous V2R1C7 release available for an explicit, owner-approved rollback.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
@@ -29,6 +30,7 @@ ROLLBACK_ROOT = Path("/var/lib/aios/v2r2c1/rollback")
|
|||||||
|
|
||||||
REQUIRED_MEMBERS = {
|
REQUIRED_MEMBERS = {
|
||||||
"payload/VERSION",
|
"payload/VERSION",
|
||||||
|
"payload/versions/V2R2C1.json",
|
||||||
"payload/active-runtime/app/deployment_state.py",
|
"payload/active-runtime/app/deployment_state.py",
|
||||||
"payload/active-runtime/app/v2r1c6_privacy_tools.py",
|
"payload/active-runtime/app/v2r1c6_privacy_tools.py",
|
||||||
"payload/active-runtime/app/v2r2c1_health_service.py",
|
"payload/active-runtime/app/v2r2c1_health_service.py",
|
||||||
@@ -87,12 +89,65 @@ def execute_request(value: Mapping[str, Any]) -> dict[str, Any]:
|
|||||||
_backup(Path("/var/lib/aios/v2r1c7/deployment-state.json"), "v2r1c7-deployment-state.previous")
|
_backup(Path("/var/lib/aios/v2r1c7/deployment-state.json"), "v2r1c7-deployment-state.previous")
|
||||||
return base.install_release(_args(), value)
|
return base.install_release(_args(), value)
|
||||||
if value.get("operation") == "probe":
|
if value.get("operation") == "probe":
|
||||||
return base.probe(_args(), value)
|
result = base.probe(_args(), value)
|
||||||
|
if result.get("status") == "ACTIVE_VERIFIED":
|
||||||
|
result.update(_runtime_registry_gate(value))
|
||||||
|
return result
|
||||||
if value.get("operation") == "rollback":
|
if value.get("operation") == "rollback":
|
||||||
return rollback(value)
|
return rollback(value)
|
||||||
raise ValueError("unsupported_operation")
|
raise ValueError("unsupported_operation")
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_registry_gate(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Prove the deployed immutable runtime contains the exact M:path blob."""
|
||||||
|
source_digest = value.get("source_registry_sha256")
|
||||||
|
source_main = value.get("source_registry_main_oid")
|
||||||
|
if not source_digest or not source_main:
|
||||||
|
return {}
|
||||||
|
source_path = str(value.get("source_registry_path") or f"versions/{VERSION}.json")
|
||||||
|
member = CURRENT_LINK / source_path
|
||||||
|
if member.is_symlink() or not member.is_file():
|
||||||
|
raise RuntimeError("RUNTIME_SOURCE_REGISTRY_MEMBER_MISSING")
|
||||||
|
raw = member.read_bytes()
|
||||||
|
digest = hashlib.sha256(raw).hexdigest()
|
||||||
|
if digest != source_digest:
|
||||||
|
raise RuntimeError("RUNTIME_SOURCE_REGISTRY_DIGEST_MISMATCH")
|
||||||
|
try:
|
||||||
|
source = json.loads(raw.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise RuntimeError("RUNTIME_SOURCE_REGISTRY_INVALID") from exc
|
||||||
|
expected = {
|
||||||
|
"version": VERSION,
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"tag_oid": value.get("tag_oid"),
|
||||||
|
"target_oid": value.get("target_oid"),
|
||||||
|
"peeled_target_oid": value.get("target_oid"),
|
||||||
|
"evidence_closure_oid": value.get("evidence_closure_oid"),
|
||||||
|
"approval_plan_id": value.get("approval_plan_id"),
|
||||||
|
}
|
||||||
|
if not isinstance(source, dict) or any(source.get(key) != expected_value for key, expected_value in expected.items()):
|
||||||
|
raise RuntimeError("RUNTIME_SOURCE_REGISTRY_IDENTITY_MISMATCH")
|
||||||
|
if source.get("tag_object") != expected["tag_oid"]:
|
||||||
|
raise RuntimeError("RUNTIME_SOURCE_REGISTRY_TAG_OBJECT_MISMATCH")
|
||||||
|
version_name = f"{VERSION}.json"
|
||||||
|
return {
|
||||||
|
"runtime_registry_versions": [version_name],
|
||||||
|
"attested_projection_versions": [version_name],
|
||||||
|
"effective_registry_versions": [version_name],
|
||||||
|
# The adapter can only inspect the deployed immutable runtime. It
|
||||||
|
# must not claim convergence with mutable authoritative Git; the
|
||||||
|
# executor performs that independent M/C read after deployment.
|
||||||
|
"source_registry_converged": "not_checked",
|
||||||
|
"source_registry_convergence_error": "executor_must_recheck_authoritative_git",
|
||||||
|
"runtime_registry_converged": True,
|
||||||
|
"runtime_bundle_contains_current_version": True,
|
||||||
|
"runtime_bundle_contains_version": True,
|
||||||
|
"registry_consistency_status": "RUNTIME_ONLY_VERIFIED",
|
||||||
|
"runtime_source_registry_main_oid": source_main,
|
||||||
|
"runtime_source_registry_sha256": digest,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def rollback(value: Mapping[str, Any]) -> dict[str, Any]:
|
def rollback(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
"""Explicit rollback only; this adapter never invokes it automatically."""
|
"""Explicit rollback only; this adapter never invokes it automatically."""
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|||||||
@@ -0,0 +1,982 @@
|
|||||||
|
"""Release registry visibility and source-convergence gates.
|
||||||
|
|
||||||
|
Two invariants are enforced:
|
||||||
|
|
||||||
|
1. listVersions may overlay exactly the current Production Baseline-attested
|
||||||
|
projection onto the immutable source bundle. Arbitrary projection files
|
||||||
|
are not surfaced.
|
||||||
|
|
||||||
|
2. release_metadata_adapter.baseline_apply may not finalize a production
|
||||||
|
baseline unless the governed repository/main already contains a source
|
||||||
|
versions/<version>.json entry matching the release identity.
|
||||||
|
|
||||||
|
The source convergence gate intentionally checks release identity, not
|
||||||
|
post-deployment runtime fields. This allows the source entry to be committed
|
||||||
|
before deployment while still preventing PRODUCTION_DEPLOYED from being
|
||||||
|
written for an uncommitted release identity.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_VERSION_RE = re.compile(r"^V[0-9]+R[0-9]+C[0-9]+(?:-[A-Za-z0-9_.-]+)?$")
|
||||||
|
_OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
|
_PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||||
|
VERSION_RE = _VERSION_RE
|
||||||
|
OID_RE = _OID_RE
|
||||||
|
SOURCE_REGISTRY_SCHEMAS = frozenset(
|
||||||
|
{"aios.source-registry.v1", "aios.production-registry.v2"}
|
||||||
|
)
|
||||||
|
SOURCE_REGISTRY_FIELDS = (
|
||||||
|
"version",
|
||||||
|
"tag_oid",
|
||||||
|
"target_oid",
|
||||||
|
"peeled_target_oid",
|
||||||
|
"evidence_closure_oid",
|
||||||
|
"approval_plan_id",
|
||||||
|
)
|
||||||
|
REPOSITORY_IDENTITY = "aios/aios"
|
||||||
|
|
||||||
|
DEFAULT_PROJECTION_ROOT = Path(
|
||||||
|
os.getenv(
|
||||||
|
"AIOS_GITOPS_REGISTRY_PROJECTION_ROOT",
|
||||||
|
"/var/lib/aios-gitops/runtime/registry-projection",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
DEFAULT_BASELINE_FILE = Path(
|
||||||
|
os.getenv(
|
||||||
|
"AIOS_GITOPS_BASELINE_FILE",
|
||||||
|
"/var/lib/aios-gitops-runtime-anchor/baseline/current.json",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
DEFAULT_MAIN_REF = os.getenv("AIOS_GITOPS_MAIN_REF", "refs/heads/main")
|
||||||
|
DEFAULT_AUTHORITATIVE_GIT_DIR = Path("/data/aios-gitops/repo.git")
|
||||||
|
DEFAULT_APPROVAL_AUTHORITY_ROOT = Path("/var/lib/aios-gitops/approval-authority")
|
||||||
|
TEST_MODE_ENV = "AIOS_TEST_MODE"
|
||||||
|
AUTHORITATIVE_GIT_ENV = "AIOS_GITOPS_AUTHORITATIVE_GIT_DIR"
|
||||||
|
APPROVAL_AUTHORITY_ROOT_ENV = "AIOS_GITOPS_APPROVAL_AUTHORITY_ROOT"
|
||||||
|
REPOSITORY_IDENTITY_ENV = "AIOS_GITOPS_REPOSITORY_IDENTITY"
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryConvergenceError(RuntimeError):
|
||||||
|
"""Release registry/source convergence is not proven."""
|
||||||
|
|
||||||
|
|
||||||
|
def _load_object(path: Path) -> dict[str, Any]:
|
||||||
|
obj = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
raise RegistryConvergenceError(f"json_not_object:{path}")
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _git_text(repo_git_dir: Path | str, *args: str) -> str:
|
||||||
|
completed = subprocess.run(
|
||||||
|
["git", f"--git-dir={Path(repo_git_dir)}", *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
timeout=45,
|
||||||
|
env={"PATH": os.environ.get("PATH", "")},
|
||||||
|
)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise RegistryConvergenceError(
|
||||||
|
completed.stderr.strip() or "authoritative_git_query_failed"
|
||||||
|
)
|
||||||
|
return completed.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _git_bytes(repo_git_dir: Path | str, *args: str) -> bytes:
|
||||||
|
completed = subprocess.run(
|
||||||
|
["git", f"--git-dir={Path(repo_git_dir)}", *args],
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
timeout=45,
|
||||||
|
env={"PATH": os.environ.get("PATH", "")},
|
||||||
|
)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise RegistryConvergenceError(
|
||||||
|
completed.stderr.decode("utf-8", "replace").strip()
|
||||||
|
or "authoritative_git_query_failed"
|
||||||
|
)
|
||||||
|
return completed.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_mode_enabled() -> bool:
|
||||||
|
"""Return whether explicit dependency injection is allowed for tests."""
|
||||||
|
return os.environ.get(TEST_MODE_ENV) == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_override(env_name: str, default: str, label: str) -> Path:
|
||||||
|
if env_name in os.environ:
|
||||||
|
if not test_mode_enabled():
|
||||||
|
raise RegistryConvergenceError(f"{label}_env_override_forbidden")
|
||||||
|
value = os.environ.get(env_name, "")
|
||||||
|
if not value:
|
||||||
|
raise RegistryConvergenceError(f"{label}_env_override_invalid")
|
||||||
|
return Path(value)
|
||||||
|
return Path(default)
|
||||||
|
|
||||||
|
|
||||||
|
def configured_authoritative_git_dir() -> Path:
|
||||||
|
return _configured_override(
|
||||||
|
AUTHORITATIVE_GIT_ENV,
|
||||||
|
str(DEFAULT_AUTHORITATIVE_GIT_DIR),
|
||||||
|
"authoritative_repository",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def configured_repository_identity() -> str:
|
||||||
|
if REPOSITORY_IDENTITY_ENV in os.environ:
|
||||||
|
if not test_mode_enabled():
|
||||||
|
raise RegistryConvergenceError("repository_identity_env_override_forbidden")
|
||||||
|
value = os.environ.get(REPOSITORY_IDENTITY_ENV, "")
|
||||||
|
if not value:
|
||||||
|
raise RegistryConvergenceError("repository_identity_env_override_invalid")
|
||||||
|
return value
|
||||||
|
return REPOSITORY_IDENTITY
|
||||||
|
|
||||||
|
|
||||||
|
def verify_authoritative_repository(
|
||||||
|
repo_git_dir: Path | str,
|
||||||
|
*,
|
||||||
|
repository_identity: str = REPOSITORY_IDENTITY,
|
||||||
|
expected_path: Path | str | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Prove that a Git directory is the configured AIOS authority.
|
||||||
|
|
||||||
|
A repository that merely contains internally consistent objects is not
|
||||||
|
sufficient. The resolved path must be the configured authority and the
|
||||||
|
bare repository must advertise the expected Gitea repository identity.
|
||||||
|
"""
|
||||||
|
if repository_identity != REPOSITORY_IDENTITY:
|
||||||
|
raise RegistryConvergenceError("authoritative_repository_identity_invalid")
|
||||||
|
candidate = Path(repo_git_dir)
|
||||||
|
configured = Path(expected_path) if expected_path is not None else configured_authoritative_git_dir()
|
||||||
|
if test_mode_enabled() and expected_path is None and AUTHORITATIVE_GIT_ENV not in os.environ:
|
||||||
|
# Explicit test mode may inject a temporary bare repository directly.
|
||||||
|
# Production mode always compares against the fixed authority path.
|
||||||
|
configured = candidate
|
||||||
|
try:
|
||||||
|
candidate_real = candidate.resolve(strict=True)
|
||||||
|
configured_real = configured.resolve(strict=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise RegistryConvergenceError("authoritative_repository_path_unavailable") from exc
|
||||||
|
if candidate_real != configured_real:
|
||||||
|
raise RegistryConvergenceError("authoritative_repository_path_mismatch")
|
||||||
|
if not candidate_real.is_dir():
|
||||||
|
raise RegistryConvergenceError("authoritative_repository_not_directory")
|
||||||
|
if _git_text(candidate_real, "rev-parse", "--is-bare-repository") != "true":
|
||||||
|
raise RegistryConvergenceError("authoritative_repository_not_bare")
|
||||||
|
remotes = subprocess.run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
f"--git-dir={candidate_real}",
|
||||||
|
"config",
|
||||||
|
"--get-regexp",
|
||||||
|
r"^remote\..*\.url$",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
timeout=45,
|
||||||
|
env={"PATH": os.environ.get("PATH", "")},
|
||||||
|
)
|
||||||
|
urls = [line.split(None, 1)[1] for line in remotes.stdout.splitlines() if " " in line]
|
||||||
|
identity_suffix = repository_identity.removesuffix(".git")
|
||||||
|
identity_verified = any(
|
||||||
|
url.rstrip("/").removesuffix(".git").replace("\\", "/").split(":")[-1]
|
||||||
|
in {identity_suffix, "/" + identity_suffix}
|
||||||
|
or url.rstrip("/").removesuffix(".git").replace("\\", "/").split(":")[-1].endswith(
|
||||||
|
"/" + identity_suffix
|
||||||
|
)
|
||||||
|
for url in urls
|
||||||
|
)
|
||||||
|
if not test_mode_enabled() and not identity_verified:
|
||||||
|
raise RegistryConvergenceError("authoritative_repository_identity_unverified")
|
||||||
|
return {
|
||||||
|
"repository_identity": repository_identity,
|
||||||
|
"configured_path": str(configured_real),
|
||||||
|
"resolved_path": str(candidate_real),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_source_registry_main_oid(
|
||||||
|
repo_git_dir: Path | str,
|
||||||
|
*,
|
||||||
|
main_ref: str = DEFAULT_MAIN_REF,
|
||||||
|
) -> str:
|
||||||
|
"""Resolve the authoritative main ref to one immutable commit OID."""
|
||||||
|
oid = _git_text(repo_git_dir, "rev-parse", "--verify", f"{main_ref}^{{commit}}")
|
||||||
|
if not _OID_RE.fullmatch(oid):
|
||||||
|
raise RegistryConvergenceError("source_registry_main_oid_invalid")
|
||||||
|
if _git_text(repo_git_dir, "cat-file", "-t", oid) != "commit":
|
||||||
|
raise RegistryConvergenceError("source_registry_main_oid_not_commit")
|
||||||
|
return oid
|
||||||
|
|
||||||
|
|
||||||
|
def source_registry_snapshot(
|
||||||
|
repo_git_dir: Path | str,
|
||||||
|
version: str,
|
||||||
|
*,
|
||||||
|
main_ref: str = DEFAULT_MAIN_REF,
|
||||||
|
source_registry_main_oid: str | None = None,
|
||||||
|
expected_tag_oid: str | None = None,
|
||||||
|
expected_target_oid: str | None = None,
|
||||||
|
expected_evidence_closure_oid: str | None = None,
|
||||||
|
expected_approval_plan_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Read one source entry and its exact bytes from one immutable commit.
|
||||||
|
|
||||||
|
Callers building an artifact should resolve ``source_registry_main_oid``
|
||||||
|
once and pass it back here. The function then never consults the mutable
|
||||||
|
ref again, so validation and packaging use the same Git blob.
|
||||||
|
"""
|
||||||
|
if not isinstance(version, str) or not _VERSION_RE.fullmatch(version):
|
||||||
|
raise RegistryConvergenceError("source_registry_version_invalid")
|
||||||
|
main_oid = source_registry_main_oid or resolve_source_registry_main_oid(
|
||||||
|
repo_git_dir,
|
||||||
|
main_ref=main_ref,
|
||||||
|
)
|
||||||
|
if not _OID_RE.fullmatch(main_oid):
|
||||||
|
raise RegistryConvergenceError("source_registry_main_oid_invalid")
|
||||||
|
if _git_text(repo_git_dir, "cat-file", "-t", main_oid) != "commit":
|
||||||
|
raise RegistryConvergenceError("source_registry_main_oid_not_commit")
|
||||||
|
path = f"versions/{version}.json"
|
||||||
|
try:
|
||||||
|
raw = _git_bytes(repo_git_dir, "show", f"{main_oid}:{path}")
|
||||||
|
except RegistryConvergenceError as exc:
|
||||||
|
if "path" in str(exc).lower() or "does not exist" in str(exc).lower():
|
||||||
|
raise RegistryConvergenceError(
|
||||||
|
f"source_registry_entry_missing:{main_oid}:{path}"
|
||||||
|
) from exc
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
source = json.loads(raw.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise RegistryConvergenceError("source_registry_json_invalid") from exc
|
||||||
|
record = validate_source_registry_record(
|
||||||
|
source,
|
||||||
|
expected_version=version,
|
||||||
|
expected_tag_oid=expected_tag_oid,
|
||||||
|
expected_target_oid=expected_target_oid,
|
||||||
|
expected_evidence_closure_oid=expected_evidence_closure_oid,
|
||||||
|
expected_approval_plan_id=expected_approval_plan_id,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"record": record,
|
||||||
|
"bytes": raw,
|
||||||
|
"source_registry_main_oid": main_oid,
|
||||||
|
"source_registry_path": path,
|
||||||
|
"source_registry_sha256": hashlib.sha256(raw).hexdigest(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_source_registry_record(
|
||||||
|
value: Any,
|
||||||
|
*,
|
||||||
|
expected_version: str | None = None,
|
||||||
|
expected_tag_oid: str | None = None,
|
||||||
|
expected_target_oid: str | None = None,
|
||||||
|
expected_evidence_closure_oid: str | None = None,
|
||||||
|
expected_approval_plan_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Validate the immutable identity carried by versions/<version>.json."""
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise RegistryConvergenceError("source_registry_not_object")
|
||||||
|
if value.get("schema") not in SOURCE_REGISTRY_SCHEMAS:
|
||||||
|
raise RegistryConvergenceError("source_registry_schema_invalid")
|
||||||
|
version = value.get("version")
|
||||||
|
if not isinstance(version, str) or not _VERSION_RE.fullmatch(version):
|
||||||
|
raise RegistryConvergenceError("source_registry_version_invalid")
|
||||||
|
if expected_version is not None and version != expected_version:
|
||||||
|
raise RegistryConvergenceError("source_registry_mismatch:version")
|
||||||
|
if value.get("repository") != REPOSITORY_IDENTITY:
|
||||||
|
raise RegistryConvergenceError("source_registry_repository_invalid")
|
||||||
|
for field in ("tag_oid", "target_oid", "peeled_target_oid", "evidence_closure_oid"):
|
||||||
|
item = value.get(field)
|
||||||
|
if not isinstance(item, str) or not _OID_RE.fullmatch(item):
|
||||||
|
raise RegistryConvergenceError(f"source_registry_{field}_invalid")
|
||||||
|
if value["peeled_target_oid"] != value["target_oid"]:
|
||||||
|
raise RegistryConvergenceError("source_registry_peeled_target_mismatch")
|
||||||
|
approval = value.get("approval_plan_id")
|
||||||
|
if not isinstance(approval, str) or not _PLAN_RE.fullmatch(approval):
|
||||||
|
raise RegistryConvergenceError("source_registry_approval_plan_invalid")
|
||||||
|
if value.get("tag_object", value["tag_oid"]) != value["tag_oid"]:
|
||||||
|
raise RegistryConvergenceError("source_registry_tag_object_mismatch")
|
||||||
|
expected = {
|
||||||
|
"tag_oid": expected_tag_oid,
|
||||||
|
"target_oid": expected_target_oid,
|
||||||
|
"evidence_closure_oid": expected_evidence_closure_oid,
|
||||||
|
"approval_plan_id": expected_approval_plan_id,
|
||||||
|
}
|
||||||
|
for field, wanted in expected.items():
|
||||||
|
if wanted is not None and value.get(field) != wanted:
|
||||||
|
raise RegistryConvergenceError(f"source_registry_mismatch:{field}")
|
||||||
|
return dict(value)
|
||||||
|
|
||||||
|
|
||||||
|
def build_source_registry_entry(
|
||||||
|
*,
|
||||||
|
version: str,
|
||||||
|
tag_oid: str,
|
||||||
|
target_oid: str,
|
||||||
|
peeled_target_oid: str,
|
||||||
|
evidence_closure_oid: str,
|
||||||
|
approval_plan_id: str,
|
||||||
|
repository: str = REPOSITORY_IDENTITY,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build the pre-promotion source entry; it performs no Git or runtime write."""
|
||||||
|
value = {
|
||||||
|
"schema": "aios.source-registry.v1",
|
||||||
|
"version": version,
|
||||||
|
"repository": repository,
|
||||||
|
"tag": version,
|
||||||
|
"tag_oid": tag_oid,
|
||||||
|
"tag_object": tag_oid,
|
||||||
|
"target_oid": target_oid,
|
||||||
|
"peeled_target_oid": peeled_target_oid,
|
||||||
|
"evidence_closure_oid": evidence_closure_oid,
|
||||||
|
"approval_plan_id": approval_plan_id,
|
||||||
|
}
|
||||||
|
return validate_source_registry_record(
|
||||||
|
value,
|
||||||
|
expected_version=version,
|
||||||
|
expected_tag_oid=tag_oid,
|
||||||
|
expected_target_oid=target_oid,
|
||||||
|
expected_evidence_closure_oid=evidence_closure_oid,
|
||||||
|
expected_approval_plan_id=approval_plan_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_source_registry_git_identity(
|
||||||
|
repo_git_dir: Path | str,
|
||||||
|
record: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Prove that every Git identity in a source entry exists and is related."""
|
||||||
|
record = validate_source_registry_record(record)
|
||||||
|
version = record["version"]
|
||||||
|
tag_oid = record["tag_oid"]
|
||||||
|
target_oid = record["target_oid"]
|
||||||
|
evidence_oid = record["evidence_closure_oid"]
|
||||||
|
if _git_text(repo_git_dir, "cat-file", "-t", tag_oid) != "tag":
|
||||||
|
raise RegistryConvergenceError("source_registry_tag_not_annotated")
|
||||||
|
named_tag = _git_text(
|
||||||
|
repo_git_dir,
|
||||||
|
"rev-parse",
|
||||||
|
"--verify",
|
||||||
|
f"refs/tags/{version}",
|
||||||
|
)
|
||||||
|
if named_tag != tag_oid:
|
||||||
|
raise RegistryConvergenceError("source_registry_named_tag_mismatch")
|
||||||
|
if _git_text(repo_git_dir, "cat-file", "-t", target_oid) != "commit":
|
||||||
|
raise RegistryConvergenceError("source_registry_target_not_commit")
|
||||||
|
peeled = _git_text(
|
||||||
|
repo_git_dir,
|
||||||
|
"rev-parse",
|
||||||
|
"--verify",
|
||||||
|
f"{tag_oid}^{{commit}}",
|
||||||
|
)
|
||||||
|
if peeled != target_oid or record["peeled_target_oid"] != target_oid:
|
||||||
|
raise RegistryConvergenceError("source_registry_tag_target_mismatch")
|
||||||
|
if _git_text(repo_git_dir, "cat-file", "-t", evidence_oid) != "commit":
|
||||||
|
raise RegistryConvergenceError("source_registry_evidence_not_commit")
|
||||||
|
ancestor = subprocess.run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
f"--git-dir={Path(repo_git_dir)}",
|
||||||
|
"merge-base",
|
||||||
|
"--is-ancestor",
|
||||||
|
target_oid,
|
||||||
|
evidence_oid,
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=45,
|
||||||
|
env={"PATH": os.environ.get("PATH", "")},
|
||||||
|
)
|
||||||
|
if ancestor.returncode != 0:
|
||||||
|
raise RegistryConvergenceError("source_registry_evidence_lineage_invalid")
|
||||||
|
return {
|
||||||
|
"repository": REPOSITORY_IDENTITY,
|
||||||
|
"version": version,
|
||||||
|
"tag_oid": tag_oid,
|
||||||
|
"tag_object_type": "tag",
|
||||||
|
"target_oid": target_oid,
|
||||||
|
"peeled_target_oid": peeled,
|
||||||
|
"evidence_closure_oid": evidence_oid,
|
||||||
|
"target_is_ancestor_of_evidence": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def verify_source_registry_main_convergence(
|
||||||
|
repo_git_dir: Path | str,
|
||||||
|
version: str,
|
||||||
|
*,
|
||||||
|
main_ref: str = DEFAULT_MAIN_REF,
|
||||||
|
source_registry_main_oid: str | None = None,
|
||||||
|
expected_tag_oid: str | None = None,
|
||||||
|
expected_target_oid: str | None = None,
|
||||||
|
expected_evidence_closure_oid: str | None = None,
|
||||||
|
expected_approval_plan_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Prove that pinned M is still converged with current authoritative main C."""
|
||||||
|
current_main_oid = resolve_source_registry_main_oid(repo_git_dir, main_ref=main_ref)
|
||||||
|
source_main_oid = source_registry_main_oid or current_main_oid
|
||||||
|
if not _OID_RE.fullmatch(source_main_oid):
|
||||||
|
raise RegistryConvergenceError("source_registry_main_oid_invalid")
|
||||||
|
ancestor = subprocess.run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
f"--git-dir={Path(repo_git_dir)}",
|
||||||
|
"merge-base",
|
||||||
|
"--is-ancestor",
|
||||||
|
source_main_oid,
|
||||||
|
current_main_oid,
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=45,
|
||||||
|
env={"PATH": os.environ.get("PATH", "")},
|
||||||
|
)
|
||||||
|
if ancestor.returncode != 0:
|
||||||
|
raise RegistryConvergenceError("source_registry_main_not_ancestor_of_current_main")
|
||||||
|
pinned = source_registry_snapshot(
|
||||||
|
repo_git_dir,
|
||||||
|
version,
|
||||||
|
source_registry_main_oid=source_main_oid,
|
||||||
|
expected_tag_oid=expected_tag_oid,
|
||||||
|
expected_target_oid=expected_target_oid,
|
||||||
|
expected_evidence_closure_oid=expected_evidence_closure_oid,
|
||||||
|
expected_approval_plan_id=expected_approval_plan_id,
|
||||||
|
)
|
||||||
|
current = source_registry_snapshot(
|
||||||
|
repo_git_dir,
|
||||||
|
version,
|
||||||
|
source_registry_main_oid=current_main_oid,
|
||||||
|
expected_tag_oid=expected_tag_oid,
|
||||||
|
expected_target_oid=expected_target_oid,
|
||||||
|
expected_evidence_closure_oid=expected_evidence_closure_oid,
|
||||||
|
expected_approval_plan_id=expected_approval_plan_id,
|
||||||
|
)
|
||||||
|
if pinned["bytes"] != current["bytes"]:
|
||||||
|
raise RegistryConvergenceError("source_registry_main_entry_changed_after_pin")
|
||||||
|
return {
|
||||||
|
"source_registry_main_oid": source_main_oid,
|
||||||
|
"current_main_oid": current_main_oid,
|
||||||
|
"source_registry_path": pinned["source_registry_path"],
|
||||||
|
"source_registry_sha256": pinned["source_registry_sha256"],
|
||||||
|
"current_main_source_registry_sha256": current["source_registry_sha256"],
|
||||||
|
"source_registry_main_is_ancestor_of_current_main": True,
|
||||||
|
"source_registry_entry_matches_current_main": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_APPROVAL_AUTHORITY_STATES = frozenset(
|
||||||
|
{
|
||||||
|
"APPROVED",
|
||||||
|
"GITEA_API_CONFIRMED",
|
||||||
|
"REMOTE_VERIFIED",
|
||||||
|
"PRODUCTION_DEPLOYED",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_APPROVAL_AUTHORITY_SCHEMAS = frozenset(
|
||||||
|
{
|
||||||
|
"aios.v2r2c1.tag-approval-request.v1",
|
||||||
|
"aios.owner-release-approval.v1",
|
||||||
|
"aios.release-binding-approval.v2",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def configured_approval_authority_root() -> Path:
|
||||||
|
return _configured_override(
|
||||||
|
APPROVAL_AUTHORITY_ROOT_ENV,
|
||||||
|
str(DEFAULT_APPROVAL_AUTHORITY_ROOT),
|
||||||
|
"approval_authority_root",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _secure_approval_authority_path(
|
||||||
|
authority_path: Path | str,
|
||||||
|
version: str,
|
||||||
|
) -> tuple[Path, Path]:
|
||||||
|
root = configured_approval_authority_root()
|
||||||
|
path = Path(authority_path)
|
||||||
|
if path.is_dir():
|
||||||
|
path = path / f"{version}.json"
|
||||||
|
try:
|
||||||
|
root_real = root.resolve(strict=True)
|
||||||
|
path_real = path.resolve(strict=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise RegistryConvergenceError("approval_authority_trusted_root_missing") from exc
|
||||||
|
try:
|
||||||
|
path_real.relative_to(root_real)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RegistryConvergenceError("approval_authority_untrusted_path") from exc
|
||||||
|
metadata = path.lstat()
|
||||||
|
if not stat.S_ISREG(metadata.st_mode) or path.is_symlink():
|
||||||
|
raise RegistryConvergenceError("approval_authority_not_regular")
|
||||||
|
if metadata.st_mode & 0o022:
|
||||||
|
raise RegistryConvergenceError("approval_authority_permissions_invalid")
|
||||||
|
if APPROVAL_AUTHORITY_ROOT_ENV not in os.environ and metadata.st_uid != 0:
|
||||||
|
raise RegistryConvergenceError("approval_authority_owner_invalid")
|
||||||
|
return path, root_real
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(value: object) -> bytes:
|
||||||
|
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _approval_is_revoked(root: Path, approval_plan_id: str, authority: dict[str, Any]) -> bool:
|
||||||
|
if any(
|
||||||
|
authority.get(name) is True
|
||||||
|
for name in ("revoked", "invalidated", "revocation")
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
state = authority.get("state") or authority.get("status")
|
||||||
|
if state in {"REVOKED", "INVALIDATED", "EXPIRED", "CANCELLED"}:
|
||||||
|
return True
|
||||||
|
for marker in (
|
||||||
|
root / "revocations" / approval_plan_id,
|
||||||
|
root / "revocations" / approval_plan_id.replace(":", "_"),
|
||||||
|
):
|
||||||
|
if marker.exists():
|
||||||
|
return True
|
||||||
|
expires_at = authority.get("expires_at")
|
||||||
|
if isinstance(expires_at, str) and expires_at:
|
||||||
|
try:
|
||||||
|
instant = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RegistryConvergenceError("approval_expiry_invalid") from exc
|
||||||
|
if instant <= datetime.now(timezone.utc):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def verify_approval_authority(
|
||||||
|
authority_path: Path | str,
|
||||||
|
record: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Verify the existing release approval/publication receipt association.
|
||||||
|
|
||||||
|
The authority document is intentionally supplied by the governed caller;
|
||||||
|
this helper never creates or rewrites an approval. The path must be under
|
||||||
|
the configured canonical Owner-authority root and the document must use a
|
||||||
|
known release-approval schema. A caller-supplied JSON file elsewhere is
|
||||||
|
never an authority, even when its fields are internally consistent.
|
||||||
|
"""
|
||||||
|
path, trusted_root = _secure_approval_authority_path(authority_path, record["version"])
|
||||||
|
authority = _load_object(path)
|
||||||
|
schema = authority.get("schema")
|
||||||
|
if schema not in _APPROVAL_AUTHORITY_SCHEMAS:
|
||||||
|
raise RegistryConvergenceError("approval_authority_schema_invalid")
|
||||||
|
if _approval_is_revoked(trusted_root, record["approval_plan_id"], authority):
|
||||||
|
raise RegistryConvergenceError("approval_plan_revoked_or_invalidated")
|
||||||
|
nested = authority.get("release_binding") or authority.get("release_request")
|
||||||
|
if not isinstance(nested, dict):
|
||||||
|
nested = authority.get("binding")
|
||||||
|
if not isinstance(nested, dict):
|
||||||
|
raise RegistryConvergenceError("approval_authority_binding_missing")
|
||||||
|
expected = {
|
||||||
|
"version": record["version"],
|
||||||
|
"repository": REPOSITORY_IDENTITY,
|
||||||
|
"tag_oid": record["tag_oid"],
|
||||||
|
"target_oid": record["target_oid"],
|
||||||
|
"peeled_target_oid": record["peeled_target_oid"],
|
||||||
|
"evidence_closure_oid": record["evidence_closure_oid"],
|
||||||
|
"approval_plan_id": record["approval_plan_id"],
|
||||||
|
}
|
||||||
|
if authority.get("approval_plan_id") != expected["approval_plan_id"]:
|
||||||
|
raise RegistryConvergenceError("approval_plan_not_found_or_mismatched")
|
||||||
|
state = authority.get("state") or authority.get("status")
|
||||||
|
if state not in _APPROVAL_AUTHORITY_STATES and authority.get("approved") is not True:
|
||||||
|
raise RegistryConvergenceError("approval_plan_not_approved")
|
||||||
|
owner_approval = authority.get("owner_approval")
|
||||||
|
if schema == "aios.v2r2c1.tag-approval-request.v1":
|
||||||
|
if not isinstance(owner_approval, dict):
|
||||||
|
raise RegistryConvergenceError("canonical_owner_approval_missing")
|
||||||
|
if (
|
||||||
|
owner_approval.get("approved") is not True
|
||||||
|
or owner_approval.get("status") != "APPROVED"
|
||||||
|
or owner_approval.get("approved_by") != "aiman"
|
||||||
|
or owner_approval.get("approval_source") != "explicit_owner_approval_in_chat"
|
||||||
|
):
|
||||||
|
raise RegistryConvergenceError("canonical_owner_approval_invalid")
|
||||||
|
expected_digest = hashlib.sha256(_canonical_json(owner_approval)).hexdigest()
|
||||||
|
if authority.get("owner_approval_digest_sha256") != expected_digest:
|
||||||
|
raise RegistryConvergenceError("canonical_owner_approval_digest_invalid")
|
||||||
|
for field, wanted in expected.items():
|
||||||
|
observed = nested.get(field)
|
||||||
|
if field == "repository" and observed is None:
|
||||||
|
observed = nested.get("repository_identity") or authority.get(
|
||||||
|
"repository_identity"
|
||||||
|
)
|
||||||
|
if field == "peeled_target_oid" and observed is None:
|
||||||
|
observed = (
|
||||||
|
nested.get("gitea_peeled_target_oid")
|
||||||
|
or authority.get("gitea_peeled_target_oid")
|
||||||
|
or nested.get("remote_peeled_target_oid")
|
||||||
|
or authority.get("remote_peeled_target_oid")
|
||||||
|
)
|
||||||
|
if observed != wanted:
|
||||||
|
raise RegistryConvergenceError(f"approval_plan_release_binding_mismatch:{field}")
|
||||||
|
if state == "GITEA_API_CONFIRMED":
|
||||||
|
gitea_tag = authority.get("gitea_tag_oid")
|
||||||
|
gitea_peeled = authority.get("gitea_peeled_target_oid")
|
||||||
|
if gitea_tag != expected["tag_oid"] or gitea_peeled != expected["target_oid"]:
|
||||||
|
raise RegistryConvergenceError("approval_plan_remote_tag_binding_mismatch")
|
||||||
|
return {
|
||||||
|
"path": str(path),
|
||||||
|
"schema": schema,
|
||||||
|
"state": state or "APPROVED",
|
||||||
|
"approval_plan_id": expected["approval_plan_id"],
|
||||||
|
"owner_approval_verified": schema == "aios.v2r2c1.tag-approval-request.v1",
|
||||||
|
"release_binding_verified": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def source_registry_json(value: dict[str, Any]) -> bytes:
|
||||||
|
"""Return the canonical bytes used by the governed file writer."""
|
||||||
|
return (json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + "\n").encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_current_projection(
|
||||||
|
projection_root: Path,
|
||||||
|
baseline_file: Path,
|
||||||
|
) -> Path | None:
|
||||||
|
"""Return only the exact projection attested by the current Baseline."""
|
||||||
|
if not baseline_file.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
baseline = _load_object(baseline_file)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
version = baseline.get("version")
|
||||||
|
if not isinstance(version, str) or not _VERSION_RE.fullmatch(version):
|
||||||
|
return None
|
||||||
|
if baseline.get("status") != "PRODUCTION_DEPLOYED":
|
||||||
|
return None
|
||||||
|
if baseline.get("production_deployed") is not True:
|
||||||
|
return None
|
||||||
|
|
||||||
|
source = baseline.get("registry_source")
|
||||||
|
digest = baseline.get("registry_digest")
|
||||||
|
if not isinstance(source, str) or not isinstance(digest, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
candidate = projection_root / f"{version}.json"
|
||||||
|
try:
|
||||||
|
if candidate.resolve() != Path(source).resolve():
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not candidate.is_file() or _sha256(candidate) != digest:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
record = _load_object(candidate)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_source_registry_record(
|
||||||
|
record,
|
||||||
|
expected_version=version,
|
||||||
|
expected_tag_oid=baseline.get("tag_oid"),
|
||||||
|
expected_target_oid=baseline.get("target_oid"),
|
||||||
|
expected_evidence_closure_oid=baseline.get("evidence_closure_oid"),
|
||||||
|
expected_approval_plan_id=baseline.get("approval_plan_id"),
|
||||||
|
)
|
||||||
|
except RegistryConvergenceError:
|
||||||
|
return None
|
||||||
|
if record.get("schema") != "aios.production-registry.v2":
|
||||||
|
return None
|
||||||
|
if record.get("status") != "PRODUCTION_DEPLOYED":
|
||||||
|
return None
|
||||||
|
if record.get("version") != version:
|
||||||
|
return None
|
||||||
|
if record.get("repository") != REPOSITORY_IDENTITY:
|
||||||
|
return None
|
||||||
|
if record.get("production_deployed") is not True:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tag_oid = record.get("tag_oid")
|
||||||
|
target_oid = record.get("target_oid")
|
||||||
|
if not isinstance(tag_oid, str) or not _OID_RE.fullmatch(tag_oid):
|
||||||
|
return None
|
||||||
|
if not isinstance(target_oid, str) or not _OID_RE.fullmatch(target_oid):
|
||||||
|
return None
|
||||||
|
if record.get("peeled_target_oid") != target_oid:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def list_registry_versions(
|
||||||
|
workspace: Path | str,
|
||||||
|
*,
|
||||||
|
projection_root: Path = DEFAULT_PROJECTION_ROOT,
|
||||||
|
baseline_file: Path = DEFAULT_BASELINE_FILE,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return the legacy display list; use the report for provenance."""
|
||||||
|
return list_registry_versions_report(
|
||||||
|
workspace,
|
||||||
|
projection_root=projection_root,
|
||||||
|
baseline_file=baseline_file,
|
||||||
|
)["registry_versions"]
|
||||||
|
|
||||||
|
|
||||||
|
def list_registry_versions_report(
|
||||||
|
workspace: Path | str,
|
||||||
|
*,
|
||||||
|
projection_root: Path = DEFAULT_PROJECTION_ROOT,
|
||||||
|
baseline_file: Path = DEFAULT_BASELINE_FILE,
|
||||||
|
repo_git_dir: Path | str | None = None,
|
||||||
|
main_ref: str = DEFAULT_MAIN_REF,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return version visibility with runtime/projection provenance.
|
||||||
|
|
||||||
|
The display list intentionally retains the historical projection overlay
|
||||||
|
for API compatibility. The explicit booleans make that overlay
|
||||||
|
non-authoritative: a projection-only version has
|
||||||
|
``runtime_bundle_contains_version == false`` and can never be reported as
|
||||||
|
converged.
|
||||||
|
"""
|
||||||
|
ws = Path(workspace)
|
||||||
|
versions_root = ws / "versions"
|
||||||
|
runtime_names = {
|
||||||
|
path.name
|
||||||
|
for path in versions_root.iterdir()
|
||||||
|
if path.is_file()
|
||||||
|
and not path.is_symlink()
|
||||||
|
and path.suffix == ".json"
|
||||||
|
and _VERSION_RE.fullmatch(path.stem)
|
||||||
|
} if versions_root.is_dir() else set()
|
||||||
|
current = _validated_current_projection(projection_root, baseline_file)
|
||||||
|
attested_version = current.stem if current is not None else None
|
||||||
|
runtime_bundle_contains = bool(
|
||||||
|
attested_version is not None
|
||||||
|
and f"{attested_version}.json" in runtime_names
|
||||||
|
)
|
||||||
|
consistency_status = "NO_ATTESTED_PROJECTION"
|
||||||
|
source_converged: bool | str = "unknown"
|
||||||
|
source_error: str | None = None
|
||||||
|
runtime_converged = False
|
||||||
|
projection_record: dict[str, Any] | None = None
|
||||||
|
if current is not None:
|
||||||
|
try:
|
||||||
|
projection_record = _load_object(current)
|
||||||
|
except (OSError, RegistryConvergenceError, json.JSONDecodeError) as exc:
|
||||||
|
source_error = f"projection_invalid:{type(exc).__name__}"
|
||||||
|
if repo_git_dir is None:
|
||||||
|
source_converged = "unknown"
|
||||||
|
source_error = "authoritative_repository_unavailable"
|
||||||
|
elif projection_record is not None:
|
||||||
|
try:
|
||||||
|
verify_authoritative_repository(repo_git_dir)
|
||||||
|
verify_source_registry_main_convergence(
|
||||||
|
repo_git_dir,
|
||||||
|
attested_version or "",
|
||||||
|
main_ref=main_ref,
|
||||||
|
expected_tag_oid=projection_record.get("tag_oid"),
|
||||||
|
expected_target_oid=projection_record.get("target_oid"),
|
||||||
|
expected_evidence_closure_oid=projection_record.get(
|
||||||
|
"evidence_closure_oid"
|
||||||
|
),
|
||||||
|
expected_approval_plan_id=projection_record.get("approval_plan_id"),
|
||||||
|
)
|
||||||
|
source_converged = True
|
||||||
|
except RegistryConvergenceError as exc:
|
||||||
|
detail = str(exc)
|
||||||
|
if detail.startswith("authoritative_repository_"):
|
||||||
|
source_converged = "unknown"
|
||||||
|
source_error = (
|
||||||
|
"authoritative_repository_unavailable"
|
||||||
|
if detail.endswith("path_unavailable")
|
||||||
|
else detail
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
source_converged = False
|
||||||
|
source_error = detail
|
||||||
|
if current is not None and not runtime_bundle_contains:
|
||||||
|
consistency_status = "RUNTIME_ARTIFACT_RECONCILIATION_REQUIRED"
|
||||||
|
elif current is not None and runtime_bundle_contains:
|
||||||
|
runtime_path = versions_root / current.name
|
||||||
|
try:
|
||||||
|
projection_record = projection_record or _load_object(current)
|
||||||
|
runtime_record = _load_object(runtime_path)
|
||||||
|
identity_fields = (
|
||||||
|
"version",
|
||||||
|
"repository",
|
||||||
|
"tag_oid",
|
||||||
|
"target_oid",
|
||||||
|
"peeled_target_oid",
|
||||||
|
"evidence_closure_oid",
|
||||||
|
"approval_plan_id",
|
||||||
|
)
|
||||||
|
runtime_converged = all(
|
||||||
|
runtime_record.get(field) == projection_record.get(field)
|
||||||
|
for field in identity_fields
|
||||||
|
)
|
||||||
|
if not runtime_converged:
|
||||||
|
consistency_status = "RUNTIME_IDENTITY_MISMATCH"
|
||||||
|
except (OSError, RegistryConvergenceError, json.JSONDecodeError):
|
||||||
|
consistency_status = "RUNTIME_ENTRY_INVALID"
|
||||||
|
runtime_converged = False
|
||||||
|
if current is not None and runtime_bundle_contains and runtime_converged:
|
||||||
|
if source_converged is True:
|
||||||
|
consistency_status = "FULLY_CONSISTENT"
|
||||||
|
elif source_converged == "unknown":
|
||||||
|
consistency_status = "SOURCE_GIT_NOT_CHECKED"
|
||||||
|
else:
|
||||||
|
consistency_status = "SOURCE_REGISTRY_NOT_CONVERGED"
|
||||||
|
elif current is not None and not runtime_bundle_contains:
|
||||||
|
if source_converged is False:
|
||||||
|
consistency_status = "SOURCE_REGISTRY_NOT_CONVERGED"
|
||||||
|
else:
|
||||||
|
consistency_status = "RUNTIME_ARTIFACT_RECONCILIATION_REQUIRED"
|
||||||
|
names = set(runtime_names)
|
||||||
|
if current is not None:
|
||||||
|
names.add(current.name)
|
||||||
|
effective = sorted(names)
|
||||||
|
projection_versions = [attested_version + ".json"] if attested_version else []
|
||||||
|
return {
|
||||||
|
"registry_versions": effective,
|
||||||
|
"effective_registry_versions": effective,
|
||||||
|
"runtime_registry_versions": sorted(runtime_names),
|
||||||
|
"attested_projection_versions": projection_versions,
|
||||||
|
"attested_projection_version": attested_version,
|
||||||
|
"source_registry_converged": source_converged,
|
||||||
|
"source_registry_convergence_error": source_error,
|
||||||
|
"runtime_registry_converged": runtime_converged,
|
||||||
|
"runtime_bundle_contains_current_version": runtime_bundle_contains,
|
||||||
|
"runtime_bundle_contains_version": runtime_bundle_contains,
|
||||||
|
"registry_consistency_status": consistency_status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assert_runtime_registry_convergence(
|
||||||
|
workspace: Path | str,
|
||||||
|
version: str,
|
||||||
|
*,
|
||||||
|
projection_root: Path = DEFAULT_PROJECTION_ROOT,
|
||||||
|
baseline_file: Path = DEFAULT_BASELINE_FILE,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fail closed when a projection-only version is visible but unpackaged."""
|
||||||
|
report = list_registry_versions_report(
|
||||||
|
workspace,
|
||||||
|
projection_root=projection_root,
|
||||||
|
baseline_file=baseline_file,
|
||||||
|
)
|
||||||
|
expected_name = f"{version}.json"
|
||||||
|
if (
|
||||||
|
report.get("attested_projection_version") == version
|
||||||
|
and report.get("runtime_bundle_contains_version") is not True
|
||||||
|
):
|
||||||
|
raise RegistryConvergenceError("runtime_bundle_missing_source_registry")
|
||||||
|
if expected_name not in report.get("runtime_registry_versions", []):
|
||||||
|
raise RegistryConvergenceError("runtime_bundle_missing_source_registry")
|
||||||
|
if report.get("source_registry_converged") is not True:
|
||||||
|
raise RegistryConvergenceError("source_registry_not_converged_or_not_checked")
|
||||||
|
if report.get("registry_consistency_status") not in {"FULLY_CONSISTENT", "PASS"}:
|
||||||
|
raise RegistryConvergenceError(
|
||||||
|
f"runtime_registry_consistency:{report.get('registry_consistency_status')}"
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def source_registry_from_main(
|
||||||
|
repo_git_dir: Path | str,
|
||||||
|
version: str,
|
||||||
|
*,
|
||||||
|
main_ref: str = DEFAULT_MAIN_REF,
|
||||||
|
source_registry_main_oid: str | None = None,
|
||||||
|
expected_tag_oid: str | None = None,
|
||||||
|
expected_target_oid: str | None = None,
|
||||||
|
expected_evidence_closure_oid: str | None = None,
|
||||||
|
expected_approval_plan_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Read and validate the canonical source entry from the supplied main ref."""
|
||||||
|
try:
|
||||||
|
return source_registry_snapshot(
|
||||||
|
repo_git_dir,
|
||||||
|
version,
|
||||||
|
main_ref=main_ref,
|
||||||
|
source_registry_main_oid=source_registry_main_oid,
|
||||||
|
expected_tag_oid=expected_tag_oid,
|
||||||
|
expected_target_oid=expected_target_oid,
|
||||||
|
expected_evidence_closure_oid=expected_evidence_closure_oid,
|
||||||
|
expected_approval_plan_id=expected_approval_plan_id,
|
||||||
|
)["record"]
|
||||||
|
except RegistryConvergenceError as exc:
|
||||||
|
if str(exc).startswith("fatal:") or "path" in str(exc).lower():
|
||||||
|
raise RegistryConvergenceError(
|
||||||
|
f"source_registry_entry_missing:{source_registry_main_oid or main_ref}:versions/{version}.json"
|
||||||
|
) from exc
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def assert_source_registry_convergence(
|
||||||
|
registry_path: Path | str,
|
||||||
|
*,
|
||||||
|
repo_git_dir: Path | str,
|
||||||
|
main_ref: str = DEFAULT_MAIN_REF,
|
||||||
|
source_registry_main_oid: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Require governed main to contain a source entry for the release identity.
|
||||||
|
|
||||||
|
The source entry is intentionally required to match only fields that are
|
||||||
|
knowable and governable before production deployment. Post-deployment
|
||||||
|
fields such as production_service/port/status timestamps are not required.
|
||||||
|
"""
|
||||||
|
registry_path = Path(registry_path)
|
||||||
|
repo_git_dir = Path(repo_git_dir)
|
||||||
|
verify_authoritative_repository(repo_git_dir)
|
||||||
|
record = _load_object(registry_path)
|
||||||
|
|
||||||
|
version = record.get("version")
|
||||||
|
tag_oid = record.get("tag_oid")
|
||||||
|
target_oid = record.get("target_oid")
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_source_registry_record(
|
||||||
|
record,
|
||||||
|
expected_version=version,
|
||||||
|
expected_tag_oid=tag_oid,
|
||||||
|
expected_target_oid=target_oid,
|
||||||
|
expected_evidence_closure_oid=record.get("evidence_closure_oid"),
|
||||||
|
expected_approval_plan_id=record.get("approval_plan_id"),
|
||||||
|
)
|
||||||
|
except RegistryConvergenceError as exc:
|
||||||
|
raise RegistryConvergenceError(f"registry_identity_invalid:{exc}") from exc
|
||||||
|
convergence = verify_source_registry_main_convergence(
|
||||||
|
repo_git_dir,
|
||||||
|
version,
|
||||||
|
main_ref=main_ref,
|
||||||
|
source_registry_main_oid=source_registry_main_oid,
|
||||||
|
expected_tag_oid=tag_oid,
|
||||||
|
expected_target_oid=target_oid,
|
||||||
|
expected_evidence_closure_oid=record["evidence_closure_oid"],
|
||||||
|
expected_approval_plan_id=record["approval_plan_id"],
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
**convergence,
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
-- 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,37 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=AIOS owner-controlled Task execution-rebind 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-execution-rebind.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_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892
|
|
||||||
Environment=AIOS_TASK_EXECUTION_REBIND_HOST=127.0.0.1
|
|
||||||
Environment=AIOS_TASK_EXECUTION_REBIND_PORT=18942
|
|
||||||
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_agent_task_execution_rebind.py
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=2
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectHome=true
|
|
||||||
ProtectKernelTunables=true
|
|
||||||
ProtectKernelModules=true
|
|
||||||
ProtectControlGroups=true
|
|
||||||
LockPersonality=true
|
|
||||||
RestrictSUIDSGID=true
|
|
||||||
RestrictRealtime=true
|
|
||||||
RestrictNamespaces=true
|
|
||||||
MemoryDenyWriteExecute=true
|
|
||||||
RestrictAddressFamilies=AF_UNIX AF_INET
|
|
||||||
ReadWritePaths=/var/lib/aios-agent-task
|
|
||||||
UMask=0077
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
[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
|
|
||||||
InaccessiblePaths=/run/credentials
|
|
||||||
InaccessiblePaths=/etc/aios-gitops
|
|
||||||
InaccessiblePaths=/data/aios-gitops
|
|
||||||
ReadOnlyPaths=/etc/aios/external-agent-runtime.d
|
|
||||||
ReadWritePaths=/var/lib/aios-external-agent-executor
|
|
||||||
UMask=0027
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
[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,4 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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-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 -
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
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 -
|
|
||||||
@@ -240,114 +240,6 @@ components:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
properties:
|
properties:
|
||||||
candidate_id: {type: string}
|
candidate_id: {type: string}
|
||||||
BridgeWriterLeaseRecoveryRequest:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required: [schema, task_id, repository, branch_mode, base_oid, expected_branch_oid, target_branch, generation, lease_seconds]
|
|
||||||
properties:
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
enum: [aios.task-writer-lease.v2, aios.task-writer-lease-request.v2]
|
|
||||||
task_id: {type: string, minLength: 1, maxLength: 256}
|
|
||||||
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}
|
|
||||||
BridgeWriterLeaseResponse:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
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:
|
|
||||||
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}
|
|
||||||
content_base64: {type: string, format: byte}
|
|
||||||
message: {type: string, minLength: 1, maxLength: 200}
|
|
||||||
BridgeCommitFilesRequest:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
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:
|
paths:
|
||||||
/createReleaseIntent:
|
/createReleaseIntent:
|
||||||
post:
|
post:
|
||||||
@@ -675,118 +567,3 @@ 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
|
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# R4.2 P0-C Trace / Tool-MCP / Credential Governance
|
|
||||||
|
|
||||||
## Status boundary
|
|
||||||
|
|
||||||
This change is a candidate implementation on the governed P0-C branch. It does
|
|
||||||
not promote protected `main`, deploy Production, or assert that all R4.2 target
|
|
||||||
architecture is complete.
|
|
||||||
|
|
||||||
## Scope model
|
|
||||||
|
|
||||||
Governance resources use an explicit `scope_type`:
|
|
||||||
|
|
||||||
- `tenant` requires `tenant_id` and may narrow to user, project,
|
|
||||||
conversation, and run;
|
|
||||||
- `system` and `global` prohibit tenant ownership fields;
|
|
||||||
- `legacy` preserves unscoped historical records without inventing ownership.
|
|
||||||
|
|
||||||
System and global tools therefore never receive a synthetic tenant merely to
|
|
||||||
satisfy a tenant-oriented schema.
|
|
||||||
|
|
||||||
## Trace lineage
|
|
||||||
|
|
||||||
`TaskPlan` and `MessageEnvelope` scope is bound to a `TraceContext`. The trace
|
|
||||||
records `trace_id`, `span_id`, parent span, correlation, causation, task,
|
|
||||||
message, and the canonical P0-A scope. Child spans must keep the parent's trace,
|
|
||||||
task, and scope. Privileged visibility does not permit cross-scope trace
|
|
||||||
splicing.
|
|
||||||
|
|
||||||
`MessageEnvelope.correlation_id` is persisted and identity-bound. Existing
|
|
||||||
handoff databases are upgraded in place with an idempotent column migration;
|
|
||||||
legacy rows retain their prior event IDs because a null correlation field is
|
|
||||||
not added to the historical identity material.
|
|
||||||
|
|
||||||
Observability keeps its legacy presentation fields, but new governed records
|
|
||||||
also carry immutable trace and scope fields. Scoped records require a trusted
|
|
||||||
server actor for queries. A repeated execution ID is an idempotent replay only
|
|
||||||
when the complete record matches; conflicting overwrites are denied.
|
|
||||||
|
|
||||||
## Tool and MCP governance
|
|
||||||
|
|
||||||
`ToolDescriptor` binds a tool or MCP server to:
|
|
||||||
|
|
||||||
- an explicit resource scope;
|
|
||||||
- required actor capabilities;
|
|
||||||
- an optional credential reference.
|
|
||||||
|
|
||||||
Invocation authorization requires exact propagation of the trace scope and a
|
|
||||||
capability superset. Unknown tools, cross-tenant calls, scope substitution, and
|
|
||||||
non-privileged access to system/global resources fail closed. Every successful
|
|
||||||
registration and invocation appends hash-chained audit evidence.
|
|
||||||
|
|
||||||
## Credential governance
|
|
||||||
|
|
||||||
Credentials are represented only by references whose schemes are
|
|
||||||
`systemd-credential`, `vault`, or `secret-manager`. Raw bearer tokens, passwords,
|
|
||||||
and arbitrary values are rejected. Grants are bound to scope, operation, task,
|
|
||||||
tool, and expiry. Public/audit records contain a reference hash where disclosure
|
|
||||||
is unnecessary; secret material is never persisted by this module.
|
|
||||||
|
|
||||||
The existing runtime credential broker remains backward compatible for system
|
|
||||||
leases, adds explicit tenant/system/global invariants, and rejects providers
|
|
||||||
that attempt to return raw token/secret/password environment values instead of
|
|
||||||
file/reference indirection.
|
|
||||||
|
|
||||||
## Durable evidence
|
|
||||||
|
|
||||||
The SQLite governance store is restart-safe and idempotently initializes four
|
|
||||||
tables: governed tools, trace spans, credential grants, and audit evidence.
|
|
||||||
Audit entries form a SHA-256 chain. Acceptance requires the chain to verify and
|
|
||||||
contain the required actions; missing evidence is not treated as success.
|
|
||||||
|
|
||||||
## Acceptance tests
|
|
||||||
|
|
||||||
The candidate must pass:
|
|
||||||
|
|
||||||
- `TRACE_LINEAGE_CONTINUITY`;
|
|
||||||
- `TRACE_SCOPE_MISMATCH_DENY`;
|
|
||||||
- `TOOL_MCP_AUTHZ_DENY`;
|
|
||||||
- `TOOL_SCOPE_PROPAGATION`;
|
|
||||||
- `CREDENTIAL_SCOPE_DENY`;
|
|
||||||
- `CREDENTIAL_SECRET_EXPOSURE_DENY`;
|
|
||||||
- `AUDIT_EVIDENCE_COMPLETENESS`;
|
|
||||||
- P0-A scope and P0-B handoff regression suites;
|
|
||||||
- Python compilation, `git diff --check`, and a diff-scoped secret scan.
|
|
||||||
|
|
||||||
The repository-wide baseline currently has seven unrelated failures in the
|
|
||||||
isolated host environment (five stale continuation-contract fixtures and two
|
|
||||||
host-permission fixtures). The same seven failures reproduce at the untouched
|
|
||||||
`a642b045775b9154a00ae78543037fbef4fb447b` base; they are not suppressed or
|
|
||||||
modified by P0-C.
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# R4.2 P1 — Durable Online Agent Execution
|
|
||||||
|
|
||||||
Status: candidate implementation. This document does not imply protected-main promotion or Production deployment.
|
|
||||||
|
|
||||||
## State model
|
|
||||||
|
|
||||||
The generic worker uses two local durable layers:
|
|
||||||
|
|
||||||
1. `tasks/<task_id>.json`: atomic fast-state cache for compatibility and quick restart.
|
|
||||||
2. `checkpoints.sqlite3`: append-only attempt/phase journal with `synchronous=FULL`.
|
|
||||||
|
|
||||||
Checkpoint identity is bound to:
|
|
||||||
|
|
||||||
- task_id
|
|
||||||
- attempt
|
|
||||||
- execution_digest
|
|
||||||
- job_id
|
|
||||||
- repository
|
|
||||||
- base_oid
|
|
||||||
- expected_branch_oid
|
|
||||||
- target_branch
|
|
||||||
- generation
|
|
||||||
|
|
||||||
A checkpoint from another repository/branch/generation is never reused.
|
|
||||||
|
|
||||||
Phases are monotonic within an attempt:
|
|
||||||
|
|
||||||
`claimed → workspace_prepared → verification_passed → source_capture_done|commit_done → result_pending → result_submitted`
|
|
||||||
|
|
||||||
`reconciliation_required` is an explicit recovery phase.
|
|
||||||
|
|
||||||
## Attempt-aware result idempotency
|
|
||||||
|
|
||||||
Result submission idempotency keys include the official Task attempt and the execution digest. The same
|
|
||||||
attempt reuses the same key after a timeout/restart; a later official attempt cannot collide with an
|
|
||||||
earlier result.
|
|
||||||
|
|
||||||
Before submitting a result the worker durably writes `result_pending`, including the result hash and
|
|
||||||
idempotency key. After a successful or exact-idempotency-reconciled API response it writes
|
|
||||||
`result_submitted`.
|
|
||||||
|
|
||||||
A 409 is accepted only if the Task API already contains the exact same result body and the Task is
|
|
||||||
VERIFYING/SUCCEEDED. A conflicting result remains a hard failure.
|
|
||||||
|
|
||||||
## Resume rules
|
|
||||||
|
|
||||||
- `source_capture_done` and `result_pending` may be reused across later official attempts only when the
|
|
||||||
execution/scope binding is identical. This avoids repeating read-only source capture.
|
|
||||||
- A deterministic task that crashed before governed commit replays from a new clean detached worktree.
|
|
||||||
This is safe because there is no external side effect yet.
|
|
||||||
- `commit_done` is never recommitted. Recovery requires exact Job receipt OID convergence plus exactly
|
|
||||||
one ACTIVE WriterLease whose `current_head_oid` equals the governed commit.
|
|
||||||
- delayed Git visibility remains `RECONCILIATION_REQUIRED`, preserving an explicit recovery entry.
|
|
||||||
|
|
||||||
## Crash cleanup
|
|
||||||
|
|
||||||
On worker startup, stale `task-*` workspaces under the configured workspace root are removed before
|
|
||||||
polling begins. The path boundary is verified before deletion.
|
|
||||||
|
|
||||||
## Contracts
|
|
||||||
|
|
||||||
P1 gates include:
|
|
||||||
|
|
||||||
- CHECKPOINT_DURABLE_WRITE
|
|
||||||
- RESUME_FROM_CHECKPOINT
|
|
||||||
- RETRY_IDEMPOTENT_NO_DUPLICATE_SIDE_EFFECT
|
|
||||||
- CRASH_RECOVERY_CONVERGENCE
|
|
||||||
- WORKER_RESTART_RESUME
|
|
||||||
- CHECKPOINT_SCOPE_ISOLATION
|
|
||||||
- RESULT_SUBMISSION_REPLAY_SAFE
|
|
||||||
- WORKSPACE_CLEANUP_RECOVERY
|
|
||||||
- LEASE_RENEWAL_RECOVERY
|
|
||||||
|
|
||||||
WriterLease and governed Job authority remain in the existing Bridge/GitOps durable stores; the worker
|
|
||||||
checkpoint DB is not a competing authority for Git state.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# R4.2 P2 Agent Fleet / Multi-Agent Scheduling / Fault Isolation
|
|
||||||
|
|
||||||
P2 adds a durable fleet decision layer inside TaskPlane without replacing TaskPlane claim/lease
|
|
||||||
ownership. Worker registration/heartbeat remains authoritative for liveness. TaskPlane claim CAS
|
|
||||||
remains authoritative for ownership. Probe-before-reassignment remains mandatory after an expired
|
|
||||||
lease. P1 checkpoint/result replay remains authoritative after worker restart.
|
|
||||||
|
|
||||||
`FleetScheduler` adds durable worker capacity slots, weighted scope fairness, starvation aging,
|
|
||||||
affinity/anti-affinity labels, worker circuit breakers, per-task/worker quarantine and idempotent
|
|
||||||
dispatch reservations. Fleet state is colocated in the existing TaskPlane SQLite database and is
|
|
||||||
created idempotently; no new daemon or database is introduced.
|
|
||||||
|
|
||||||
A fleet reservation is only a scheduling decision. It does not claim a Task. Callers must perform
|
|
||||||
the normal TaskPlane claim and may then bind the returned claim ID to the reservation. This preserves
|
|
||||||
the existing ownership and recovery invariants.
|
|
||||||
|
|
||||||
Failure recovery never bypasses probe evidence. When TaskPlane recovery places work back in a
|
|
||||||
claimable state, P2 may quarantine the failed worker and prefer another worker/failure domain.
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# R4.2 P2 Compact Interface Map Runtime
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
The persistent Online Agent already supports the canonical read-only `source_capture` action, but
|
|
||||||
its `SourceCaptureExecutor` always returns an `aios.engineering.source-snapshot.v2` tar.gz+base64
|
|
||||||
payload. That is correct for implementation source transfer, but too large for control-plane
|
|
||||||
consumers that only need a bounded implementation map.
|
|
||||||
|
|
||||||
P2 adds a new **adapter**, not a new execution action:
|
|
||||||
|
|
||||||
```text
|
|
||||||
action=source_capture
|
|
||||||
adapter=compact_interface_map
|
|
||||||
```
|
|
||||||
|
|
||||||
This preserves the existing execution-v2 schema and Bridge policy:
|
|
||||||
|
|
||||||
- read-only repository;
|
|
||||||
- no change set;
|
|
||||||
- no verification commands;
|
|
||||||
- `writer_lease=false`;
|
|
||||||
- no branch or commit;
|
|
||||||
- existing capability and execution-digest validation.
|
|
||||||
|
|
||||||
## Compact map contract
|
|
||||||
|
|
||||||
`aios_online_agent_compact_map.py` scans only approved repository roots and source suffixes. It
|
|
||||||
returns:
|
|
||||||
|
|
||||||
- at most 40 relevant modules;
|
|
||||||
- at most 80 selected Python symbols in total;
|
|
||||||
- at most 40 test symbols;
|
|
||||||
- at most 25 excerpts;
|
|
||||||
- at most 1200 characters per excerpt;
|
|
||||||
- SQL table names;
|
|
||||||
- systemd unit names;
|
|
||||||
- current invariants, failure windows and reuse points;
|
|
||||||
- P2 fleet contract-to-surface mappings.
|
|
||||||
|
|
||||||
The result is hard-bounded to 120 KiB and contains no source archive or `payload_b64`.
|
|
||||||
|
|
||||||
Source excerpts apply a conservative sensitive-pattern redaction before result construction.
|
|
||||||
|
|
||||||
## P2 contracts
|
|
||||||
|
|
||||||
The map supplies evidence surfaces for:
|
|
||||||
|
|
||||||
- `FLEET_AGENT_REGISTRATION`
|
|
||||||
- `FLEET_CAPACITY_SCHEDULING`
|
|
||||||
- `FLEET_FAIRNESS`
|
|
||||||
- `FLEET_FAULT_ISOLATION`
|
|
||||||
- `FLEET_WORKER_REASSIGNMENT`
|
|
||||||
- `FLEET_SCOPE_ISOLATION`
|
|
||||||
- `FLEET_IDEMPOTENT_DISPATCH`
|
|
||||||
- `FLEET_FAILURE_RECOVERY`
|
|
||||||
|
|
||||||
Missing first-class scheduling/fleet primitives are reported as gaps rather than invented.
|
|
||||||
|
|
||||||
## Result
|
|
||||||
|
|
||||||
When the adapter is selected, the worker returns:
|
|
||||||
|
|
||||||
```text
|
|
||||||
status=SUCCEEDED
|
|
||||||
classification=P2_INTERFACE_MAP_CAPTURED
|
|
||||||
execution_mode=compact_interface_map
|
|
||||||
writer_lease=NONE
|
|
||||||
```
|
|
||||||
|
|
||||||
The existing `source_capture_done` / P1 checkpoint/retry path is reused, so the compact analysis
|
|
||||||
inherits attempt-aware result idempotency and restart-safe result submission.
|
|
||||||
|
|
||||||
## Governance
|
|
||||||
|
|
||||||
This capability is runtime infrastructure only. The implementation candidate may use a governed
|
|
||||||
branch/WriterLease for its own code change, but any task executed through `compact_interface_map`
|
|
||||||
is read-only and must not create a WriterLease, branch, commit, protected-ref write, or Production
|
|
||||||
side effect.
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# R4.2 P2-B Fleet Order Preservation Repair
|
|
||||||
|
|
||||||
P2-C live acceptance exposed a real integration defect in the verified P2-B candidate. The fleet
|
|
||||||
scheduler correctly produced durable cross-scope fairness order, but the integration passed the
|
|
||||||
entire ranked list to the legacy scheduler. The legacy scheduler then applied its own priority /
|
|
||||||
created_at / task_id sort and silently discarded fleet ordering.
|
|
||||||
|
|
||||||
The repair preserves the legacy scheduler as the fail-closed security oracle while changing how it
|
|
||||||
is called: one fleet-ranked candidate is presented at a time. A candidate rejected by legacy
|
|
||||||
security/policy advances to the next fleet candidate. A candidate returned by the oracle must have
|
|
||||||
the same task ID as the candidate presented, otherwise execution fails closed.
|
|
||||||
|
|
||||||
Task ownership remains unchanged: the original schedule_for_worker/claim_task CAS remains
|
|
||||||
authoritative, and a fleet reservation is still only bound after the claim succeeds.
|
|
||||||
|
|
||||||
This repair does not modify FleetScheduler state semantics, capacity, WriterLease, checkpoint,
|
|
||||||
protected refs or Production behavior.
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# R4.2 P2-B Fleet Runtime Integration
|
|
||||||
|
|
||||||
P2-B connects the verified P2 fleet primitives to the existing TaskPlane pull scheduler without
|
|
||||||
replacing its security or ownership logic.
|
|
||||||
|
|
||||||
The integration uses two stages. `FleetScheduler.rank_for_worker()` performs only read-only
|
|
||||||
capacity/fairness/fault-isolation ranking. The original `TaskScheduler.next_queued_task()` is then
|
|
||||||
run over that ordered list and remains the fail-closed security oracle for worker liveness,
|
|
||||||
capabilities, data classification, execution zone and other existing policy checks. Only a task
|
|
||||||
accepted by the original scheduler receives a short durable fleet reservation.
|
|
||||||
|
|
||||||
The original `TaskScheduler.schedule_for_worker()` remains the sole claim path and continues to call
|
|
||||||
the existing TaskPlane `claim_task` CAS. After a successful claim, the claim ID is bound to the
|
|
||||||
fleet reservation. A claim error releases the reservation. A fleet reservation is never treated as
|
|
||||||
task ownership.
|
|
||||||
|
|
||||||
P2-B also makes the reservation capacity check atomic inside the fleet SQLite transaction so two
|
|
||||||
concurrent pullers cannot oversubscribe the same worker slot. Circuit and task/worker quarantine
|
|
||||||
state are enforced on the real pull path. Explicit execution-failure signals can be recorded through
|
|
||||||
the integrated scheduler's failure/success/quarantine methods; policy/claim failures are not
|
|
||||||
automatically mislabeled as worker crashes.
|
|
||||||
|
|
||||||
No new daemon, database or production write path is introduced.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generic Compact Interface-Map Profiles
|
|
||||||
|
|
||||||
The compact interface-map runtime is a reusable read-only control-plane capability. It keeps the
|
|
||||||
canonical `source_capture` action and `compact_interface_map` adapter, but selects a bounded semantic
|
|
||||||
profile from the task's source signals.
|
|
||||||
|
|
||||||
The existing `fleet` profile remains backward compatible and returns
|
|
||||||
`P2_INTERFACE_MAP_CAPTURED` with the `FLEET_*` contracts.
|
|
||||||
|
|
||||||
The new `model_governance` profile is selected when model/provider/routing/budget/evaluation terms
|
|
||||||
are present. It returns `MODEL_GOVERNANCE_INTERFACE_MAP_CAPTURED` and model-specific contracts for
|
|
||||||
provider registry, selection policy, version pinning, budget accounting, fallback, scope isolation,
|
|
||||||
credential boundary, trace/audit, evaluation gates and routing resilience.
|
|
||||||
|
|
||||||
Profile and classification are emitted by the compact-map module and revalidated by the worker
|
|
||||||
before result submission. Unknown or mismatched profile/classification pairs fail closed.
|
|
||||||
|
|
||||||
No new Bridge action, daemon, database, WriterLease, mutation path or credential surface is added.
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
# Hermes / OpenClaw Online Agent Runtime
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Promote Hermes and OpenClaw from shadow-only adapter descriptions to real Online Agent workers
|
|
||||||
without creating a second Task/claim protocol.
|
|
||||||
|
|
||||||
Both workers reuse the existing P1/P2 Online Agent lifecycle:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Online Task projected
|
|
||||||
→ capability-filtered poll
|
|
||||||
→ automatic claim
|
|
||||||
→ heartbeat
|
|
||||||
→ durable checkpoint
|
|
||||||
→ external Agent execution
|
|
||||||
→ attempt-aware result submission
|
|
||||||
→ official verification
|
|
||||||
```
|
|
||||||
|
|
||||||
They do **not** change the existing TaskPlane `OpenClawAdapter/HermesAdapter` shadow contract. Those
|
|
||||||
adapters remain side-effect-free compatibility surfaces.
|
|
||||||
|
|
||||||
## Execution action
|
|
||||||
|
|
||||||
The canonical execution envelope adds a read-only action:
|
|
||||||
|
|
||||||
```text
|
|
||||||
action=agent_runtime
|
|
||||||
adapter=hermes | openclaw
|
|
||||||
```
|
|
||||||
|
|
||||||
The action requires capability `agent.runtime.<adapter>`, has an empty change set, no verification
|
|
||||||
commands, and `writer_lease=false`.
|
|
||||||
|
|
||||||
`project_online_task()` copies only bounded task fields into the signed execution envelope:
|
|
||||||
|
|
||||||
- goal;
|
|
||||||
- instructions;
|
|
||||||
- success criteria;
|
|
||||||
- non-secret constraints.
|
|
||||||
|
|
||||||
The task may not choose a command, executable, URL, credential path, environment, or shell string.
|
|
||||||
|
|
||||||
## Worker separation
|
|
||||||
|
|
||||||
The generic engineering worker defaults to:
|
|
||||||
|
|
||||||
```text
|
|
||||||
AIOS_WORKER_ALLOWED_ADAPTERS=deterministic,source_capture,compact_interface_map,codex
|
|
||||||
```
|
|
||||||
|
|
||||||
so it skips Hermes/OpenClaw tasks before claim.
|
|
||||||
|
|
||||||
Dedicated services advertise:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Hermes:
|
|
||||||
AIOS_WORKER_CAPABILITIES=agent.runtime.hermes
|
|
||||||
AIOS_WORKER_ALLOWED_ADAPTERS=hermes
|
|
||||||
|
|
||||||
OpenClaw:
|
|
||||||
AIOS_WORKER_CAPABILITIES=agent.runtime.openclaw
|
|
||||||
AIOS_WORKER_ALLOWED_ADAPTERS=openclaw
|
|
||||||
```
|
|
||||||
|
|
||||||
This makes adapter selection a pre-claim capability/fleet decision instead of a post-claim failure.
|
|
||||||
|
|
||||||
## Credential-isolated executor
|
|
||||||
|
|
||||||
The Online Agent worker itself still owns only Task API/Bridge coordination credentials. It writes
|
|
||||||
a hash-bound request to `/var/lib/aios-external-agent-executor`.
|
|
||||||
|
|
||||||
`aios-external-agent-executor.service` runs as the existing uncredentialed engineering executor user.
|
|
||||||
It has:
|
|
||||||
|
|
||||||
- no `LoadCredential`;
|
|
||||||
- `/run/credentials`, `/etc/aios-gitops`, and `/data/aios-gitops` inaccessible;
|
|
||||||
- no repository path;
|
|
||||||
- no shell execution;
|
|
||||||
- loopback denied, preventing direct access to the AIOS Task/Bridge control plane;
|
|
||||||
- external network available for an installed Agent runtime/provider;
|
|
||||||
- only the exchange directory writable.
|
|
||||||
|
|
||||||
The root-owned runtime profile in `/etc/aios/external-agent-runtime.d/<runtime>.json` decides the
|
|
||||||
absolute executable and I/O mode. A task cannot override it.
|
|
||||||
|
|
||||||
## Runtime profile
|
|
||||||
|
|
||||||
A profile supports:
|
|
||||||
|
|
||||||
- `argv`: fixed argv, absolute allowlisted executable;
|
|
||||||
- `input_mode`: `stdin_text`, `stdin_json`, or `file_json`;
|
|
||||||
- `output_mode`: `stdout_text`, `stdout_json`, or `file_json`;
|
|
||||||
- timeout;
|
|
||||||
- bounded output size.
|
|
||||||
|
|
||||||
`{input_json}` and `{output_json}` placeholders are allowed only for file modes.
|
|
||||||
|
|
||||||
The repository includes examples only. Before activation, the Agent must inspect the actually
|
|
||||||
installed Hermes/OpenClaw non-interactive CLI and create the root-owned host profile that matches
|
|
||||||
that CLI. Do not blindly install the example.
|
|
||||||
|
|
||||||
If a runtime requires raw API credentials in the child process, live activation is blocked until a
|
|
||||||
separate governed credential projection exists. This worker does not pass API keys through task
|
|
||||||
metadata or environment variables.
|
|
||||||
|
|
||||||
## Retry/restart
|
|
||||||
|
|
||||||
The external request ID is deterministic over Task/Job/execution digest/runtime. The exchange keeps
|
|
||||||
the request/result across worker restart. A repeated attempt with the exact execution digest reuses
|
|
||||||
the durable external result instead of invoking the Agent twice.
|
|
||||||
|
|
||||||
The normal P1 result idempotency/checkpoint path remains authoritative for Task result submission.
|
|
||||||
|
|
||||||
## Result
|
|
||||||
|
|
||||||
Successful execution returns:
|
|
||||||
|
|
||||||
```text
|
|
||||||
status=SUCCEEDED
|
|
||||||
classification=EXTERNAL_AGENT_RUNTIME_SUCCEEDED
|
|
||||||
execution_mode=agent_runtime
|
|
||||||
agent_runtime=hermes | openclaw
|
|
||||||
writer_lease=NONE
|
|
||||||
changed_files=[]
|
|
||||||
credential_material_exposed=false
|
|
||||||
control_plane_access_granted_to_runtime=false
|
|
||||||
```
|
|
||||||
|
|
||||||
This first runtime version is intentionally read-only with respect to the repository. Engineering
|
|
||||||
changes continue to use assistant-authored deterministic plans and governed WriterLease/commit.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# Owner-Controlled Same-Binding Execution Rebind
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
`aios_agent_task_execution_rebind.py` closes a projection/plan race without replacing the Task,
|
|
||||||
Governed Job, target branch, generation, or idempotency lineage.
|
|
||||||
|
|
||||||
The only supported transition is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
official NEEDS_REWORK
|
|
||||||
→ Task QUEUED with no active lease
|
|
||||||
→ persisted source_capture envelope
|
|
||||||
→ canonical Bridge resolves an already-registered deterministic plan
|
|
||||||
→ CAS-bound metadata.execution + execution_projection replacement
|
|
||||||
→ audit event
|
|
||||||
→ normal worker claim
|
|
||||||
```
|
|
||||||
|
|
||||||
The service rejects arbitrary metadata patches. It derives the new envelope from canonical Bridge
|
|
||||||
`/v2/execution-envelope`, validates every governed binding coordinate against the persisted envelope,
|
|
||||||
requires `source_capture → deterministic`, requires exact old/new execution digests supplied by the
|
|
||||||
Owner recovery request, and increments the Task revision under `BEGIN IMMEDIATE`.
|
|
||||||
|
|
||||||
It records `TASK_EXECUTION_REBIND_AUTHORIZED` and an idempotent `task_execution_rebinds` receipt.
|
|
||||||
No Task/Job/branch replacement is created and the worker still performs the real claim/heartbeat,
|
|
||||||
WriterLease and governed commit.
|
|
||||||
|
|
||||||
The endpoint is loopback-only:
|
|
||||||
|
|
||||||
`POST /v1/tasks/{task_id}/execution-rebind`
|
|
||||||
|
|
||||||
and requires the existing Owner control credential plus
|
|
||||||
`AIOS_AGENT_TASK_EXECUTION_REBIND_AUTHORIZATION_ID`.
|
|
||||||
|
|
||||||
Direct/manual Task database edits remain forbidden; the service is the audited mutation boundary.
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# R4.2 P0-A TaskPlane Scope Isolation
|
|
||||||
|
|
||||||
Status: candidate implementation. This is not proof of protected-main promotion or production deployment.
|
|
||||||
|
|
||||||
Task scope is now represented by `scope_type`, `tenant_id`, `user_id`, `project_id`, `conversation_id`, and `run_id` in the model and SQLite persistence. Historical rows become `legacy`; their tenant is never guessed. Tenant tasks require `tenant_id`. System resources use `scope_type=system` without a fabricated tenant.
|
|
||||||
|
|
||||||
The HTTP adapter uses the authenticated identity. `PolicyEngine` resolves that identity through server-owned `identity_actor_contexts`; request payload fields do not grant scope. Public create binds the trusted scope, and public list/get apply parameterized scope predicates in `TaskPlaneStore`.
|
|
||||||
|
|
||||||
SQLite is the current runtime database. PostgreSQL remains a separate migration and runtime-acceptance gate.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# R4.2 P0-B — Durable Message / Handoff Fabric
|
|
||||||
|
|
||||||
Status: candidate implementation; not evidence of protected-main merge or Production deployment.
|
|
||||||
|
|
||||||
`MessageEnvelope` is the single durable handoff identity and carries source/target/type, tenant/user/
|
|
||||||
project/conversation/task/run scope, causation, sequence, capability scope, confidentiality scope,
|
|
||||||
idempotency key, expiry and delivery state.
|
|
||||||
|
|
||||||
Identity includes **target + scope + sequence + idempotency key + payload hash**, fixing the existing
|
|
||||||
AgentEventBus target collision. Server ActorContext is authoritative; caller scope may narrow but may
|
|
||||||
not widen/forge trusted tenant/user/project/conversation/run fields.
|
|
||||||
|
|
||||||
`DurableHandoffStore` provides SQLite durability in the current runtime. Each logical stream has
|
|
||||||
unique `(stream_key, idempotency_key)` and `(stream_key, sequence)`. Exact retry returns `REPLAY`;
|
|
||||||
changed payload under the same key is rejected; sequence gaps are rejected; expired messages are not
|
|
||||||
delivered.
|
|
||||||
|
|
||||||
Confidentiality scopes:
|
|
||||||
|
|
||||||
- public: no tenant requirement
|
|
||||||
- tenant: same tenant
|
|
||||||
- project: same tenant + project
|
|
||||||
- private: same tenant + project + user
|
|
||||||
- run: same tenant + project + user + run
|
|
||||||
|
|
||||||
Required `capability_scope` must be present on the authenticated delivery actor unless the actor is
|
|
||||||
explicitly administrative.
|
|
||||||
|
|
||||||
Compatibility adapters convert AgentEventBus events, TaskPlane CloudEvents and legacy Agent OS
|
|
||||||
MessageBus messages into the durable envelope. Existing message_outbox remains intact as a migration
|
|
||||||
source; P0-B does not delete or silently rewrite it.
|
|
||||||
|
|
||||||
Required gates:
|
|
||||||
`MESSAGE_SCOPE_MISMATCH_DENY`, `MESSAGE_TARGET_COLLISION_DENY`, `MESSAGE_IDEMPOTENCY_REPLAY`,
|
|
||||||
`MESSAGE_SEQUENCE_GAP_DETECT`, `MESSAGE_CONFIDENTIALITY_SCOPE_ENFORCE`,
|
|
||||||
`HANDOFF_CROSS_TENANT_DENY`, plus existing P0-A TaskPlane scope/security regressions.
|
|
||||||
|
|
||||||
SQLite remains current runtime persistence. PostgreSQL remains a separate migration gate.
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"schema": "aios.source-registry-reconciliation.v1",
|
||||||
|
"version": "V2R2C1",
|
||||||
|
"root_cause": "PROMOTION_STATE_MACHINE_FINALIZED_BEFORE_SOURCE_REGISTRY_CONVERGENCE",
|
||||||
|
"main_before": "3244f0f17f8845592ac94d585f5ebd65693c9f29",
|
||||||
|
"tag_oid": "07fc2746b688f5f243f29b39de39f32edc3dd9d2",
|
||||||
|
"target_oid": "998d30304787e31e5ae607c0bd86b5edb2b0e5ad",
|
||||||
|
"projection_sha256": "924dfc0ec58afc4e31638f6538e551c5bc8858f804cdbee5af7dce6eba7c90fe",
|
||||||
|
"evidence_closure_oid": "a8b906d1bc24088c19647e98de6a679268f6d0a2",
|
||||||
|
"approval_plan_id": "sha256:c0df52008ae7f64cec5ee2d28947aa7b892066c670ab8295e4549140b1a7500e",
|
||||||
|
"remote_tag_verified": true,
|
||||||
|
"remote_target_verified": true,
|
||||||
|
"gitea_api_verified_by_owner_acceptance": true,
|
||||||
|
"source_entry_before": "missing",
|
||||||
|
"source_entry_added": "versions/V2R2C1.json",
|
||||||
|
"source_entry_is_exact_attested_projection": true,
|
||||||
|
"release_manifest_added": false,
|
||||||
|
"release_manifest_reason": "Historical release-manifest schema differs; no canonical V2R2C1 source-manifest generator was proven. Do not synthesize one by copying old release values.",
|
||||||
|
"runtime_backing": "immutable WS/versions bundle",
|
||||||
|
"runtime_mutated": false,
|
||||||
|
"tag_rewritten": false,
|
||||||
|
"baseline_modified": false,
|
||||||
|
"production_modified": false
|
||||||
|
}
|
||||||
@@ -48,8 +48,3 @@ class MessageBus:
|
|||||||
messages = list(self.queue)
|
messages = list(self.queue)
|
||||||
self.queue.clear()
|
self.queue.clear()
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
# R4.2 P0-B compatibility adapter.
|
|
||||||
def to_handoff_envelope(message, *, target=None, sequence=1, idempotency_key=None, **scope):
|
|
||||||
from message_handoff_adapters import legacy_message_to_envelope
|
|
||||||
return legacy_message_to_envelope(message, target=target, sequence=sequence, idempotency_key=idempotency_key, **scope)
|
|
||||||
|
|||||||
@@ -32,18 +32,36 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
parser.add_argument("--tree-oid", required=True)
|
parser.add_argument("--tree-oid", required=True)
|
||||||
parser.add_argument("--output-directory", required=True, type=Path)
|
parser.add_argument("--output-directory", required=True, type=Path)
|
||||||
parser.add_argument("--name", default="aios-autonomous-release-candidate")
|
parser.add_argument("--name", default="aios-autonomous-release-candidate")
|
||||||
|
parser.add_argument("--release-version")
|
||||||
|
parser.add_argument("--release-binding-json", type=Path)
|
||||||
|
parser.add_argument("--artifact-mode", choices=("release", "general"))
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
args = build_parser().parse_args(argv)
|
args = build_parser().parse_args(argv)
|
||||||
|
release_binding = (
|
||||||
|
json.loads(args.release_binding_json.read_text(encoding="utf-8"))
|
||||||
|
if args.release_binding_json is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if release_binding is not None and not isinstance(release_binding, dict):
|
||||||
|
raise ValueError("release binding JSON must be an object")
|
||||||
output = args.output_directory
|
output = args.output_directory
|
||||||
output.mkdir(parents=True, exist_ok=True, mode=0o750)
|
output.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||||
if output.is_symlink():
|
if output.is_symlink():
|
||||||
raise RuntimeError("artifact output directory symlink rejected")
|
raise RuntimeError("artifact output directory symlink rejected")
|
||||||
os.chmod(output, output.stat().st_mode & 0o750)
|
os.chmod(output, output.stat().st_mode & 0o750)
|
||||||
builder = ArtifactBuilder()
|
builder = ArtifactBuilder()
|
||||||
built = builder.build_from_directory(args.source_oid, args.tree_oid, args.source)
|
built = builder.build_from_directory(
|
||||||
|
args.source_oid,
|
||||||
|
args.tree_oid,
|
||||||
|
args.source,
|
||||||
|
release_version=args.release_version,
|
||||||
|
release_binding=release_binding,
|
||||||
|
release_mode=args.release_version is not None,
|
||||||
|
artifact_mode=args.artifact_mode,
|
||||||
|
)
|
||||||
builder.verify(built)
|
builder.verify(built)
|
||||||
artifact_path = output / f"{args.name}.tar"
|
artifact_path = output / f"{args.name}.tar"
|
||||||
manifest_path = output / f"{args.name}.manifest.json"
|
manifest_path = output / f"{args.name}.manifest.json"
|
||||||
|
|||||||
Executable
+124
@@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate one canonical source entry for a governed non-main change.
|
||||||
|
|
||||||
|
This utility only creates the file in the caller's worktree. It never commits,
|
||||||
|
pushes, changes main, rewrites a tag, or updates a production projection. The
|
||||||
|
caller must submit the resulting file through the existing governed writer and
|
||||||
|
approval flow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
APP = Path(__file__).resolve().parents[1] / "active-runtime" / "app"
|
||||||
|
sys.path.insert(0, str(APP))
|
||||||
|
|
||||||
|
from version_registry import ( # noqa: E402
|
||||||
|
build_source_registry_entry,
|
||||||
|
configured_authoritative_git_dir,
|
||||||
|
configured_repository_identity,
|
||||||
|
source_registry_json,
|
||||||
|
validate_source_registry_record,
|
||||||
|
verify_approval_authority,
|
||||||
|
verify_authoritative_repository,
|
||||||
|
verify_source_registry_git_identity,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parser() -> argparse.ArgumentParser:
|
||||||
|
value = argparse.ArgumentParser()
|
||||||
|
value.add_argument("--output", required=True, type=Path)
|
||||||
|
value.add_argument("--version", required=True)
|
||||||
|
value.add_argument("--tag-oid", required=True)
|
||||||
|
value.add_argument("--target-oid", required=True)
|
||||||
|
value.add_argument("--peeled-target-oid", required=True)
|
||||||
|
value.add_argument("--evidence-closure-oid", required=True)
|
||||||
|
value.add_argument("--approval-plan-id", required=True)
|
||||||
|
value.add_argument(
|
||||||
|
"--repository",
|
||||||
|
type=Path,
|
||||||
|
default=configured_authoritative_git_dir(),
|
||||||
|
help="authoritative bare repository; required for release generation",
|
||||||
|
)
|
||||||
|
value.add_argument(
|
||||||
|
"--approval-authority",
|
||||||
|
type=Path,
|
||||||
|
default=(
|
||||||
|
Path(os.environ["AIOS_GITOPS_APPROVAL_AUTHORITY"])
|
||||||
|
if os.environ.get("AIOS_GITOPS_APPROVAL_AUTHORITY")
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
help="canonical approval/publication receipt for this release request",
|
||||||
|
)
|
||||||
|
value.add_argument(
|
||||||
|
"--repository-identity",
|
||||||
|
default=configured_repository_identity(),
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parser().parse_args()
|
||||||
|
if args.approval_authority is None:
|
||||||
|
raise ValueError("canonical approval authority is required")
|
||||||
|
verify_authoritative_repository(
|
||||||
|
args.repository,
|
||||||
|
repository_identity=args.repository_identity,
|
||||||
|
)
|
||||||
|
tag_oid = args.tag_oid.lower()
|
||||||
|
target_oid = args.target_oid.lower()
|
||||||
|
peeled_target_oid = args.peeled_target_oid.lower()
|
||||||
|
evidence_closure_oid = args.evidence_closure_oid.lower()
|
||||||
|
expected = build_source_registry_entry(
|
||||||
|
version=args.version,
|
||||||
|
tag_oid=tag_oid,
|
||||||
|
target_oid=target_oid,
|
||||||
|
peeled_target_oid=peeled_target_oid,
|
||||||
|
evidence_closure_oid=evidence_closure_oid,
|
||||||
|
approval_plan_id=args.approval_plan_id,
|
||||||
|
repository=args.repository_identity,
|
||||||
|
)
|
||||||
|
verify_source_registry_git_identity(args.repository, expected)
|
||||||
|
verify_approval_authority(args.approval_authority, expected)
|
||||||
|
destination = args.output.resolve()
|
||||||
|
if destination.name != f"{args.version}.json" or destination.parent.name != "versions":
|
||||||
|
raise ValueError("output must be versions/<version>.json")
|
||||||
|
payload = source_registry_json(expected)
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if destination.is_symlink():
|
||||||
|
raise ValueError("existing source registry path must not be a symlink")
|
||||||
|
if destination.exists() and not destination.is_file():
|
||||||
|
raise ValueError("existing source registry path must be a regular file")
|
||||||
|
if destination.exists():
|
||||||
|
current = json.loads(destination.read_text(encoding="utf-8"))
|
||||||
|
validate_source_registry_record(
|
||||||
|
current,
|
||||||
|
expected_version=args.version,
|
||||||
|
expected_tag_oid=tag_oid,
|
||||||
|
expected_target_oid=target_oid,
|
||||||
|
expected_evidence_closure_oid=evidence_closure_oid,
|
||||||
|
expected_approval_plan_id=args.approval_plan_id,
|
||||||
|
)
|
||||||
|
print(json.dumps({"status": "EXISTING_IDENTITY_VERIFIED", "path": str(destination)}))
|
||||||
|
return 0
|
||||||
|
descriptor = os.open(
|
||||||
|
destination,
|
||||||
|
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
|
||||||
|
0o640,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
os.write(descriptor, payload)
|
||||||
|
os.fsync(descriptor)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
print(json.dumps({"status": "GENERATED", "path": str(destination)}))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+200
-1
@@ -8,6 +8,7 @@ import io
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
|
import re
|
||||||
import stat
|
import stat
|
||||||
import tarfile
|
import tarfile
|
||||||
from typing import Mapping
|
from typing import Mapping
|
||||||
@@ -75,6 +76,30 @@ class BuiltArtifact:
|
|||||||
manifest: ArtifactManifest
|
manifest: ArtifactManifest
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReleaseIdentityBinding:
|
||||||
|
"""Immutable release identity required for a release artifact build."""
|
||||||
|
|
||||||
|
version: str
|
||||||
|
repository: str
|
||||||
|
tag_oid: str
|
||||||
|
target_oid: str
|
||||||
|
evidence_closure_oid: str
|
||||||
|
approval_plan_id: str
|
||||||
|
peeled_target_oid: str | None = None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"version": self.version,
|
||||||
|
"repository": self.repository,
|
||||||
|
"tag_oid": self.tag_oid,
|
||||||
|
"target_oid": self.target_oid,
|
||||||
|
"peeled_target_oid": self.peeled_target_oid or self.target_oid,
|
||||||
|
"evidence_closure_oid": self.evidence_closure_oid,
|
||||||
|
"approval_plan_id": self.approval_plan_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ArtifactBuilder:
|
class ArtifactBuilder:
|
||||||
MAX_FILE_BYTES = 8 * 1024 * 1024
|
MAX_FILE_BYTES = 8 * 1024 * 1024
|
||||||
MAX_ARTIFACT_BYTES = 64 * 1024 * 1024
|
MAX_ARTIFACT_BYTES = 64 * 1024 * 1024
|
||||||
@@ -92,14 +117,188 @@ class ArtifactBuilder:
|
|||||||
if not is_git_oid(value):
|
if not is_git_oid(value):
|
||||||
raise ValueError(f"{name} must be a full lowercase Git OID")
|
raise ValueError(f"{name} must be a full lowercase Git OID")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _binding_dict(
|
||||||
|
binding: ReleaseIdentityBinding | Mapping[str, str],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
if isinstance(binding, ReleaseIdentityBinding):
|
||||||
|
return binding.as_dict()
|
||||||
|
values = {str(key): str(value) for key, value in binding.items()}
|
||||||
|
values.setdefault("peeled_target_oid", values.get("target_oid", ""))
|
||||||
|
return values
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _validate_source_registry(
|
||||||
|
cls,
|
||||||
|
root: Path,
|
||||||
|
release_version: str,
|
||||||
|
release_binding: ReleaseIdentityBinding | Mapping[str, str] | None = None,
|
||||||
|
*,
|
||||||
|
release_mode: bool = False,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
if not re.fullmatch(r"V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?", release_version):
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_SOURCE_REGISTRY_INVALID", "release version is not canonical"
|
||||||
|
)
|
||||||
|
path = root / "versions" / f"{release_version}.json"
|
||||||
|
if path.is_symlink() or not path.is_file():
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_SOURCE_REGISTRY_MISSING",
|
||||||
|
f"artifact source is missing versions/{release_version}.json",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_SOURCE_REGISTRY_INVALID",
|
||||||
|
f"artifact source registry cannot be read: {path}",
|
||||||
|
) from exc
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_SOURCE_REGISTRY_INVALID", "source registry is not an object"
|
||||||
|
)
|
||||||
|
if value.get("version") != release_version or value.get("repository") != "aios/aios":
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_SOURCE_REGISTRY_IDENTITY_MISMATCH",
|
||||||
|
"source registry version or repository does not match the artifact",
|
||||||
|
)
|
||||||
|
if value.get("schema") not in {
|
||||||
|
"aios.source-registry.v1",
|
||||||
|
"aios.production-registry.v2",
|
||||||
|
}:
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_SOURCE_REGISTRY_INVALID", "source registry schema is invalid"
|
||||||
|
)
|
||||||
|
for field in ("tag_oid", "target_oid", "peeled_target_oid", "evidence_closure_oid"):
|
||||||
|
item = value.get(field)
|
||||||
|
if not isinstance(item, str) or not re.fullmatch(r"[0-9a-f]{40}", item):
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_SOURCE_REGISTRY_INVALID",
|
||||||
|
f"source registry field is invalid: {field}",
|
||||||
|
)
|
||||||
|
if value["peeled_target_oid"] != value["target_oid"]:
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_SOURCE_REGISTRY_IDENTITY_MISMATCH",
|
||||||
|
"source registry peeled target differs from target",
|
||||||
|
)
|
||||||
|
if not isinstance(value.get("approval_plan_id"), str) or not re.fullmatch(
|
||||||
|
r"sha256:[0-9a-f]{64}", value["approval_plan_id"]
|
||||||
|
):
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_SOURCE_REGISTRY_INVALID", "source registry approval plan is invalid"
|
||||||
|
)
|
||||||
|
if release_mode and release_binding is None:
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_RELEASE_BINDING_REQUIRED",
|
||||||
|
"release artifact builds require an expected release identity",
|
||||||
|
)
|
||||||
|
if release_binding is not None:
|
||||||
|
expected = cls._binding_dict(release_binding)
|
||||||
|
required = (
|
||||||
|
"version",
|
||||||
|
"repository",
|
||||||
|
"tag_oid",
|
||||||
|
"target_oid",
|
||||||
|
"peeled_target_oid",
|
||||||
|
"evidence_closure_oid",
|
||||||
|
"approval_plan_id",
|
||||||
|
)
|
||||||
|
if any(not expected.get(name) for name in required):
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_RELEASE_BINDING_INVALID",
|
||||||
|
"expected release identity is incomplete",
|
||||||
|
)
|
||||||
|
for name in ("tag_oid", "target_oid", "peeled_target_oid", "evidence_closure_oid"):
|
||||||
|
if not re.fullmatch(r"[0-9a-f]{40}", expected[name]):
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_RELEASE_BINDING_INVALID",
|
||||||
|
f"expected release identity field is invalid: {name}",
|
||||||
|
)
|
||||||
|
if not re.fullmatch(r"sha256:[0-9a-f]{64}", expected["approval_plan_id"]):
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_RELEASE_BINDING_INVALID",
|
||||||
|
"expected release identity approval plan is invalid",
|
||||||
|
)
|
||||||
|
for name in required:
|
||||||
|
if value.get(name) != expected[name]:
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_SOURCE_REGISTRY_IDENTITY_MISMATCH",
|
||||||
|
f"source registry does not match expected release identity: {name}",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"version": str(value["version"]),
|
||||||
|
"repository": str(value["repository"]),
|
||||||
|
"tag_oid": str(value["tag_oid"]),
|
||||||
|
"target_oid": str(value["target_oid"]),
|
||||||
|
"peeled_target_oid": str(value["peeled_target_oid"]),
|
||||||
|
"evidence_closure_oid": str(value["evidence_closure_oid"]),
|
||||||
|
"approval_plan_id": str(value["approval_plan_id"]),
|
||||||
|
}
|
||||||
|
|
||||||
def build_from_directory(
|
def build_from_directory(
|
||||||
self, source_oid: str, tree_oid: str, source: str | Path
|
self,
|
||||||
|
source_oid: str,
|
||||||
|
tree_oid: str,
|
||||||
|
source: str | Path,
|
||||||
|
release_version: str | None = None,
|
||||||
|
release_binding: ReleaseIdentityBinding | Mapping[str, str] | None = None,
|
||||||
|
release_mode: bool | None = None,
|
||||||
|
artifact_mode: str | None = None,
|
||||||
) -> BuiltArtifact:
|
) -> BuiltArtifact:
|
||||||
root = Path(source)
|
root = Path(source)
|
||||||
if root.is_symlink() or not root.is_dir():
|
if root.is_symlink() or not root.is_dir():
|
||||||
raise ReleaseBlocked(
|
raise ReleaseBlocked(
|
||||||
"ARTIFACT_SOURCE_LINK_REJECTED", "artifact source must be a real directory"
|
"ARTIFACT_SOURCE_LINK_REJECTED", "artifact source must be a real directory"
|
||||||
)
|
)
|
||||||
|
if artifact_mode not in {None, "release", "general"}:
|
||||||
|
raise ValueError("artifact_mode must be release, general, or omitted")
|
||||||
|
if artifact_mode == "release" and release_mode is False:
|
||||||
|
raise ValueError("artifact_mode=release conflicts with release_mode=False")
|
||||||
|
if artifact_mode == "general" and release_mode is True:
|
||||||
|
raise ValueError("artifact_mode=general conflicts with release_mode=True")
|
||||||
|
|
||||||
|
explicit_release_version = release_version is not None
|
||||||
|
detected_canonical_version = False
|
||||||
|
if release_version is None:
|
||||||
|
version_file = root / "VERSION"
|
||||||
|
if version_file.is_file() and not version_file.is_symlink():
|
||||||
|
release_version = version_file.read_text(encoding="utf-8").strip()
|
||||||
|
detected_canonical_version = bool(
|
||||||
|
re.fullmatch(r"V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?", release_version)
|
||||||
|
)
|
||||||
|
elif re.fullmatch(r"V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?", release_version):
|
||||||
|
detected_canonical_version = True
|
||||||
|
|
||||||
|
binding_expected: dict[str, str] | None = None
|
||||||
|
if release_binding is not None:
|
||||||
|
binding_expected = self._binding_dict(release_binding)
|
||||||
|
if not binding_expected.get("version"):
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_RELEASE_BINDING_INVALID",
|
||||||
|
"release identity binding must include version",
|
||||||
|
)
|
||||||
|
release_version = release_version or binding_expected["version"]
|
||||||
|
|
||||||
|
release_requested = bool(
|
||||||
|
explicit_release_version
|
||||||
|
or detected_canonical_version
|
||||||
|
or release_binding is not None
|
||||||
|
or release_mode is True
|
||||||
|
or artifact_mode == "release"
|
||||||
|
)
|
||||||
|
if artifact_mode == "general" and release_requested:
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_GENERAL_MODE_RELEASE_VERSION",
|
||||||
|
"general artifacts cannot contain canonical release metadata",
|
||||||
|
)
|
||||||
|
if release_requested:
|
||||||
|
release_mode = True
|
||||||
|
self._validate_source_registry(
|
||||||
|
root,
|
||||||
|
release_version or "",
|
||||||
|
release_binding,
|
||||||
|
release_mode=True,
|
||||||
|
)
|
||||||
files: list[ArtifactFile] = []
|
files: list[ArtifactFile] = []
|
||||||
for directory, dirnames, filenames in os.walk(root, followlinks=False):
|
for directory, dirnames, filenames in os.walk(root, followlinks=False):
|
||||||
current = Path(directory)
|
current = Path(directory)
|
||||||
|
|||||||
+17
-1
@@ -61,6 +61,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
artifact_build.add_argument("--source-oid", required=True)
|
artifact_build.add_argument("--source-oid", required=True)
|
||||||
artifact_build.add_argument("--tree-oid", required=True)
|
artifact_build.add_argument("--tree-oid", required=True)
|
||||||
artifact_build.add_argument("--output-directory", required=True, type=Path)
|
artifact_build.add_argument("--output-directory", required=True, type=Path)
|
||||||
|
artifact_build.add_argument("--release-version")
|
||||||
|
artifact_build.add_argument("--release-binding-json", type=Path)
|
||||||
|
artifact_build.add_argument("--artifact-mode", choices=("release", "general"))
|
||||||
artifact_verify = artifact_sub.add_parser("verify")
|
artifact_verify = artifact_sub.add_parser("verify")
|
||||||
artifact_verify.add_argument("--artifact", required=True, type=Path)
|
artifact_verify.add_argument("--artifact", required=True, type=Path)
|
||||||
artifact_verify.add_argument("--manifest", required=True, type=Path)
|
artifact_verify.add_argument("--manifest", required=True, type=Path)
|
||||||
@@ -145,7 +148,20 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
if args.command == "artifact":
|
if args.command == "artifact":
|
||||||
builder = ArtifactBuilder()
|
builder = ArtifactBuilder()
|
||||||
if args.artifact_command == "build":
|
if args.artifact_command == "build":
|
||||||
built = builder.build_from_directory(args.source_oid, args.tree_oid, args.source)
|
release_binding = (
|
||||||
|
load_json(args.release_binding_json)
|
||||||
|
if args.release_binding_json is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
built = builder.build_from_directory(
|
||||||
|
args.source_oid,
|
||||||
|
args.tree_oid,
|
||||||
|
args.source,
|
||||||
|
release_version=args.release_version,
|
||||||
|
release_binding=release_binding,
|
||||||
|
release_mode=args.release_version is not None,
|
||||||
|
artifact_mode=args.artifact_mode,
|
||||||
|
)
|
||||||
builder.verify(built)
|
builder.verify(built)
|
||||||
artifact_path = args.output_directory / "candidate.tar"
|
artifact_path = args.output_directory / "candidate.tar"
|
||||||
manifest_path = args.output_directory / "manifest.json"
|
manifest_path = args.output_directory / "manifest.json"
|
||||||
|
|||||||
@@ -1,11 +1,35 @@
|
|||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from aios_autonomous_release_candidate.artifact import ArtifactBuilder, ArtifactFile, BuiltArtifact
|
from aios_autonomous_release_candidate.artifact import (
|
||||||
|
ArtifactBuilder,
|
||||||
|
ArtifactFile,
|
||||||
|
BuiltArtifact,
|
||||||
|
ReleaseIdentityBinding,
|
||||||
|
)
|
||||||
from aios_autonomous_release_candidate.errors import EvidenceIncomplete, ReleaseBlocked
|
from aios_autonomous_release_candidate.errors import EvidenceIncomplete, ReleaseBlocked
|
||||||
|
|
||||||
|
|
||||||
|
def _source_registry(root: Path) -> None:
|
||||||
|
target = "2" * 40
|
||||||
|
value = {
|
||||||
|
"schema": "aios.source-registry.v1",
|
||||||
|
"version": "V2R2C1",
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"tag_oid": "1" * 40,
|
||||||
|
"tag_object": "1" * 40,
|
||||||
|
"target_oid": target,
|
||||||
|
"peeled_target_oid": target,
|
||||||
|
"evidence_closure_oid": "3" * 40,
|
||||||
|
"approval_plan_id": "sha256:" + "4" * 64,
|
||||||
|
}
|
||||||
|
path = root / "versions" / "V2R2C1.json"
|
||||||
|
path.parent.mkdir(parents=True)
|
||||||
|
path.write_text(json.dumps(value) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def test_artifact_is_deterministic_and_reverified() -> None:
|
def test_artifact_is_deterministic_and_reverified() -> None:
|
||||||
builder = ArtifactBuilder()
|
builder = ArtifactBuilder()
|
||||||
files = (
|
files = (
|
||||||
@@ -32,6 +56,143 @@ def test_source_tree_rejects_symlink_and_hardlink(tmp_path: Path) -> None:
|
|||||||
ArtifactBuilder().build_from_directory("a" * 40, "b" * 40, root)
|
ArtifactBuilder().build_from_directory("a" * 40, "b" * 40, root)
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_source_tree_requires_source_registry_entry(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "source"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "VERSION").write_text("V2R2C1\n", encoding="utf-8")
|
||||||
|
with pytest.raises(ReleaseBlocked, match="ARTIFACT_SOURCE_REGISTRY_MISSING"):
|
||||||
|
ArtifactBuilder().build_from_directory("a" * 40, "b" * 40, root)
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_version_without_binding_fails_closed(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "source"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "VERSION").write_text("V2R2C1\n", encoding="utf-8")
|
||||||
|
_source_registry(root)
|
||||||
|
with pytest.raises(ReleaseBlocked, match="ARTIFACT_RELEASE_BINDING_REQUIRED"):
|
||||||
|
ArtifactBuilder().build_from_directory("a" * 40, "b" * 40, root)
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_source_tree_requires_expected_release_identity(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "source"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "VERSION").write_text("V2R2C1\n", encoding="utf-8")
|
||||||
|
_source_registry(root)
|
||||||
|
binding = ReleaseIdentityBinding(
|
||||||
|
version="V2R2C1",
|
||||||
|
repository="aios/aios",
|
||||||
|
tag_oid="1" * 40,
|
||||||
|
target_oid="2" * 40,
|
||||||
|
evidence_closure_oid="3" * 40,
|
||||||
|
approval_plan_id="sha256:" + "4" * 64,
|
||||||
|
)
|
||||||
|
built = ArtifactBuilder().build_from_directory(
|
||||||
|
"a" * 40,
|
||||||
|
"b" * 40,
|
||||||
|
root,
|
||||||
|
release_binding=binding,
|
||||||
|
)
|
||||||
|
ArtifactBuilder().verify(built)
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_version_with_malformed_binding_fails_closed(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "source"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "VERSION").write_text("V2R2C1\n", encoding="utf-8")
|
||||||
|
_source_registry(root)
|
||||||
|
with pytest.raises(ReleaseBlocked, match="ARTIFACT_RELEASE_BINDING_INVALID"):
|
||||||
|
ArtifactBuilder().build_from_directory(
|
||||||
|
"a" * 40,
|
||||||
|
"b" * 40,
|
||||||
|
root,
|
||||||
|
release_binding={
|
||||||
|
"version": "V2R2C1",
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"tag_oid": "not-an-oid",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_general_artifact_without_release_version_passes(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "source"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "README.txt").write_text("general candidate\n", encoding="utf-8")
|
||||||
|
built = ArtifactBuilder().build_from_directory(
|
||||||
|
"a" * 40,
|
||||||
|
"b" * 40,
|
||||||
|
root,
|
||||||
|
artifact_mode="general",
|
||||||
|
)
|
||||||
|
assert "README.txt" in built.manifest.files
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_general_mode_rejects_canonical_version(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "source"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "VERSION").write_text("V2R2C1\n", encoding="utf-8")
|
||||||
|
_source_registry(root)
|
||||||
|
with pytest.raises(ReleaseBlocked, match="ARTIFACT_GENERAL_MODE_RELEASE_VERSION"):
|
||||||
|
ArtifactBuilder().build_from_directory(
|
||||||
|
"a" * 40,
|
||||||
|
"b" * 40,
|
||||||
|
root,
|
||||||
|
artifact_mode="general",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"field,wrong",
|
||||||
|
[
|
||||||
|
("repository", "other/repository"),
|
||||||
|
("version", "V2R2C2"),
|
||||||
|
("tag_oid", "9" * 40),
|
||||||
|
("target_oid", "8" * 40),
|
||||||
|
("peeled_target_oid", "7" * 40),
|
||||||
|
("evidence_closure_oid", "6" * 40),
|
||||||
|
("approval_plan_id", "sha256:" + "5" * 64),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_release_source_tree_rejects_every_identity_mismatch(
|
||||||
|
tmp_path: Path, field: str, wrong: str
|
||||||
|
) -> None:
|
||||||
|
root = tmp_path / "source"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "VERSION").write_text("V2R2C1\n", encoding="utf-8")
|
||||||
|
_source_registry(root)
|
||||||
|
binding = {
|
||||||
|
"version": "V2R2C1",
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"tag_oid": "1" * 40,
|
||||||
|
"target_oid": "2" * 40,
|
||||||
|
"peeled_target_oid": "2" * 40,
|
||||||
|
"evidence_closure_oid": "3" * 40,
|
||||||
|
"approval_plan_id": "sha256:" + "4" * 64,
|
||||||
|
}
|
||||||
|
binding[field] = wrong
|
||||||
|
with pytest.raises(ReleaseBlocked, match="ARTIFACT_SOURCE_REGISTRY"):
|
||||||
|
ArtifactBuilder().build_from_directory(
|
||||||
|
"a" * 40,
|
||||||
|
"b" * 40,
|
||||||
|
root,
|
||||||
|
release_binding=binding,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_mode_without_binding_fails_closed(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "source"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "VERSION").write_text("V2R2C1\n", encoding="utf-8")
|
||||||
|
_source_registry(root)
|
||||||
|
with pytest.raises(ReleaseBlocked, match="ARTIFACT_RELEASE_BINDING_REQUIRED"):
|
||||||
|
ArtifactBuilder().build_from_directory(
|
||||||
|
"a" * 40,
|
||||||
|
"b" * 40,
|
||||||
|
root,
|
||||||
|
release_version="V2R2C1",
|
||||||
|
release_mode=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_artifact_rejects_secret_markers() -> None:
|
def test_artifact_rejects_secret_markers() -> None:
|
||||||
with pytest.raises(ReleaseBlocked, match="ARTIFACT_SECRET_MARKER"):
|
with pytest.raises(ReleaseBlocked, match="ARTIFACT_SECRET_MARKER"):
|
||||||
ArtifactBuilder().build(
|
ArtifactBuilder().build(
|
||||||
|
|||||||
@@ -45,9 +45,6 @@ class ShadowClientRBACPublicInterfaceTests(unittest.TestCase):
|
|||||||
identity_verifier=self.IdentityVerifier({"shadow-client"}),
|
identity_verifier=self.IdentityVerifier({"shadow-client"}),
|
||||||
execution_enabled=False,
|
execution_enabled=False,
|
||||||
identity_capabilities={"shadow-client": set(capabilities)},
|
identity_capabilities={"shadow-client": set(capabilities)},
|
||||||
identity_actor_contexts={
|
|
||||||
"shadow-client": {"scope_type": "system"}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
service = self.TaskPlaneService(store, policy=policy)
|
service = self.TaskPlaneService(store, policy=policy)
|
||||||
|
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[3]
|
|
||||||
ACTIVE_RUNTIME = ROOT / "active-runtime"
|
|
||||||
if str(ACTIVE_RUNTIME) not in sys.path:
|
|
||||||
sys.path.insert(0, str(ACTIVE_RUNTIME))
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
import threading
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.task_plane.fleet import FleetConflict, FleetScheduler
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Plan:
|
|
||||||
task_id: str
|
|
||||||
required_capabilities: list[str] = field(default_factory=lambda:["code.read"])
|
|
||||||
metadata: dict = field(default_factory=dict)
|
|
||||||
scope_type: str = "tenant"
|
|
||||||
tenant_id: str = "tenant-a"
|
|
||||||
project_id: str | None = "project-a"
|
|
||||||
user_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Task:
|
|
||||||
task_id: str
|
|
||||||
revision: int = 1
|
|
||||||
status: str = "QUEUED"
|
|
||||||
created_at: float = 1.0
|
|
||||||
task_plan: Plan | None = None
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
if self.task_plan is None:
|
|
||||||
self.task_plan=Plan(self.task_id)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Worker:
|
|
||||||
worker_id: str
|
|
||||||
capabilities: list[str]=field(default_factory=lambda:["code.read"])
|
|
||||||
|
|
||||||
|
|
||||||
class Store:
|
|
||||||
def __init__(self, path=":memory:"):
|
|
||||||
self.conn=sqlite3.connect(path,check_same_thread=False)
|
|
||||||
self._lock=threading.RLock()
|
|
||||||
self.conn.executescript("""
|
|
||||||
CREATE TABLE IF NOT EXISTS claims(
|
|
||||||
claim_id TEXT PRIMARY KEY,task_id TEXT,worker_id TEXT,status TEXT,lease_expires_at REAL
|
|
||||||
);
|
|
||||||
""")
|
|
||||||
self.tasks=[]
|
|
||||||
self.workers={}
|
|
||||||
|
|
||||||
def list_tasks(self,_ctx=None):
|
|
||||||
return list(self.tasks)
|
|
||||||
|
|
||||||
def get_worker(self,worker_id):
|
|
||||||
return self.workers[worker_id]
|
|
||||||
|
|
||||||
|
|
||||||
class Registry:
|
|
||||||
def __init__(self):
|
|
||||||
self.online=set()
|
|
||||||
def is_online(self,worker_id,now=None):
|
|
||||||
return worker_id in self.online
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fleet():
|
|
||||||
store=Store(); registry=Registry()
|
|
||||||
for wid in ("w-a","w-b","w-c"):
|
|
||||||
store.workers[wid]=Worker(wid)
|
|
||||||
registry.online.add(wid)
|
|
||||||
scheduler=FleetScheduler(store,registry,now_fn=lambda:1000.0)
|
|
||||||
scheduler.configure_worker("w-a",max_slots=1,failure_domain="zone-a",weight=1)
|
|
||||||
scheduler.configure_worker("w-b",max_slots=2,failure_domain="zone-b",weight=1)
|
|
||||||
scheduler.configure_worker("w-c",max_slots=1,failure_domain="zone-c",weight=2)
|
|
||||||
return store,registry,scheduler
|
|
||||||
|
|
||||||
|
|
||||||
def test_FLEET_AGENT_REGISTRATION_capacity_policy_is_durable(fleet):
|
|
||||||
store,_,scheduler=fleet
|
|
||||||
policy=scheduler.state.worker_policy("w-b")
|
|
||||||
assert policy.max_slots==2 and policy.failure_domain=="zone-b"
|
|
||||||
reloaded=type(scheduler)(store, scheduler.workers, now_fn=lambda:1000.0)
|
|
||||||
assert reloaded.state.worker_policy("w-b").max_slots==2
|
|
||||||
|
|
||||||
|
|
||||||
def test_FLEET_CAPACITY_SCHEDULING_does_not_oversubscribe_worker(fleet):
|
|
||||||
store,_,scheduler=fleet
|
|
||||||
store.tasks=[Task("task-1"),Task("task-2"),Task("task-3")]
|
|
||||||
decisions=scheduler.select(["w-a"],limit=3,now=1000.0)
|
|
||||||
assert [d.worker_id for d in decisions]==["w-a"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_FLEET_CAPACITY_SCHEDULING_accounts_for_active_claims(fleet):
|
|
||||||
store,_,scheduler=fleet
|
|
||||||
store.conn.execute(
|
|
||||||
"INSERT INTO claims VALUES ('claim-1','other','w-a','ACTIVE',2000)"
|
|
||||||
)
|
|
||||||
store.tasks=[Task("task-1")]
|
|
||||||
assert scheduler.select(["w-a"],limit=1,now=1000.0)==[]
|
|
||||||
|
|
||||||
|
|
||||||
def test_FLEET_FAIRNESS_scope_virtual_finish_prevents_hot_scope_monopoly(fleet):
|
|
||||||
store,_,scheduler=fleet
|
|
||||||
store.tasks=[
|
|
||||||
Task("a1",created_at=1,task_plan=Plan("a1",tenant_id="tenant-a",project_id="p-a")),
|
|
||||||
Task("a2",created_at=2,task_plan=Plan("a2",tenant_id="tenant-a",project_id="p-a")),
|
|
||||||
Task("b1",created_at=3,task_plan=Plan("b1",tenant_id="tenant-b",project_id="p-b")),
|
|
||||||
]
|
|
||||||
first=scheduler.select(["w-b"],limit=1,now=1000.0)[0]
|
|
||||||
assert first.task_id=="a1"
|
|
||||||
scheduler.state.release("a1",state="COMPLETED",now=1000.1)
|
|
||||||
store.tasks=[task for task in store.tasks if task.task_id!="a1"]
|
|
||||||
second=scheduler.select(["w-b"],limit=1,now=1000.2)[0]
|
|
||||||
assert second.task_id=="b1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_FLEET_FAULT_ISOLATION_opens_worker_circuit_after_threshold(fleet):
|
|
||||||
store,_,scheduler=fleet
|
|
||||||
store.tasks=[Task("task-1")]
|
|
||||||
for _ in range(3):
|
|
||||||
scheduler.state.record_worker_failure("w-a","runtime_crash",now=1000.0)
|
|
||||||
assert scheduler.state.worker_circuit_open("w-a",1001.0)
|
|
||||||
assert scheduler.select(["w-a"],limit=1,now=1001.0)==[]
|
|
||||||
|
|
||||||
|
|
||||||
def test_FLEET_WORKER_REASSIGNMENT_avoids_quarantined_worker(fleet):
|
|
||||||
store,_,scheduler=fleet
|
|
||||||
store.tasks=[Task("task-1")]
|
|
||||||
scheduler.state.record_task_worker_failure(
|
|
||||||
"task-1","w-a","lease_expired",quarantine_seconds=60,now=1000.0
|
|
||||||
)
|
|
||||||
decision=scheduler.select(["w-a","w-b"],limit=1,now=1001.0)[0]
|
|
||||||
assert decision.worker_id=="w-b"
|
|
||||||
|
|
||||||
|
|
||||||
def test_FLEET_SCOPE_ISOLATION_uses_tenant_project_fairness_keys(fleet):
|
|
||||||
store,_,scheduler=fleet
|
|
||||||
store.tasks=[
|
|
||||||
Task("a",task_plan=Plan("a",tenant_id="tenant-a",project_id="p")),
|
|
||||||
Task("b",task_plan=Plan("b",tenant_id="tenant-b",project_id="p")),
|
|
||||||
]
|
|
||||||
decisions=scheduler.select(["w-b"],limit=2,now=1000.0)
|
|
||||||
assert {d.scope_key for d in decisions}=={
|
|
||||||
"tenant:tenant-a|project:p","tenant:tenant-b|project:p"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_FLEET_IDEMPOTENT_DISPATCH_replay_same_task_worker(fleet):
|
|
||||||
store,_,scheduler=fleet
|
|
||||||
store.tasks=[Task("task-1")]
|
|
||||||
one=scheduler.select(["w-b"],limit=1,now=1000.0)[0]
|
|
||||||
# Reservation remains authoritative; a conflicting second worker cannot steal it.
|
|
||||||
with pytest.raises(FleetConflict):
|
|
||||||
scheduler.state.reserve(
|
|
||||||
task_id="task-1",worker_id="w-c",task_revision=1,
|
|
||||||
scope_key=one.scope_key,failure_domain="zone-c",score=one.score,
|
|
||||||
reservation_seconds=30,now=1001.0,scope_weight=1.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_FLEET_FAILURE_RECOVERY_worker_success_closes_circuit(fleet):
|
|
||||||
store,_,scheduler=fleet
|
|
||||||
for _ in range(3):
|
|
||||||
scheduler.state.record_worker_failure("w-a","runtime_crash",now=1000.0)
|
|
||||||
scheduler.state.record_worker_success("w-a",now=1001.0)
|
|
||||||
assert not scheduler.state.worker_circuit_open("w-a",1001.1)
|
|
||||||
|
|
||||||
|
|
||||||
def test_affinity_and_failure_domain_anti_affinity(fleet):
|
|
||||||
store,_,scheduler=fleet
|
|
||||||
scheduler.configure_worker("w-b",max_slots=2,failure_domain="zone-b",labels={"gpu":"false"})
|
|
||||||
scheduler.configure_worker("w-c",max_slots=1,failure_domain="zone-c",labels={"gpu":"true"})
|
|
||||||
task=Task("gpu-task")
|
|
||||||
task.task_plan.metadata={
|
|
||||||
"fleet_affinity_labels":{"gpu":"true"},
|
|
||||||
"fleet_avoid_failure_domains":["zone-b"],
|
|
||||||
}
|
|
||||||
store.tasks=[task]
|
|
||||||
decision=scheduler.select(["w-b","w-c"],limit=1,now=1000.0)[0]
|
|
||||||
assert decision.worker_id=="w-c"
|
|
||||||
|
|
||||||
|
|
||||||
def test_FLEET_FAILURE_RECOVERY_state_survives_sqlite_restart(tmp_path):
|
|
||||||
path=tmp_path/"task-plane.sqlite3"
|
|
||||||
first=Store(str(path)); registry=Registry()
|
|
||||||
first.workers["w-a"]=Worker("w-a"); registry.online.add("w-a")
|
|
||||||
scheduler=FleetScheduler(first,registry,now_fn=lambda:1000.0)
|
|
||||||
scheduler.configure_worker("w-a",max_slots=3,failure_domain="zone-a")
|
|
||||||
scheduler.state.record_worker_failure("w-a","runtime_crash",threshold=1,now=1000.0)
|
|
||||||
first.conn.close()
|
|
||||||
|
|
||||||
second=Store(str(path)); second.workers["w-a"]=Worker("w-a")
|
|
||||||
resumed=FleetScheduler(second,registry,now_fn=lambda:1001.0)
|
|
||||||
assert resumed.state.worker_policy("w-a").max_slots==3
|
|
||||||
assert resumed.state.worker_circuit_open("w-a",1001.0)
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[3]
|
|
||||||
ACTIVE_RUNTIME = ROOT / "active-runtime"
|
|
||||||
if str(ACTIVE_RUNTIME) not in sys.path:
|
|
||||||
sys.path.insert(0, str(ACTIVE_RUNTIME))
|
|
||||||
|
|
||||||
from app.task_plane.scheduler import TaskScheduler
|
|
||||||
|
|
||||||
|
|
||||||
def _security_contract():
|
|
||||||
path = Path(__file__).with_name("test_task_plane_security_contract.py")
|
|
||||||
spec = importlib.util.spec_from_file_location("p2b_order_security_contract", path)
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
sys.modules[spec.name] = module
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
def _create_task(service, contract, task_id, tenant, project):
|
|
||||||
return service.create_task(
|
|
||||||
{
|
|
||||||
"task_id": task_id,
|
|
||||||
"issuer": {"type": "agent", "id": contract.ADMIN_IDENTITY},
|
|
||||||
"required_capabilities": ["code.read"],
|
|
||||||
"scope_type": "tenant",
|
|
||||||
"tenant_id": tenant,
|
|
||||||
"project_id": project,
|
|
||||||
"metadata": {"fleet_priority": 0},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _task_id(claim):
|
|
||||||
return getattr(
|
|
||||||
claim,
|
|
||||||
"task_id",
|
|
||||||
claim.get("task_id") if isinstance(claim, dict) else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_P2B_FLEET_ORDER_is_preserved_through_legacy_claim_oracle():
|
|
||||||
contract = _security_contract()
|
|
||||||
service = contract._service()
|
|
||||||
service.register_worker(contract._worker_payload(max_concurrency=3))
|
|
||||||
worker_id = contract.WORKER_ID
|
|
||||||
|
|
||||||
scheduler = TaskScheduler(service.store)
|
|
||||||
scheduler.fleet_scheduler.configure_worker(
|
|
||||||
worker_id, max_slots=3, failure_domain="zone-a"
|
|
||||||
)
|
|
||||||
|
|
||||||
_create_task(service, contract, "p2b-order-a1", "tenant-a", "project-a")
|
|
||||||
_create_task(service, contract, "p2b-order-a2", "tenant-a", "project-a")
|
|
||||||
_create_task(service, contract, "p2b-order-b1", "tenant-b", "project-b")
|
|
||||||
|
|
||||||
first = scheduler.schedule_for_worker(worker_id)
|
|
||||||
second = scheduler.schedule_for_worker(worker_id)
|
|
||||||
|
|
||||||
assert _task_id(first) == "p2b-order-a1"
|
|
||||||
assert _task_id(second) == "p2b-order-b1"
|
|
||||||
|
|
||||||
rows = service.store.conn.execute(
|
|
||||||
"SELECT scope_key,dispatch_count FROM fleet_scope_state ORDER BY scope_key"
|
|
||||||
).fetchall()
|
|
||||||
assert len(rows) >= 2
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
import importlib.util
|
|
||||||
from pathlib import Path
|
|
||||||
import sqlite3
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[3]
|
|
||||||
ACTIVE_RUNTIME = ROOT / "active-runtime"
|
|
||||||
if str(ACTIVE_RUNTIME) not in sys.path:
|
|
||||||
sys.path.insert(0, str(ACTIVE_RUNTIME))
|
|
||||||
|
|
||||||
from app.task_plane.fleet import FleetConflict, FleetScheduler
|
|
||||||
from app.task_plane.errors import NotFoundError
|
|
||||||
from app.task_plane.scheduler import TaskScheduler
|
|
||||||
|
|
||||||
|
|
||||||
def _security_contract():
|
|
||||||
path = Path(__file__).with_name("test_task_plane_security_contract.py")
|
|
||||||
spec = importlib.util.spec_from_file_location("p2b_security_contract", path)
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
sys.modules[spec.name] = module
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
def test_P2B_REAL_DISPATCH_claim_binds_fleet_reservation_and_circuit():
|
|
||||||
contract = _security_contract()
|
|
||||||
service = contract._service()
|
|
||||||
service.register_worker(contract._worker_payload(max_concurrency=2))
|
|
||||||
worker_id = contract.WORKER_ID
|
|
||||||
|
|
||||||
service.create_task(
|
|
||||||
{
|
|
||||||
"task_id": "task-p2b-real-1",
|
|
||||||
"issuer": {"type": "agent", "id": contract.ADMIN_IDENTITY},
|
|
||||||
"required_capabilities": ["code.read"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
scheduler = TaskScheduler(service.store)
|
|
||||||
scheduler.fleet_scheduler.configure_worker(
|
|
||||||
worker_id,
|
|
||||||
max_slots=2,
|
|
||||||
failure_domain="zone-real",
|
|
||||||
labels={"runtime": "task-plane"},
|
|
||||||
)
|
|
||||||
first = scheduler.schedule_for_worker(worker_id)
|
|
||||||
assert first is not None
|
|
||||||
assert first.task_id == "task-p2b-real-1"
|
|
||||||
|
|
||||||
row = service.store.conn.execute(
|
|
||||||
"SELECT state,claim_id,worker_id FROM fleet_dispatches WHERE task_id=?",
|
|
||||||
("task-p2b-real-1",),
|
|
||||||
).fetchone()
|
|
||||||
assert tuple(row) == ("CLAIMED", first.claim_id, worker_id)
|
|
||||||
|
|
||||||
# A real circuit blocks the actual scheduler even though one capacity slot remains.
|
|
||||||
failure_now = time.time()
|
|
||||||
scheduler.record_worker_failure(
|
|
||||||
worker_id, "runtime_crash", threshold=1, now=failure_now
|
|
||||||
)
|
|
||||||
service.create_task(
|
|
||||||
{
|
|
||||||
"task_id": "task-p2b-real-2",
|
|
||||||
"issuer": {"type": "agent", "id": contract.ADMIN_IDENTITY},
|
|
||||||
"required_capabilities": ["code.read"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
with pytest.raises(NotFoundError, match="no queued tasks available"):
|
|
||||||
scheduler.schedule_for_worker(worker_id)
|
|
||||||
|
|
||||||
scheduler.record_worker_success(worker_id, now=failure_now + 1.0)
|
|
||||||
second = scheduler.schedule_for_worker(worker_id)
|
|
||||||
assert second is not None
|
|
||||||
assert second.task_id == "task-p2b-real-2"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Plan:
|
|
||||||
task_id: str
|
|
||||||
required_capabilities: list[str] = field(default_factory=lambda: ["code.read"])
|
|
||||||
metadata: dict = field(default_factory=dict)
|
|
||||||
scope_type: str = "tenant"
|
|
||||||
tenant_id: str = "tenant-a"
|
|
||||||
project_id: str | None = "project-a"
|
|
||||||
user_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Task:
|
|
||||||
task_id: str
|
|
||||||
revision: int = 1
|
|
||||||
status: str = "QUEUED"
|
|
||||||
created_at: float = 1.0
|
|
||||||
task_plan: Plan | None = None
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
if self.task_plan is None:
|
|
||||||
self.task_plan = Plan(self.task_id)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Worker:
|
|
||||||
worker_id: str
|
|
||||||
capabilities: list[str] = field(default_factory=lambda: ["code.read"])
|
|
||||||
|
|
||||||
|
|
||||||
class Store:
|
|
||||||
def __init__(self):
|
|
||||||
self.conn = sqlite3.connect(":memory:", check_same_thread=False)
|
|
||||||
self._lock = threading.RLock()
|
|
||||||
self.conn.execute(
|
|
||||||
"CREATE TABLE claims("
|
|
||||||
"claim_id TEXT PRIMARY KEY,task_id TEXT,worker_id TEXT,status TEXT,"
|
|
||||||
"lease_expires_at REAL)"
|
|
||||||
)
|
|
||||||
self.tasks = []
|
|
||||||
self.workers = {}
|
|
||||||
|
|
||||||
def list_tasks(self, _ctx=None):
|
|
||||||
return list(self.tasks)
|
|
||||||
|
|
||||||
def get_worker(self, worker_id):
|
|
||||||
return self.workers[worker_id]
|
|
||||||
|
|
||||||
|
|
||||||
class Registry:
|
|
||||||
def __init__(self):
|
|
||||||
self.online = set()
|
|
||||||
|
|
||||||
def is_online(self, worker_id, now=None):
|
|
||||||
return worker_id in self.online
|
|
||||||
|
|
||||||
|
|
||||||
def _fleet():
|
|
||||||
store = Store()
|
|
||||||
registry = Registry()
|
|
||||||
store.workers["w-a"] = Worker("w-a")
|
|
||||||
registry.online.add("w-a")
|
|
||||||
fleet = FleetScheduler(store, registry, now_fn=lambda: 1000.0)
|
|
||||||
fleet.configure_worker("w-a", max_slots=1, failure_domain="zone-a")
|
|
||||||
return store, fleet
|
|
||||||
|
|
||||||
|
|
||||||
def test_P2B_CAPACITY_RESERVATION_is_atomic_under_concurrent_pullers():
|
|
||||||
store, fleet = _fleet()
|
|
||||||
tasks = [Task("task-a"), Task("task-b")]
|
|
||||||
store.tasks = tasks
|
|
||||||
barrier = threading.Barrier(2)
|
|
||||||
outcomes = []
|
|
||||||
|
|
||||||
def reserve(task):
|
|
||||||
barrier.wait()
|
|
||||||
try:
|
|
||||||
fleet.reserve_for_worker("w-a", task, now=1000.0)
|
|
||||||
outcomes.append("reserved")
|
|
||||||
except FleetConflict:
|
|
||||||
outcomes.append("conflict")
|
|
||||||
|
|
||||||
threads = [threading.Thread(target=reserve, args=(task,)) for task in tasks]
|
|
||||||
for thread in threads:
|
|
||||||
thread.start()
|
|
||||||
for thread in threads:
|
|
||||||
thread.join()
|
|
||||||
|
|
||||||
assert sorted(outcomes) == ["conflict", "reserved"]
|
|
||||||
assert fleet.state.reserved_slots("w-a", 1000.0) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_P2B_RANK_is_read_only_until_legacy_security_accepts_candidate():
|
|
||||||
store, fleet = _fleet()
|
|
||||||
store.tasks = [Task("task-a"), Task("task-b")]
|
|
||||||
ranked = fleet.rank_for_worker("w-a", now=1000.0)
|
|
||||||
assert [task.task_id for task in ranked] == ["task-a", "task-b"]
|
|
||||||
assert fleet.state.reserved_slots("w-a", 1000.0) == 0
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from http.server import ThreadingHTTPServer
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
ROOT = os.getcwd()
|
|
||||||
if os.path.join(ROOT, "active-runtime") not in sys.path:
|
|
||||||
sys.path.append(os.path.join(ROOT, "active-runtime"))
|
|
||||||
|
|
||||||
from app.task_plane.api import TaskPlaneRequestHandler, TaskPlaneService
|
|
||||||
from app.task_plane.errors import ForbiddenError, NotFoundError, ValidationError
|
|
||||||
from app.task_plane.models import TaskPlan
|
|
||||||
from app.task_plane.policy import PolicyEngine
|
|
||||||
from app.task_plane.store import TaskPlaneStore
|
|
||||||
|
|
||||||
|
|
||||||
def _policy() -> PolicyEngine:
|
|
||||||
return PolicyEngine(
|
|
||||||
identity_capabilities={
|
|
||||||
"actor-a": {"task.creator", "task.read"},
|
|
||||||
"actor-b": {"task.creator", "task.read"},
|
|
||||||
"reader-a": {"task.read"},
|
|
||||||
"reader-b": {"task.read"},
|
|
||||||
"owner": {"task.creator", "task.read", "task.read.all"},
|
|
||||||
"unbound": {"task.creator", "task.read"},
|
|
||||||
},
|
|
||||||
identity_actor_contexts={
|
|
||||||
"actor-a": {
|
|
||||||
"scope_type": "tenant",
|
|
||||||
"tenant_id": "tenant-a",
|
|
||||||
"user_id": "user-a",
|
|
||||||
"project_id": "project-a",
|
|
||||||
"conversation_id": "conversation-a",
|
|
||||||
"run_id": "run-a",
|
|
||||||
},
|
|
||||||
"reader-a": {
|
|
||||||
"scope_type": "tenant",
|
|
||||||
"tenant_id": "tenant-a",
|
|
||||||
"user_id": "user-a",
|
|
||||||
"project_id": "project-a",
|
|
||||||
"conversation_id": "conversation-a",
|
|
||||||
"run_id": "run-a",
|
|
||||||
},
|
|
||||||
"actor-b": {
|
|
||||||
"scope_type": "tenant",
|
|
||||||
"tenant_id": "tenant-b",
|
|
||||||
"user_id": "user-b",
|
|
||||||
"project_id": "project-b",
|
|
||||||
},
|
|
||||||
"reader-b": {
|
|
||||||
"scope_type": "tenant",
|
|
||||||
"tenant_id": "tenant-b",
|
|
||||||
"user_id": "user-b",
|
|
||||||
"project_id": "project-b",
|
|
||||||
},
|
|
||||||
"owner": {"scope_type": "system"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _service() -> TaskPlaneService:
|
|
||||||
return TaskPlaneService(TaskPlaneStore(":memory:"), policy=_policy())
|
|
||||||
|
|
||||||
|
|
||||||
def test_taskplan_scope_fields_round_trip_as_first_class_values() -> None:
|
|
||||||
plan = TaskPlan.from_dict(
|
|
||||||
{
|
|
||||||
"task_id": "task-scope-roundtrip",
|
|
||||||
"scope_type": "tenant",
|
|
||||||
"tenant_id": "tenant-a",
|
|
||||||
"user_id": "user-a",
|
|
||||||
"project_id": "project-a",
|
|
||||||
"conversation_id": "conversation-a",
|
|
||||||
"run_id": "run-a",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
payload = plan.to_dict()
|
|
||||||
assert TaskPlan.from_dict(payload).to_dict() == payload
|
|
||||||
|
|
||||||
|
|
||||||
def test_tenant_scope_requires_tenant_id_and_system_scope_does_not() -> None:
|
|
||||||
with pytest.raises(ValidationError) as exc:
|
|
||||||
TaskPlan.from_dict({"task_id": "task-missing-tenant", "scope_type": "tenant"})
|
|
||||||
assert exc.value.error_code == "TASK_SCOPE_TENANT_ID_REQUIRED"
|
|
||||||
system = TaskPlan.from_dict(
|
|
||||||
{"task_id": "task-system", "scope_type": "system", "run_id": "system-run"}
|
|
||||||
)
|
|
||||||
assert system.tenant_id is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_ACTOR_BINDING_AUTHZ_MERGE_uses_server_scope() -> None:
|
|
||||||
service = _service()
|
|
||||||
created = service.create_task(
|
|
||||||
{"task_id": "task-a", "task_type": "code.change"},
|
|
||||||
actor_identity="actor-a",
|
|
||||||
)
|
|
||||||
assert created["plan"]["tenant_id"] == "tenant-a"
|
|
||||||
assert created["plan"]["user_id"] == "user-a"
|
|
||||||
assert created["plan"]["project_id"] == "project-a"
|
|
||||||
declared = service.create_task(
|
|
||||||
{
|
|
||||||
"task_id": "task-a-declared",
|
|
||||||
"tenant_id": "tenant-a",
|
|
||||||
"user_id": "user-a",
|
|
||||||
"project_id": "project-a",
|
|
||||||
"conversation_id": "conversation-a",
|
|
||||||
"run_id": "run-a",
|
|
||||||
},
|
|
||||||
actor_identity="actor-a",
|
|
||||||
)
|
|
||||||
assert declared["plan"]["scope_type"] == "tenant"
|
|
||||||
|
|
||||||
|
|
||||||
def test_TASK_SCOPE_MISMATCH_DENY_rejects_forged_client_scope() -> None:
|
|
||||||
service = _service()
|
|
||||||
with pytest.raises(ForbiddenError) as exc:
|
|
||||||
service.create_task(
|
|
||||||
{
|
|
||||||
"task_id": "task-forged",
|
|
||||||
"scope_type": "tenant",
|
|
||||||
"tenant_id": "tenant-b",
|
|
||||||
"user_id": "user-b",
|
|
||||||
"project_id": "project-b",
|
|
||||||
},
|
|
||||||
actor_identity="actor-a",
|
|
||||||
)
|
|
||||||
assert exc.value.error_code == "TASK_SCOPE_MISMATCH_DENY"
|
|
||||||
|
|
||||||
|
|
||||||
def test_public_create_fails_closed_without_server_actor_scope() -> None:
|
|
||||||
service = _service()
|
|
||||||
with pytest.raises(ForbiddenError) as exc:
|
|
||||||
service.create_task(
|
|
||||||
{"task_id": "task-unbound"}, actor_identity="unbound"
|
|
||||||
)
|
|
||||||
assert exc.value.error_code == "TASK_ACTOR_SCOPE_UNAVAILABLE"
|
|
||||||
|
|
||||||
|
|
||||||
def test_TASKPLANE_LIST_AUTHZ_DENY_and_TASK_OWNERSHIP_FILTER() -> None:
|
|
||||||
service = _service()
|
|
||||||
service.create_task({"task_id": "task-a"}, actor_identity="actor-a")
|
|
||||||
service.create_task({"task_id": "task-b"}, actor_identity="actor-b")
|
|
||||||
assert [item["task_id"] for item in service.list_tasks("reader-a")] == ["task-a"]
|
|
||||||
assert [item["task_id"] for item in service.list_tasks("reader-b")] == ["task-b"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_TASKPLANE_GET_AUTHZ_DENY_does_not_disclose_cross_tenant_task() -> None:
|
|
||||||
service = _service()
|
|
||||||
service.create_task({"task_id": "task-a"}, actor_identity="actor-a")
|
|
||||||
with pytest.raises(NotFoundError):
|
|
||||||
service.get_task("task-a", "reader-b")
|
|
||||||
|
|
||||||
|
|
||||||
def test_http_authentication_drives_server_scope_and_cross_tenant_404() -> None:
|
|
||||||
service = _service()
|
|
||||||
|
|
||||||
class Handler(TaskPlaneRequestHandler):
|
|
||||||
pass
|
|
||||||
|
|
||||||
Handler.service = service
|
|
||||||
Handler.authenticator = lambda token: {
|
|
||||||
"token-a": "actor-a",
|
|
||||||
"token-b": "reader-b",
|
|
||||||
}.get(token)
|
|
||||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
||||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
base = f"http://127.0.0.1:{server.server_port}"
|
|
||||||
try:
|
|
||||||
request = urllib.request.Request(
|
|
||||||
base + "/tasks",
|
|
||||||
data=json.dumps({"task_id": "task-http-a"}).encode(),
|
|
||||||
headers={
|
|
||||||
"Authorization": "Bearer token-a",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(request, timeout=3) as response:
|
|
||||||
created = json.loads(response.read())
|
|
||||||
assert created["plan"]["tenant_id"] == "tenant-a"
|
|
||||||
|
|
||||||
denied = urllib.request.Request(
|
|
||||||
base + "/tasks/task-http-a",
|
|
||||||
headers={"Authorization": "Bearer token-b"},
|
|
||||||
)
|
|
||||||
with pytest.raises(urllib.error.HTTPError) as exc:
|
|
||||||
urllib.request.urlopen(denied, timeout=3)
|
|
||||||
assert exc.value.code == 404
|
|
||||||
finally:
|
|
||||||
server.shutdown()
|
|
||||||
server.server_close()
|
|
||||||
thread.join(timeout=3)
|
|
||||||
|
|
||||||
|
|
||||||
def test_mutating_endpoint_is_scoped_before_cancel() -> None:
|
|
||||||
policy = PolicyEngine(
|
|
||||||
execution_enabled=True,
|
|
||||||
identity_capabilities={
|
|
||||||
"actor-a": {"task.creator", "task.read"},
|
|
||||||
"actor-b": {"task.cancel"},
|
|
||||||
},
|
|
||||||
identity_actor_contexts={
|
|
||||||
"actor-a": {"scope_type": "tenant", "tenant_id": "tenant-a"},
|
|
||||||
"actor-b": {"scope_type": "tenant", "tenant_id": "tenant-b"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
service = TaskPlaneService(TaskPlaneStore(":memory:"), policy=policy)
|
|
||||||
service.create_task({"task_id": "task-a"}, actor_identity="actor-a")
|
|
||||||
with pytest.raises(NotFoundError):
|
|
||||||
service.cancel("task-a", {"actor_identity": "actor-b"})
|
|
||||||
|
|
||||||
|
|
||||||
def test_privileged_server_actor_can_cross_scope() -> None:
|
|
||||||
service = _service()
|
|
||||||
service.create_task({"task_id": "task-a"}, actor_identity="actor-a")
|
|
||||||
service.create_task({"task_id": "task-b"}, actor_identity="actor-b")
|
|
||||||
assert {item["task_id"] for item in service.list_tasks("owner")} == {
|
|
||||||
"task-a",
|
|
||||||
"task-b",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_scope_sql_parameters_do_not_interpolate_tenant_values() -> None:
|
|
||||||
service = _service()
|
|
||||||
service.create_task({"task_id": "task-a"}, actor_identity="actor-a")
|
|
||||||
policy = PolicyEngine(
|
|
||||||
identity_capabilities={"attacker": {"task.read"}},
|
|
||||||
identity_actor_contexts={
|
|
||||||
"attacker": {
|
|
||||||
"scope_type": "tenant",
|
|
||||||
"tenant_id": "tenant-a' OR 1=1 --",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
attacker_service = TaskPlaneService(service.store, policy=policy)
|
|
||||||
assert attacker_service.list_tasks("attacker") == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_existing_sqlite_rows_migrate_to_legacy_without_tenant_guess(tmp_path) -> None:
|
|
||||||
path = tmp_path / "legacy.sqlite3"
|
|
||||||
connection = sqlite3.connect(path)
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE tasks (
|
|
||||||
task_id TEXT PRIMARY KEY, revision INTEGER NOT NULL, status TEXT NOT NULL,
|
|
||||||
task_json TEXT NOT NULL, task_type TEXT NOT NULL, root_task_id TEXT NOT NULL,
|
|
||||||
parent_task_id TEXT, correlation_id TEXT, idempotency_key TEXT UNIQUE,
|
|
||||||
created_at REAL NOT NULL, updated_at REAL NOT NULL, failure_reason TEXT
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
connection.execute(
|
|
||||||
"INSERT INTO tasks VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
||||||
(
|
|
||||||
"task-legacy",
|
|
||||||
1,
|
|
||||||
"CREATED",
|
|
||||||
json.dumps({"task_id": "task-legacy"}),
|
|
||||||
"code.change",
|
|
||||||
"task-legacy",
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
"legacy-key",
|
|
||||||
1.0,
|
|
||||||
1.0,
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
connection.commit()
|
|
||||||
connection.close()
|
|
||||||
|
|
||||||
store = TaskPlaneStore(str(path))
|
|
||||||
task = store.get_task("task-legacy")
|
|
||||||
assert task.task_plan.scope_type == "legacy"
|
|
||||||
assert task.task_plan.tenant_id is None
|
|
||||||
replay = store.create_task(
|
|
||||||
TaskPlan.from_dict({"task_id": "task-legacy"}),
|
|
||||||
idempotency_key="legacy-key",
|
|
||||||
)
|
|
||||||
assert replay.task_id == "task-legacy"
|
|
||||||
assert tuple(
|
|
||||||
store.conn.execute(
|
|
||||||
"SELECT scope_type,tenant_id FROM tasks WHERE task_id='task-legacy'"
|
|
||||||
).fetchone()
|
|
||||||
) == ("legacy", None)
|
|
||||||
tenant_context = _policy().actor_context("reader-a")
|
|
||||||
with pytest.raises(NotFoundError):
|
|
||||||
store.get_task("task-legacy", tenant_context)
|
|
||||||
store.close()
|
|
||||||
@@ -20,12 +20,3 @@ def test_unified_event_flow():
|
|||||||
|
|
||||||
dashboard = AgentEventDashboard(bus).overview()
|
dashboard = AgentEventDashboard(bus).overview()
|
||||||
assert dashboard["audit"]["enabled"] is True
|
assert dashboard["audit"]["enabled"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_target_changes_event_identity(tmp_path):
|
|
||||||
bus = AgentEventBus(tmp_path)
|
|
||||||
first = bus.publish('test', 'same', 'user-a', 'same summary', {'value': 1})
|
|
||||||
second = bus.publish('test', 'same', 'user-b', 'same summary', {'value': 1})
|
|
||||||
first_id = first.get('id') if isinstance(first, dict) else getattr(first, 'id', first)
|
|
||||||
second_id = second.get('id') if isinstance(second, dict) else getattr(second, 'id', second)
|
|
||||||
assert first_id != second_id
|
|
||||||
|
|||||||
@@ -1,293 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import sqlite3
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
ROOT=Path(__file__).resolve().parents[1]
|
|
||||||
sys.path.insert(0,str(ROOT/"active-runtime/app"))
|
|
||||||
|
|
||||||
from aios_agent_task_execution_rebind import (
|
|
||||||
ExecutionRebindError,
|
|
||||||
ExecutionRebindRequest,
|
|
||||||
ExecutionRebindStore,
|
|
||||||
)
|
|
||||||
from aios_online_agent_execution import (
|
|
||||||
GovernedExecutionBinding,
|
|
||||||
project_execution_envelope,
|
|
||||||
)
|
|
||||||
|
|
||||||
AUTHORIZATION="owner-execution-rebind-20260818"
|
|
||||||
TASK_ID="task-rebind-a"
|
|
||||||
JOB_ID="job-rebind-a"
|
|
||||||
BASE="a"*40
|
|
||||||
BRANCH="agent/task-rebind-a"
|
|
||||||
IDEMPOTENCY="task-rebind-a-binding-v1"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _auth(monkeypatch):
|
|
||||||
monkeypatch.setenv(
|
|
||||||
"AIOS_AGENT_TASK_EXECUTION_REBIND_AUTHORIZATION_ID",
|
|
||||||
AUTHORIZATION,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def canon(value):
|
|
||||||
return hashlib.sha256(
|
|
||||||
json.dumps(value,sort_keys=True,separators=(",",":")).encode()
|
|
||||||
).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def binding():
|
|
||||||
return GovernedExecutionBinding.from_mapping({
|
|
||||||
"task_id":TASK_ID,
|
|
||||||
"job_id":JOB_ID,
|
|
||||||
"repository":"aios/aios",
|
|
||||||
"base_oid":BASE,
|
|
||||||
"expected_branch_oid":BASE,
|
|
||||||
"target_branch":BRANCH,
|
|
||||||
"branch_mode":"CREATE_NEW",
|
|
||||||
"generation":1,
|
|
||||||
"idempotency_key":IDEMPOTENCY,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def old_envelope():
|
|
||||||
return project_execution_envelope(
|
|
||||||
binding(),
|
|
||||||
{
|
|
||||||
"action":"source_capture",
|
|
||||||
"adapter":"compact_interface_map",
|
|
||||||
"capability_scope":[
|
|
||||||
"git.read","git.test","governed.write.branch","governed.write.file"
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def new_envelope():
|
|
||||||
return project_execution_envelope(
|
|
||||||
binding(),
|
|
||||||
{
|
|
||||||
"action":"deterministic",
|
|
||||||
"adapter":"deterministic",
|
|
||||||
"capability_scope":[
|
|
||||||
"git.read","git.test","governed.write.branch","governed.write.file"
|
|
||||||
],
|
|
||||||
"change_set":[
|
|
||||||
{
|
|
||||||
"path":"active-runtime/app/example.py",
|
|
||||||
"operation":"write_file",
|
|
||||||
"content":"VALUE=1\\n",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"verification":[
|
|
||||||
{
|
|
||||||
"label":"unit",
|
|
||||||
"argv":["/usr/bin/python3","-m","pytest","-q"],
|
|
||||||
"cwd":".",
|
|
||||||
"timeout_seconds":120,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"governance":{
|
|
||||||
"commit_idempotency_key":"task-rebind-a-commit-v1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def make_db(tmp_path, *, status="QUEUED", revision=5, attempt=2, lease=False):
|
|
||||||
path=tmp_path/"state.sqlite3"
|
|
||||||
con=sqlite3.connect(path)
|
|
||||||
con.executescript("""
|
|
||||||
CREATE TABLE tasks(
|
|
||||||
task_id TEXT PRIMARY KEY,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
attempt INTEGER NOT NULL,
|
|
||||||
max_attempts INTEGER NOT NULL,
|
|
||||||
revision INTEGER NOT NULL,
|
|
||||||
claimed_by TEXT,
|
|
||||||
lease_token_hash TEXT,
|
|
||||||
lease_expires_at TEXT,
|
|
||||||
verification_json TEXT,
|
|
||||||
result_json TEXT,
|
|
||||||
metadata_json TEXT,
|
|
||||||
required_capabilities_json TEXT
|
|
||||||
);
|
|
||||||
CREATE TABLE task_events(
|
|
||||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
task_id TEXT,
|
|
||||||
event_type TEXT,
|
|
||||||
agent_id TEXT,
|
|
||||||
payload_json TEXT,
|
|
||||||
created_at TEXT
|
|
||||||
);
|
|
||||||
""")
|
|
||||||
verification={"decision":"NEEDS_REWORK","reason":"WRONG_ENVELOPE_BEFORE_PLAN"}
|
|
||||||
old=old_envelope()
|
|
||||||
metadata={
|
|
||||||
"execution":old,
|
|
||||||
"execution_projection":{
|
|
||||||
"schema":"aios.online-agent.task-projection.v1",
|
|
||||||
"task_id":TASK_ID,
|
|
||||||
"job_id":JOB_ID,
|
|
||||||
"execution_digest":old["execution_digest"],
|
|
||||||
},
|
|
||||||
"preserved":{"value":"unchanged"},
|
|
||||||
}
|
|
||||||
con.execute(
|
|
||||||
"INSERT INTO tasks VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
||||||
(
|
|
||||||
TASK_ID,status,attempt,3,revision,
|
|
||||||
"previous-worker" if not lease else "active-worker",
|
|
||||||
"lease-hash" if lease else None,
|
|
||||||
"2099-01-01T00:00:00Z" if lease else None,
|
|
||||||
json.dumps(verification),
|
|
||||||
json.dumps({"status":"SUCCEEDED"}),
|
|
||||||
json.dumps(metadata),
|
|
||||||
json.dumps([
|
|
||||||
"git.read","git.test","governed.write.branch","governed.write.file"
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
con.commit();con.close()
|
|
||||||
return path,verification
|
|
||||||
|
|
||||||
|
|
||||||
def request(verification, *, revision=5, old=None, new=None, key="rebind-task-a-v1"):
|
|
||||||
old=old or old_envelope()
|
|
||||||
new=new or new_envelope()
|
|
||||||
return ExecutionRebindRequest.from_mapping({
|
|
||||||
"expected_status":"QUEUED",
|
|
||||||
"expected_revision":revision,
|
|
||||||
"expected_attempt":2,
|
|
||||||
"previous_verification_sha256":canon(verification),
|
|
||||||
"expected_old_execution_digest":old["execution_digest"],
|
|
||||||
"expected_new_execution_digest":new["execution_digest"],
|
|
||||||
"authorization_id":AUTHORIZATION,
|
|
||||||
"idempotency_key":key,
|
|
||||||
"reason":"projection-before-plan race recovery",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def resolver(_task_id,_caps):
|
|
||||||
return new_envelope()
|
|
||||||
|
|
||||||
|
|
||||||
def test_rebind_cas_updates_only_execution_projection_and_audits(tmp_path):
|
|
||||||
path,verification=make_db(tmp_path)
|
|
||||||
result=ExecutionRebindStore(path,resolver).rebind(
|
|
||||||
TASK_ID,request(verification)
|
|
||||||
)
|
|
||||||
assert result["status"]=="QUEUED"
|
|
||||||
assert result["revision"]==6
|
|
||||||
assert result["idempotent"] is False
|
|
||||||
|
|
||||||
con=sqlite3.connect(path)
|
|
||||||
row=con.execute(
|
|
||||||
"SELECT status,attempt,revision,claimed_by,metadata_json FROM tasks"
|
|
||||||
).fetchone()
|
|
||||||
assert row[:4]==("QUEUED",2,6,None)
|
|
||||||
metadata=json.loads(row[4])
|
|
||||||
assert metadata["preserved"]=={"value":"unchanged"}
|
|
||||||
assert metadata["execution"]["action"]=="deterministic"
|
|
||||||
assert metadata["execution"]["execution_digest"]==new_envelope()["execution_digest"]
|
|
||||||
assert metadata["execution_projection"]["execution_digest"]==new_envelope()["execution_digest"]
|
|
||||||
|
|
||||||
event=con.execute(
|
|
||||||
"SELECT event_type,payload_json FROM task_events"
|
|
||||||
).fetchone()
|
|
||||||
assert event[0]=="TASK_EXECUTION_REBIND_AUTHORIZED"
|
|
||||||
payload=json.loads(event[1])
|
|
||||||
assert payload["old_action"]=="source_capture"
|
|
||||||
assert payload["new_action"]=="deterministic"
|
|
||||||
assert payload["stale_claim_normalized"] is True
|
|
||||||
assert con.execute("SELECT COUNT(*) FROM task_execution_rebinds").fetchone()[0]==1
|
|
||||||
|
|
||||||
|
|
||||||
def test_rebind_idempotent_replay(tmp_path):
|
|
||||||
path,verification=make_db(tmp_path)
|
|
||||||
store=ExecutionRebindStore(path,resolver)
|
|
||||||
req=request(verification)
|
|
||||||
first=store.rebind(TASK_ID,req)
|
|
||||||
second=store.rebind(TASK_ID,req)
|
|
||||||
assert first["idempotent"] is False
|
|
||||||
assert second["idempotent"] is True
|
|
||||||
assert second["new_execution_digest"]==first["new_execution_digest"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_rebind_rejects_active_lease(tmp_path):
|
|
||||||
path,verification=make_db(tmp_path,lease=True)
|
|
||||||
with pytest.raises(ExecutionRebindError,match="EXECUTION_REBIND_ACTIVE_LEASE_DENY"):
|
|
||||||
ExecutionRebindStore(path,resolver).rebind(TASK_ID,request(verification))
|
|
||||||
|
|
||||||
|
|
||||||
def test_rebind_rejects_verification_drift(tmp_path):
|
|
||||||
path,_=make_db(tmp_path)
|
|
||||||
with pytest.raises(ExecutionRebindError,match="EXECUTION_REBIND_VERIFICATION_DRIFT"):
|
|
||||||
ExecutionRebindStore(path,resolver).rebind(
|
|
||||||
TASK_ID,request({"decision":"NEEDS_REWORK","reason":"other"})
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_rebind_rejects_nonqueued_task(tmp_path):
|
|
||||||
path,verification=make_db(tmp_path,status="VERIFYING")
|
|
||||||
with pytest.raises(ExecutionRebindError,match="EXECUTION_REBIND_CAS_MISMATCH"):
|
|
||||||
ExecutionRebindStore(path,resolver).rebind(TASK_ID,request(verification))
|
|
||||||
|
|
||||||
|
|
||||||
def test_rebind_rejects_non_deterministic_canonical_resolution(tmp_path):
|
|
||||||
path,verification=make_db(tmp_path)
|
|
||||||
def wrong(_task,_caps):
|
|
||||||
return old_envelope()
|
|
||||||
with pytest.raises(ExecutionRebindError,match="EXECUTION_REBIND_DETERMINISTIC_PLAN_REQUIRED"):
|
|
||||||
ExecutionRebindStore(path,wrong).rebind(TASK_ID,request(verification))
|
|
||||||
|
|
||||||
|
|
||||||
def test_rebind_rejects_binding_drift(tmp_path):
|
|
||||||
path,verification=make_db(tmp_path)
|
|
||||||
different=project_execution_envelope(
|
|
||||||
GovernedExecutionBinding.from_mapping({
|
|
||||||
"task_id":TASK_ID,
|
|
||||||
"job_id":"job-other",
|
|
||||||
"repository":"aios/aios",
|
|
||||||
"base_oid":BASE,
|
|
||||||
"expected_branch_oid":BASE,
|
|
||||||
"target_branch":BRANCH,
|
|
||||||
"branch_mode":"CREATE_NEW",
|
|
||||||
"generation":1,
|
|
||||||
"idempotency_key":IDEMPOTENCY,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
"action":"deterministic",
|
|
||||||
"capability_scope":["git.read"],
|
|
||||||
"change_set":[{"path":"x.py","operation":"write_file","content":"x=1\\n"}],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def wrong(_task,_caps):
|
|
||||||
return different
|
|
||||||
with pytest.raises(Exception):
|
|
||||||
ExecutionRebindStore(path,wrong).rebind(TASK_ID,request(verification))
|
|
||||||
|
|
||||||
|
|
||||||
def test_rebind_request_is_narrow_and_digest_bound():
|
|
||||||
verification={"decision":"NEEDS_REWORK"}
|
|
||||||
data=request(verification).public()
|
|
||||||
data["arbitrary_metadata"]={"x":1}
|
|
||||||
with pytest.raises(ExecutionRebindError,match="EXECUTION_REBIND_FIELDS_INVALID"):
|
|
||||||
ExecutionRebindRequest.from_mapping(data)
|
|
||||||
|
|
||||||
|
|
||||||
def test_execution_rebind_unit_is_unprivileged_and_owner_credential_bound():
|
|
||||||
unit=(ROOT/"deploy/systemd/aios-agent-task-execution-rebind.service").read_text()
|
|
||||||
assert "User=aios-agent-task" in unit
|
|
||||||
assert "Group=aios-agent-task" in unit
|
|
||||||
assert "LoadCredential=agent_task_control_token:" in unit
|
|
||||||
assert "Environment=AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892" in unit
|
|
||||||
assert "RestrictAddressFamilies=AF_UNIX AF_INET" in unit
|
|
||||||
assert "User=root" not in unit
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
import hashlib, json, sqlite3, sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
ROOT=Path(__file__).resolve().parents[1]
|
|
||||||
sys.path.insert(0,str(ROOT/"active-runtime/app"))
|
|
||||||
from aios_agent_task_retry_extension import RetryExtensionRequest, RetryExtensionStore, RetryExtensionError
|
|
||||||
|
|
||||||
AUTHORIZATION_ID="owner-scheme-a-20260817"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _authorization_id(monkeypatch):
|
|
||||||
monkeypatch.setenv("AIOS_AGENT_TASK_AUTHORIZATION_ID", AUTHORIZATION_ID)
|
|
||||||
|
|
||||||
|
|
||||||
def canon(v):
|
|
||||||
return hashlib.sha256(json.dumps(v,sort_keys=True,separators=(",",":")).encode()).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def db(tmp_path):
|
|
||||||
p=tmp_path/"state.sqlite3"
|
|
||||||
c=sqlite3.connect(p)
|
|
||||||
c.executescript("""
|
|
||||||
CREATE TABLE tasks(
|
|
||||||
task_id TEXT PRIMARY KEY,status TEXT,attempt INTEGER,max_attempts INTEGER,revision INTEGER,
|
|
||||||
claimed_by TEXT,lease_token_hash TEXT,lease_expires_at TEXT,verification_json TEXT,result_json TEXT
|
|
||||||
);
|
|
||||||
CREATE TABLE task_events(
|
|
||||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,task_id TEXT,event_type TEXT,agent_id TEXT,
|
|
||||||
payload_json TEXT,created_at TEXT
|
|
||||||
);
|
|
||||||
""")
|
|
||||||
verification={"decision":"NEEDS_REWORK","reason":"BASE_OID_BINDING_INVALID"}
|
|
||||||
c.execute("INSERT INTO tasks VALUES (?,?,?,?,?,?,?,?,?,?)",
|
|
||||||
("task-a","FAILED",10,10,41,None,None,None,json.dumps(verification),json.dumps({"status":"FAILED"})))
|
|
||||||
c.commit(); c.close()
|
|
||||||
return p,verification
|
|
||||||
|
|
||||||
|
|
||||||
def request(v):
|
|
||||||
return RetryExtensionRequest.from_mapping({
|
|
||||||
"expected_status":"FAILED","expected_revision":41,"expected_attempt":10,"expected_max_attempts":10,
|
|
||||||
"next_max_attempts":11,"previous_verification_sha256":canon(v),
|
|
||||||
"authorization_id":AUTHORIZATION_ID,"idempotency_key":"retry-task-a-attempt11",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def test_retry_extension_cas_and_audit(tmp_path):
|
|
||||||
p,v=db(tmp_path)
|
|
||||||
out=RetryExtensionStore(p).extend("task-a",request(v))
|
|
||||||
assert out["status"]=="QUEUED" and out["max_attempts"]==11 and out["revision"]==42
|
|
||||||
c=sqlite3.connect(p)
|
|
||||||
assert c.execute("select status,attempt,max_attempts,revision from tasks").fetchone()==("QUEUED",10,11,42)
|
|
||||||
event=c.execute("select event_type,payload_json from task_events").fetchone()
|
|
||||||
assert event[0]=="TASK_RETRY_EXTENSION_AUTHORIZED"
|
|
||||||
assert json.loads(event[1])["authorization_id"]=="owner-scheme-a-20260817"
|
|
||||||
|
|
||||||
|
|
||||||
def test_retry_extension_idempotent_replay(tmp_path):
|
|
||||||
p,v=db(tmp_path); store=RetryExtensionStore(p); req=request(v)
|
|
||||||
first=store.extend("task-a",req); second=store.extend("task-a",req)
|
|
||||||
assert first["idempotent"] is False and second["idempotent"] is True
|
|
||||||
c=sqlite3.connect(p)
|
|
||||||
assert c.execute("select count(*) from task_retry_extensions").fetchone()[0]==1
|
|
||||||
|
|
||||||
|
|
||||||
def test_retry_extension_normalizes_stale_terminal_claim_without_active_lease(tmp_path):
|
|
||||||
p,v=db(tmp_path)
|
|
||||||
c=sqlite3.connect(p)
|
|
||||||
c.execute("update tasks set claimed_by='previous-worker'")
|
|
||||||
c.commit(); c.close()
|
|
||||||
out=RetryExtensionStore(p).extend("task-a",request(v))
|
|
||||||
assert out["status"]=="QUEUED"
|
|
||||||
c=sqlite3.connect(p)
|
|
||||||
assert c.execute(
|
|
||||||
"select claimed_by,lease_token_hash,lease_expires_at from tasks"
|
|
||||||
).fetchone()==(None,None,None)
|
|
||||||
payload=json.loads(c.execute("select payload_json from task_events").fetchone()[0])
|
|
||||||
assert payload["previous_claimed_by"]=="previous-worker"
|
|
||||||
assert payload["stale_terminal_claim_normalized"] is True
|
|
||||||
assert c.execute("select count(*) from task_events").fetchone()[0]==1
|
|
||||||
|
|
||||||
|
|
||||||
def test_retry_extension_verification_drift_denied(tmp_path):
|
|
||||||
p,v=db(tmp_path)
|
|
||||||
bad=request({"decision":"NEEDS_REWORK","reason":"other"})
|
|
||||||
with pytest.raises(RetryExtensionError,match="RETRY_EXTENSION_VERIFICATION_DRIFT"):
|
|
||||||
RetryExtensionStore(p).extend("task-a",bad)
|
|
||||||
|
|
||||||
|
|
||||||
def test_retry_extension_authorization_id_mismatch_rejected(tmp_path, monkeypatch):
|
|
||||||
p,v=db(tmp_path)
|
|
||||||
monkeypatch.setenv("AIOS_AGENT_TASK_AUTHORIZATION_ID", "other-owner-id")
|
|
||||||
with pytest.raises(RetryExtensionError,match="RETRY_EXTENSION_AUTHORIZATION_ID_MISMATCH"):
|
|
||||||
RetryExtensionStore(p).extend("task-a",request(v))
|
|
||||||
|
|
||||||
|
|
||||||
def test_retry_extension_bounded_one_step():
|
|
||||||
v={"decision":"NEEDS_REWORK"}
|
|
||||||
data={
|
|
||||||
"expected_status":"FAILED","expected_revision":41,"expected_attempt":10,"expected_max_attempts":10,
|
|
||||||
"next_max_attempts":12,"previous_verification_sha256":canon(v),
|
|
||||||
"authorization_id":"owner-scheme-a-20260817","idempotency_key":"retry-task-a-attempt11",
|
|
||||||
}
|
|
||||||
with pytest.raises(RetryExtensionError,match="RETRY_EXTENSION_MUST_BE_BOUNDED_ONE_STEP"):
|
|
||||||
RetryExtensionRequest.from_mapping(data)
|
|
||||||
|
|
||||||
|
|
||||||
def test_retry_extension_unit_is_unprivileged_and_requires_owner_binding():
|
|
||||||
unit=(ROOT/"deploy/systemd/aios-agent-task-retry-extension.service").read_text()
|
|
||||||
assert "User=aios-agent-task" in unit
|
|
||||||
assert "Group=aios-agent-task" in unit
|
|
||||||
assert "LoadCredential=agent_task_control_token:" in unit
|
|
||||||
assert "EnvironmentFile=/etc/aios-gitops/agent-task-retry-extension.env" in unit
|
|
||||||
assert "User=root" not in unit
|
|
||||||
@@ -158,7 +158,7 @@ def test_execute_orders_registry_before_baseline_and_baseline_last(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"approved_release_executor.verify_artifact",
|
"approved_release_executor.verify_artifact",
|
||||||
lambda binding: {"verified": True},
|
lambda binding, **kwargs: {"verified": True},
|
||||||
)
|
)
|
||||||
|
|
||||||
calls = []
|
calls = []
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ class AuthoritativeGitContextTests(unittest.TestCase):
|
|||||||
json.dumps({"branch": "patch/context"}), encoding="utf-8"
|
json.dumps({"branch": "patch/context"}), encoding="utf-8"
|
||||||
)
|
)
|
||||||
self.env = {
|
self.env = {
|
||||||
|
"AIOS_TEST_MODE": "1",
|
||||||
"AIOS_GITOPS_AUTHORITATIVE_GIT_DIR": str(self.bare),
|
"AIOS_GITOPS_AUTHORITATIVE_GIT_DIR": str(self.bare),
|
||||||
"AIOS_GITOPS_REPOSITORY_IDENTITY": "aios/aios",
|
"AIOS_GITOPS_REPOSITORY_IDENTITY": "aios/aios",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import base64
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from aios_authority_git_executor import AuthorityGitExecutor
|
from aios_authority_git_executor import AuthorityGitExecutor
|
||||||
from aios_chatgpt_bridge_v2 import BridgeV2Adapter, OPERATION_REQUIRED_FIELDS
|
|
||||||
from aios_chatgpt_bridge_v2_server import BridgeHandler, RuntimeConfig
|
from aios_chatgpt_bridge_v2_server import BridgeHandler, RuntimeConfig
|
||||||
from aios_continuation_executor import GiteaContinuationRemote, ServerSideCredentialProjection
|
from aios_continuation_executor import GiteaContinuationRemote, ServerSideCredentialProjection
|
||||||
from aios_continuation_lease import WriterLeaseRequestV2
|
from aios_continuation_lease import WriterLeaseRequestV2
|
||||||
@@ -27,7 +26,6 @@ class Probe:
|
|||||||
|
|
||||||
def test_create_new_lease_is_exact_base_and_absent():
|
def test_create_new_lease_is_exact_base_and_absent():
|
||||||
request = WriterLeaseRequestV2(
|
request = WriterLeaseRequestV2(
|
||||||
job_id="job-bridge-runtime-lineage-test",
|
|
||||||
task_id="TASK-P0-BRIDGE-V21-DURABLE-RECONCILIATION-20260809",
|
task_id="TASK-P0-BRIDGE-V21-DURABLE-RECONCILIATION-20260809",
|
||||||
repository="aios/aios",
|
repository="aios/aios",
|
||||||
branch_mode="CREATE_NEW",
|
branch_mode="CREATE_NEW",
|
||||||
@@ -41,7 +39,6 @@ def test_create_new_lease_is_exact_base_and_absent():
|
|||||||
|
|
||||||
def test_create_new_lease_rejects_existing_target():
|
def test_create_new_lease_rejects_existing_target():
|
||||||
request = WriterLeaseRequestV2(
|
request = WriterLeaseRequestV2(
|
||||||
job_id="job-bridge-runtime-lineage-test",
|
|
||||||
task_id="task",
|
task_id="task",
|
||||||
repository="aios/aios",
|
repository="aios/aios",
|
||||||
branch_mode="CREATE_NEW",
|
branch_mode="CREATE_NEW",
|
||||||
@@ -78,7 +75,8 @@ def test_slash_branch_uses_paginated_collection():
|
|||||||
def test_http_route_contract_is_complete():
|
def test_http_route_contract_is_complete():
|
||||||
expected = {
|
expected = {
|
||||||
"/v2/runtime", "/v2/capabilities", "/v2/governed-task",
|
"/v2/runtime", "/v2/capabilities", "/v2/governed-task",
|
||||||
"/v2/writer-lease", "/v2/writer-lease/bootstrap", "/v2/writer-lease/renew", "/v2/commit-file", "/v2/commit-files",
|
"/v2/writer-lease", "/v2/commit-file", "/v2/commit-files",
|
||||||
|
"/v2/branch/publish",
|
||||||
"/v2/job", "/v2/job/resume", "/v2/job/reconcile",
|
"/v2/job", "/v2/job/resume", "/v2/job/reconcile",
|
||||||
"/v2/tag/approval-request",
|
"/v2/tag/approval-request",
|
||||||
"/v2/tag/publish",
|
"/v2/tag/publish",
|
||||||
@@ -91,121 +89,3 @@ def test_http_route_contract_is_complete():
|
|||||||
from aios_chatgpt_bridge_v2_server import ROUTES
|
from aios_chatgpt_bridge_v2_server import ROUTES
|
||||||
|
|
||||||
assert set(ROUTES) == expected
|
assert set(ROUTES) == expected
|
||||||
|
|
||||||
|
|
||||||
def test_renew_writer_lease_is_exposed_in_action_and_capability_contracts():
|
|
||||||
adapter = BridgeV2Adapter(runtime_status={})
|
|
||||||
capabilities = adapter.getGovernedWriteCapabilities()
|
|
||||||
contract = capabilities["operation_contracts"]["renewWriterLease"]
|
|
||||||
|
|
||||||
assert contract["method"] == "POST"
|
|
||||||
assert contract["path"] == "/v2/writer-lease/renew"
|
|
||||||
assert tuple(contract["required_fields"]) == OPERATION_REQUIRED_FIELDS["renewWriterLease"]
|
|
||||||
assert "runtime_rehydrated" in contract["response_fields"]
|
|
||||||
|
|
||||||
action_schema = Path("docs/gitops/openapi-chatgpt-actions.yaml").read_text(encoding="utf-8")
|
|
||||||
assert "/v2/writer-lease/renew:" in action_schema
|
|
||||||
assert "operationId: renewWriterLease" in action_schema
|
|
||||||
assert "BridgeWriterLeaseRecoveryRequest" in action_schema
|
|
||||||
assert "BridgeWriterLeaseRecoveryResponse" in action_schema
|
|
||||||
assert {
|
|
||||||
capabilities["operation_contracts"][operation]["path"]
|
|
||||||
for operation in (
|
|
||||||
"acquireWriterLease",
|
|
||||||
"renewWriterLease",
|
|
||||||
"commitFileGoverned",
|
|
||||||
"commitFilesGoverned",
|
|
||||||
)
|
|
||||||
} == {
|
|
||||||
"/v2/writer-lease",
|
|
||||||
"/v2/writer-lease/renew",
|
|
||||||
"/v2/commit-file",
|
|
||||||
"/v2/commit-files",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_bootstrap_writer_lease_is_exposed_in_action_and_capability_contracts():
|
|
||||||
adapter = BridgeV2Adapter(runtime_status={})
|
|
||||||
capabilities = adapter.getGovernedWriteCapabilities()
|
|
||||||
contract = capabilities["operation_contracts"]["bootstrapWriterLease"]
|
|
||||||
|
|
||||||
assert contract["method"] == "POST"
|
|
||||||
assert contract["path"] == "/v2/writer-lease/bootstrap"
|
|
||||||
assert tuple(contract["required_fields"]) == OPERATION_REQUIRED_FIELDS["bootstrapWriterLease"]
|
|
||||||
assert "bootstrapped" in contract["response_fields"]
|
|
||||||
assert "durable_branch_lease_present" in contract["response_fields"]
|
|
||||||
|
|
||||||
action_schema = Path("docs/gitops/openapi-chatgpt-actions.yaml").read_text(encoding="utf-8")
|
|
||||||
assert "/v2/writer-lease/bootstrap:" in action_schema
|
|
||||||
assert "operationId: bootstrapWriterLease" in action_schema
|
|
||||||
assert "bootstrapped: {type: boolean}" in action_schema
|
|
||||||
assert "durable_branch_lease_present: {type: boolean}" in action_schema
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def test_http_allows_explicit_job_id_on_lease_and_commit_requests():
|
|
||||||
handler = BridgeHandler.__new__(BridgeHandler)
|
|
||||||
lease_body = {
|
|
||||||
"schema": WriterLeaseRequestV2.schema,
|
|
||||||
"task_id": "task",
|
|
||||||
"job_id": "job-explicit-runtime-lineage",
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"branch_mode": "CONTINUE_EXISTING",
|
|
||||||
"base_oid": "a" * 40,
|
|
||||||
"expected_branch_oid": "a" * 40,
|
|
||||||
"target_branch": "agent/explicit-runtime-lineage",
|
|
||||||
"generation": 2,
|
|
||||||
"lease_seconds": 120,
|
|
||||||
}
|
|
||||||
handler._allowed("acquireWriterLease", lease_body)
|
|
||||||
handler._allowed(
|
|
||||||
"commitFilesGoverned",
|
|
||||||
{
|
|
||||||
"task_id": "task",
|
|
||||||
"job_id": "job-explicit-runtime-lineage",
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"expected_branch_oid": "a" * 40,
|
|
||||||
"target_branch": "agent/explicit-runtime-lineage",
|
|
||||||
"generation": 2,
|
|
||||||
"idempotency_key": "commit-explicit-runtime-lineage",
|
|
||||||
"files": {"docs/x.txt": "eA=="},
|
|
||||||
"message": "test",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_http_allows_omitted_job_id_for_durable_server_resolution():
|
|
||||||
handler = BridgeHandler.__new__(BridgeHandler)
|
|
||||||
lease_body = {
|
|
||||||
"schema": WriterLeaseRequestV2.schema,
|
|
||||||
"task_id": "task",
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"branch_mode": "CONTINUE_EXISTING",
|
|
||||||
"base_oid": "a" * 40,
|
|
||||||
"expected_branch_oid": "a" * 40,
|
|
||||||
"target_branch": "agent/implicit-runtime-lineage",
|
|
||||||
"generation": 2,
|
|
||||||
"lease_seconds": 120,
|
|
||||||
}
|
|
||||||
handler._allowed("acquireWriterLease", lease_body)
|
|
||||||
handler._allowed(
|
|
||||||
"commitFilesGoverned",
|
|
||||||
{
|
|
||||||
"task_id": "task",
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"expected_branch_oid": "a" * 40,
|
|
||||||
"target_branch": "agent/implicit-runtime-lineage",
|
|
||||||
"generation": 2,
|
|
||||||
"idempotency_key": "commit-implicit-runtime-lineage",
|
|
||||||
"files": {"docs/x.txt": "eA=="},
|
|
||||||
"message": "test",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_capability_contracts_return_exact_job_id_lineage():
|
|
||||||
contracts = BridgeV2Adapter(runtime_status={}).getGovernedWriteCapabilities()["operation_contracts"]
|
|
||||||
for operation in ("acquireWriterLease", "bootstrapWriterLease", "renewWriterLease"):
|
|
||||||
assert "job_id" in contracts[operation]["response_fields"]
|
|
||||||
for operation in ("commitFileGoverned", "commitFilesGoverned"):
|
|
||||||
assert "job_id" in contracts[operation]["response_fields"]
|
|
||||||
|
|||||||
@@ -1,304 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
import json
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from aios_chatgpt_bridge_v2_server import GovernedRuntime
|
|
||||||
from aios_continuation_executor import ContinuationExecutor
|
|
||||||
from aios_continuation_lease import WriterLeaseRequestV2
|
|
||||||
from aios_gitops_job_controller import JobState
|
|
||||||
|
|
||||||
|
|
||||||
TASK = "continuation-normalization-test"
|
|
||||||
JOB = "job-continuation-normalization-test"
|
|
||||||
REPO = "aios/aios"
|
|
||||||
BASE = "a" * 40
|
|
||||||
BRANCH = "agent/continuation-normalization-test"
|
|
||||||
|
|
||||||
|
|
||||||
class Authority:
|
|
||||||
def __init__(self, head: str | None) -> None:
|
|
||||||
self.head = head
|
|
||||||
|
|
||||||
def read_ref(self, ref: str) -> str | None:
|
|
||||||
assert ref == "refs/heads/" + BRANCH
|
|
||||||
return self.head
|
|
||||||
|
|
||||||
def commit_exists(self, oid: str) -> bool:
|
|
||||||
return oid == BASE
|
|
||||||
|
|
||||||
def branch_exists(self, branch: str) -> bool:
|
|
||||||
assert branch == BRANCH
|
|
||||||
return self.head is not None
|
|
||||||
|
|
||||||
def branch_head(self, branch: str) -> str:
|
|
||||||
assert branch == BRANCH and self.head is not None
|
|
||||||
return self.head
|
|
||||||
|
|
||||||
def is_ancestor(self, older: str, newer: str) -> bool:
|
|
||||||
return older == BASE and newer == BASE
|
|
||||||
|
|
||||||
|
|
||||||
def lease(**overrides: object) -> WriterLeaseRequestV2:
|
|
||||||
values = {
|
|
||||||
"task_id": TASK,
|
|
||||||
"job_id": JOB,
|
|
||||||
"repository": REPO,
|
|
||||||
"branch_mode": "CREATE_NEW",
|
|
||||||
"base_oid": BASE,
|
|
||||||
"expected_branch_oid": BASE,
|
|
||||||
"target_branch": BRANCH,
|
|
||||||
"generation": 1,
|
|
||||||
"lease_seconds": 120,
|
|
||||||
}
|
|
||||||
values.update(overrides)
|
|
||||||
return WriterLeaseRequestV2(**values)
|
|
||||||
|
|
||||||
|
|
||||||
def runtime(*, head: str | None = BASE, **binding_overrides: object) -> GovernedRuntime:
|
|
||||||
value = GovernedRuntime.__new__(GovernedRuntime)
|
|
||||||
value.authority = Authority(head)
|
|
||||||
binding = {
|
|
||||||
"task_id": TASK,
|
|
||||||
"job_id": JOB,
|
|
||||||
"repository": REPO,
|
|
||||||
"base_oid": BASE,
|
|
||||||
"expected_branch_oid": BASE,
|
|
||||||
"target_branch": BRANCH,
|
|
||||||
"generation": 1,
|
|
||||||
"branch_mode": "CREATE_NEW",
|
|
||||||
}
|
|
||||||
binding.update(binding_overrides)
|
|
||||||
value.bindings = {TASK: binding}
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def test_same_lineage_create_new_normalizes_to_continue_existing() -> None:
|
|
||||||
normalized = runtime()._normalize_runtime_lease(lease())
|
|
||||||
assert normalized.branch_mode == "CONTINUE_EXISTING"
|
|
||||||
assert normalized.task_id == TASK
|
|
||||||
assert normalized.job_id == JOB
|
|
||||||
assert normalized.repository == REPO
|
|
||||||
assert normalized.target_branch == BRANCH
|
|
||||||
assert normalized.expected_branch_oid == BASE
|
|
||||||
assert normalized.generation == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_unprovisioned_create_new_stays_create_new() -> None:
|
|
||||||
assert runtime(head=None)._normalize_runtime_lease(lease()).branch_mode == "CREATE_NEW"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("field", "value"),
|
|
||||||
(
|
|
||||||
("repository", "other/repo"),
|
|
||||||
("base_oid", "b" * 40),
|
|
||||||
("expected_branch_oid", "b" * 40),
|
|
||||||
("target_branch", "agent/foreign"),
|
|
||||||
("generation", 2),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
def test_binding_mismatch_fails_closed(field: str, value: object) -> None:
|
|
||||||
with pytest.raises(RuntimeError, match="LEASE_BINDING_CONFLICT"):
|
|
||||||
runtime(**{field: value})._normalize_runtime_lease(lease())
|
|
||||||
|
|
||||||
|
|
||||||
def test_drifted_existing_head_fails_closed() -> None:
|
|
||||||
with pytest.raises(RuntimeError, match="EXPECTED_BRANCH_OID_CONFLICT"):
|
|
||||||
runtime(head="b" * 40)._normalize_runtime_lease(lease())
|
|
||||||
|
|
||||||
|
|
||||||
def test_openapi_contract_parses_and_requires_exact_commit_lineage() -> None:
|
|
||||||
document = yaml.safe_load(
|
|
||||||
Path("docs/gitops/openapi-chatgpt-actions.yaml").read_text(encoding="utf-8")
|
|
||||||
)
|
|
||||||
schema = document["components"]["schemas"]["BridgeCommitResponse"]
|
|
||||||
assert {
|
|
||||||
"job_id",
|
|
||||||
"commit_oid",
|
|
||||||
"commit_visible",
|
|
||||||
"branch_mode",
|
|
||||||
"target_branch",
|
|
||||||
"generation",
|
|
||||||
} <= set(schema["required"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_changed_production_files_pass_secret_scan() -> None:
|
|
||||||
paths = [
|
|
||||||
Path("active-runtime/app/aios_authority_git_executor.py"),
|
|
||||||
Path("active-runtime/app/aios_chatgpt_bridge_v2_server.py"),
|
|
||||||
Path("active-runtime/app/aios_continuation_executor.py"),
|
|
||||||
Path("active-runtime/app/aios_continuation_lease.py"),
|
|
||||||
Path("active-runtime/app/authorized_task_writer_provisioning.py"),
|
|
||||||
Path("active-runtime/app/aios_chatgpt_bridge_v2.py"),
|
|
||||||
Path("docs/gitops/openapi-chatgpt-actions.yaml"),
|
|
||||||
]
|
|
||||||
expression = re.compile(
|
|
||||||
r"(?i)(BEGIN (RSA|OPENSSH|EC) PRIVATE KEY|"
|
|
||||||
r"authorization:\\s*bearer\\s+[A-Za-z0-9._-]{12,}|"
|
|
||||||
r"api[_-]?key\\s*[:=]\\s*['\\\"][^'\\\"]{12,})"
|
|
||||||
)
|
|
||||||
hits = []
|
|
||||||
for path in paths:
|
|
||||||
for number, line in enumerate(
|
|
||||||
path.read_text(encoding="utf-8", errors="replace").splitlines(), 1
|
|
||||||
):
|
|
||||||
if expression.search(line):
|
|
||||||
hits.append(f"{path}:{number}")
|
|
||||||
assert hits == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_remote_verified_job_is_not_completed_when_head_cas_callback_fails() -> None:
|
|
||||||
class Store:
|
|
||||||
def read(self, _job_id):
|
|
||||||
return {
|
|
||||||
"branch": BRANCH,
|
|
||||||
"request_json": json.dumps(
|
|
||||||
{"binding": {"expected_branch_oid": BASE}}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
class Controller:
|
|
||||||
def __init__(self):
|
|
||||||
self.store = Store()
|
|
||||||
self.states = []
|
|
||||||
|
|
||||||
def advance(self, _job_id, state, **_kwargs):
|
|
||||||
self.states.append(state)
|
|
||||||
return SimpleNamespace(as_dict=lambda: {"state": state.value})
|
|
||||||
|
|
||||||
executor = ContinuationExecutor.__new__(ContinuationExecutor)
|
|
||||||
executor.controller = Controller()
|
|
||||||
executor._observe_remote = lambda _branch, oid: (oid, oid, True)
|
|
||||||
|
|
||||||
def fail_cas(_oid):
|
|
||||||
raise RuntimeError("LEASE_CAS_CONFLICT")
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="LEASE_CAS_CONFLICT"):
|
|
||||||
executor._finish_remote_verification(
|
|
||||||
JOB,
|
|
||||||
"b" * 40,
|
|
||||||
"b" * 40,
|
|
||||||
before_complete=fail_cas,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert executor.controller.states == [JobState.REMOTE_VERIFIED]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("branch_mode", ("CREATE_NEW", "CONTINUE_EXISTING"))
|
|
||||||
def test_executor_preflight_accepts_the_exact_mode_dependent_normalized_shape(
|
|
||||||
branch_mode: str,
|
|
||||||
) -> None:
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from aios_continuation_executor import (
|
|
||||||
ContinuationPermissions,
|
|
||||||
ContinuationRuntimeBinding,
|
|
||||||
)
|
|
||||||
|
|
||||||
request = lease(branch_mode=branch_mode)
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
executor = ContinuationExecutor.__new__(ContinuationExecutor)
|
|
||||||
executor.authority = Authority(None if branch_mode == "CREATE_NEW" else BASE)
|
|
||||||
executor.now = lambda: now
|
|
||||||
executor._max_generation = lambda _branch, *, exclude_job_id=None: 0
|
|
||||||
executor.workspace = SimpleNamespace(
|
|
||||||
is_detached=lambda: True,
|
|
||||||
head_oid=lambda: BASE,
|
|
||||||
)
|
|
||||||
executor.remote = SimpleNamespace(repository_identity=lambda: REPO)
|
|
||||||
binding = ContinuationRuntimeBinding(
|
|
||||||
request=request,
|
|
||||||
job_id=JOB,
|
|
||||||
workspace_path=Path("/tmp/continuation-normalization-test"),
|
|
||||||
lease_not_before=now - timedelta(seconds=1),
|
|
||||||
lease_expires_at=now + timedelta(seconds=60),
|
|
||||||
original_receipt_valid=True,
|
|
||||||
original_grant_consumed=True,
|
|
||||||
permissions=ContinuationPermissions(),
|
|
||||||
)
|
|
||||||
|
|
||||||
executor._validate_preflight(binding, job_id=JOB)
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_new_executor_resumes_executing_and_uses_absence_bound_writers() -> None:
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from aios_continuation_executor import (
|
|
||||||
ContinuationCommit,
|
|
||||||
ContinuationPermissions,
|
|
||||||
ContinuationRuntimeBinding,
|
|
||||||
)
|
|
||||||
|
|
||||||
new_oid = "b" * 40
|
|
||||||
|
|
||||||
class Store:
|
|
||||||
def read(self, _job_id: str) -> dict[str, object]:
|
|
||||||
return {"state": "EXECUTING", "revision": 4}
|
|
||||||
|
|
||||||
class Controller:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.store = Store()
|
|
||||||
self.advanced: list[str] = []
|
|
||||||
|
|
||||||
def advance(self, _job_id: str, state: JobState, **_kwargs: object) -> object:
|
|
||||||
self.advanced.append(state.value)
|
|
||||||
return SimpleNamespace(as_dict=lambda: {"state": state.value})
|
|
||||||
|
|
||||||
class Workspace:
|
|
||||||
def commit_files(self, _files: object, _message: str) -> str:
|
|
||||||
return new_oid
|
|
||||||
|
|
||||||
class CreateAuthority:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.created: list[tuple[str, str, str]] = []
|
|
||||||
|
|
||||||
def is_ancestor(self, older: str, newer: str) -> bool:
|
|
||||||
return older == BASE and newer == new_oid
|
|
||||||
|
|
||||||
def create_task_branch_cas(self, branch: str, base: str, new: str) -> str:
|
|
||||||
self.created.append((branch, base, new))
|
|
||||||
return new
|
|
||||||
|
|
||||||
def compare_and_swap_branch(self, *_args: object) -> str:
|
|
||||||
raise AssertionError("existing-branch CAS must not serve CREATE_NEW")
|
|
||||||
|
|
||||||
class CreateRemote:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.created: list[tuple[str, str]] = []
|
|
||||||
|
|
||||||
def push_new_exact(self, branch: str, new: str) -> None:
|
|
||||||
self.created.append((branch, new))
|
|
||||||
|
|
||||||
def push_exact(self, *_args: object) -> None:
|
|
||||||
raise AssertionError("existing-branch push must not serve CREATE_NEW")
|
|
||||||
|
|
||||||
class Credential:
|
|
||||||
def project(self) -> None: pass
|
|
||||||
def revoke(self) -> None: pass
|
|
||||||
|
|
||||||
controller=Controller(); authority=CreateAuthority(); remote=CreateRemote()
|
|
||||||
executor=ContinuationExecutor(controller,authority,Workspace(),remote,Credential())
|
|
||||||
executor._validate_preflight=lambda *_args,**_kwargs: None
|
|
||||||
executor._finish_remote_verification=lambda *_args,**_kwargs: {
|
|
||||||
"job_id":JOB,"state":"COMPLETED","receipt":{"authority_oid":new_oid,"commit_visible":True}
|
|
||||||
}
|
|
||||||
now=datetime.now(timezone.utc)
|
|
||||||
binding=ContinuationRuntimeBinding(
|
|
||||||
request=lease(branch_mode="CREATE_NEW"),job_id=JOB,
|
|
||||||
workspace_path=Path("/tmp/create-new-executor-resume"),
|
|
||||||
lease_not_before=now-timedelta(seconds=1),lease_expires_at=now+timedelta(seconds=60),
|
|
||||||
original_receipt_valid=True,original_grant_consumed=True,
|
|
||||||
permissions=ContinuationPermissions(),
|
|
||||||
)
|
|
||||||
result=executor.execute(
|
|
||||||
binding,ContinuationCommit("create-new-test",{"docs/test.txt":b"ok"},"test")
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["state"]=="COMPLETED"
|
|
||||||
assert authority.created==[(BRANCH,BASE,new_oid)]
|
|
||||||
assert remote.created==[(BRANCH,new_oid)]
|
|
||||||
assert controller.advanced==["LOCAL_VERIFIED","REMOTE_SYNCING"]
|
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from aios_existing_branch_publisher import ExistingBranchPublicationState, ExistingBranchPublisher
|
||||||
|
from gitea_tag_publication import GiteaConfigurationError, GiteaTagClient, InvalidReleaseVersion, PublicationRequest, TagPublisher, validate_version
|
||||||
|
from signer_contract_compatibility import run_compatible_signer
|
||||||
|
|
||||||
|
|
||||||
|
class Authority:
|
||||||
|
def __init__(self, oid: str):
|
||||||
|
self.oid = oid
|
||||||
|
def read_ref(self, ref: str) -> str | None:
|
||||||
|
return self.oid if ref.startswith("refs/heads/") else None
|
||||||
|
|
||||||
|
|
||||||
|
class Remote:
|
||||||
|
def __init__(self, oid: str | None = None, api_oid: str | None = None, visible: bool = True):
|
||||||
|
self.oid = oid
|
||||||
|
self.api_oid = api_oid
|
||||||
|
self.visible = visible
|
||||||
|
self.pushes: list[tuple[str, str | None, str]] = []
|
||||||
|
def repository_identity(self) -> str:
|
||||||
|
return "aios/aios"
|
||||||
|
def read_git_ref(self, branch: str) -> str | None:
|
||||||
|
return self.oid
|
||||||
|
def read_api_branch(self, branch: str) -> str | None:
|
||||||
|
return self.api_oid
|
||||||
|
def commit_visible(self, oid: str) -> bool:
|
||||||
|
return self.visible
|
||||||
|
def push_exact(self, branch: str, expected: str | None, new: str) -> None:
|
||||||
|
assert expected is None
|
||||||
|
self.pushes.append((branch, expected, new))
|
||||||
|
self.oid = new
|
||||||
|
self.api_oid = new
|
||||||
|
self.visible = True
|
||||||
|
|
||||||
|
|
||||||
|
def request(oid: str, key: str = "gate-closure-test-0001") -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"branch": "agent/gate-closure-test",
|
||||||
|
"expected_branch_oid": oid,
|
||||||
|
"owner_approval_reference": "owner-authorization:test",
|
||||||
|
"idempotency_key": key,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_branch_publisher_creates_exact_missing_remote_and_replays_idempotently(tmp_path: Path):
|
||||||
|
oid = "a" * 40
|
||||||
|
remote = Remote()
|
||||||
|
publisher = ExistingBranchPublisher(Authority(oid), remote, tmp_path / "state")
|
||||||
|
first = publisher.publish(request(oid))
|
||||||
|
assert first["publication_state"] == ExistingBranchPublicationState.COMPLETED.value
|
||||||
|
assert remote.pushes == [("agent/gate-closure-test", None, oid)]
|
||||||
|
second = publisher.publish(request(oid))
|
||||||
|
assert second["publication_state"] == ExistingBranchPublicationState.COMPLETED.value
|
||||||
|
assert second["idempotent"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_branch_publisher_conflict_is_fail_closed(tmp_path: Path):
|
||||||
|
oid = "a" * 40
|
||||||
|
remote = Remote(oid="b" * 40, api_oid="b" * 40)
|
||||||
|
result = ExistingBranchPublisher(Authority(oid), remote, tmp_path / "state").publish(request(oid, "gate-closure-test-0002"))
|
||||||
|
assert result["publication_state"] == ExistingBranchPublicationState.MANUAL_OWNER_REVIEW_REQUIRED.value
|
||||||
|
assert remote.pushes == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_branch_publisher_rejects_main_and_wrong_authority_oid(tmp_path: Path):
|
||||||
|
oid = "a" * 40
|
||||||
|
with pytest.raises(Exception, match="BRANCH_NAMESPACE_REJECTED"):
|
||||||
|
ExistingBranchPublisher(Authority(oid), Remote(), tmp_path / "state").publish({**request(oid), "branch": "main", "idempotency_key": "gate-closure-test-0003"})
|
||||||
|
with pytest.raises(Exception, match="AUTHORITY_BRANCH_OID_MISMATCH"):
|
||||||
|
ExistingBranchPublisher(Authority("b" * 40), Remote(), tmp_path / "state").publish(request(oid, "gate-closure-test-0004"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_signer_broker_path_has_no_direct_root_signer_call():
|
||||||
|
class FakeBroker:
|
||||||
|
def self_test(self):
|
||||||
|
return None
|
||||||
|
def sign(self, payload_kind, payload):
|
||||||
|
return b"signature", "test-broker-key"
|
||||||
|
request_doc = {
|
||||||
|
"schema": "aios.task-scoped-writer-grant-request.v1",
|
||||||
|
"task_id": "gate-closure-signer-unit",
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"base": {"ref": "refs/heads/main", "oid": "a" * 40},
|
||||||
|
"target": {"branch": "agent/gate-closure-signer-unit"},
|
||||||
|
"owner_authorization": {"identity": "aiman", "reference": "owner-authorization:test"},
|
||||||
|
"authorization": {
|
||||||
|
"production_change_authorized": False,
|
||||||
|
"tag_change_authorized": False,
|
||||||
|
"baseline_change_authorized": False,
|
||||||
|
"main_write_authorized": False,
|
||||||
|
},
|
||||||
|
"allowed_operations": ["create_worktree"],
|
||||||
|
"denied_operations": ["direct_main_push", "force_push"],
|
||||||
|
"one_time_use": True,
|
||||||
|
"not_before": "2026-08-10T00:00:00Z",
|
||||||
|
"expires_at": "2026-08-10T00:00:00Z",
|
||||||
|
"nonce": "unit-nonce",
|
||||||
|
}
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
root = Path(td)
|
||||||
|
grant = root / "grant"
|
||||||
|
grant.mkdir()
|
||||||
|
(grant / "grant-request.json").write_text(json.dumps(request_doc), encoding="utf-8")
|
||||||
|
(grant / "approval-plan.json").write_text(json.dumps({"approval_plan_id": "sha256:" + "a" * 64}), encoding="utf-8")
|
||||||
|
with patch("signer_contract_compatibility._supports_deployed_signer", return_value=True):
|
||||||
|
result = run_compatible_signer(["unused-signer-command"], grant, root / "result.json", broker=FakeBroker())
|
||||||
|
assert result["signing_key_identity"] == "test-broker-key"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gitea_configuration_has_explicit_error_codes():
|
||||||
|
with pytest.raises(GiteaConfigurationError, match="GITEA_API_URL_MISSING"):
|
||||||
|
GiteaTagClient.from_authoritative_environment({})
|
||||||
|
with pytest.raises(GiteaConfigurationError, match="GITEA_REPOSITORY_MISSING"):
|
||||||
|
GiteaTagClient.from_authoritative_environment({"AIOS_GITOPS_GITEA_API_URL": "https://gitea.invalid/api/v1"})
|
||||||
|
with pytest.raises(GiteaConfigurationError, match="GITEA_TOKEN_REFERENCE_MISSING"):
|
||||||
|
GiteaTagClient.from_authoritative_environment({"AIOS_GITOPS_GITEA_API_URL": "https://gitea.invalid/api/v1", "AIOS_GITOPS_GITEA_REPOSITORY": "aios/aios"})
|
||||||
|
assert validate_version("V2R2C1") == "V2R2C1"
|
||||||
|
with pytest.raises(InvalidReleaseVersion, match="invalid release version"):
|
||||||
|
validate_version("not-a-release")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_consistency_reports_tag_target_mismatch(tmp_path: Path):
|
||||||
|
target = "a" * 40
|
||||||
|
evidence = "b" * 40
|
||||||
|
tag_oid = "c" * 40
|
||||||
|
mismatched_target = "d" * 40
|
||||||
|
request = PublicationRequest("V2R2C1", target, evidence, "approval-plan:test")
|
||||||
|
(tmp_path / "baseline").mkdir()
|
||||||
|
(tmp_path / "versions").mkdir()
|
||||||
|
common = {
|
||||||
|
"version": request.version,
|
||||||
|
"target_oid": request.target_oid,
|
||||||
|
"evidence_closure_oid": request.evidence_closure_oid,
|
||||||
|
"approval_plan_id": request.approval_plan_id,
|
||||||
|
"tag_oid": tag_oid,
|
||||||
|
}
|
||||||
|
(tmp_path / "baseline/current.json").write_text(
|
||||||
|
json.dumps({**common, "current_baseline": request.version}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
(tmp_path / "versions/V2R2C1.json").write_text(
|
||||||
|
json.dumps({**common, "registered": True, "enabled": True}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
verified = {
|
||||||
|
**common,
|
||||||
|
"remote_tag_oid": tag_oid,
|
||||||
|
"remote_peeled_target_oid": mismatched_target,
|
||||||
|
"gitea_tag_oid": tag_oid,
|
||||||
|
"gitea_peeled_target_oid": target,
|
||||||
|
"gitea_listing_visible": True,
|
||||||
|
"repository_identity": "aios/aios",
|
||||||
|
}
|
||||||
|
result = TagPublisher(tmp_path, object(), object(), "gitea").get_release_consistency(
|
||||||
|
request, verified=verified
|
||||||
|
)
|
||||||
|
assert result["status"] == "FAIL"
|
||||||
|
assert result["error"] == "TAG_TARGET_MISMATCH"
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
ROOT=Path(__file__).resolve().parents[1]
|
|
||||||
APP=ROOT/"active-runtime/app"
|
|
||||||
sys.path.insert(0,str(APP))
|
|
||||||
|
|
||||||
from aios_external_agent_runtime import (
|
|
||||||
ExternalAgentRuntimeError,
|
|
||||||
RuntimeProfile,
|
|
||||||
execute_request,
|
|
||||||
)
|
|
||||||
from aios_online_agent_execution import (
|
|
||||||
ExecutionProjectionError,
|
|
||||||
GovernedExecutionBinding,
|
|
||||||
project_online_task,
|
|
||||||
validate_execution_envelope,
|
|
||||||
)
|
|
||||||
import aios_online_agent_worker as worker
|
|
||||||
|
|
||||||
|
|
||||||
BASE="a"*40
|
|
||||||
|
|
||||||
|
|
||||||
def binding(task_id="task-hermes-runtime"):
|
|
||||||
return GovernedExecutionBinding.from_mapping({
|
|
||||||
"task_id":task_id,
|
|
||||||
"job_id":"job-"+task_id,
|
|
||||||
"repository":"aios/aios",
|
|
||||||
"base_oid":BASE,
|
|
||||||
"expected_branch_oid":BASE,
|
|
||||||
"target_branch":"agent/"+task_id,
|
|
||||||
"branch_mode":"CREATE_NEW",
|
|
||||||
"generation":1,
|
|
||||||
"idempotency_key":task_id+"-binding-v1",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def task_request(task_id, runtime):
|
|
||||||
return {
|
|
||||||
"task_id":task_id,
|
|
||||||
"goal":"Summarize the requested operation.",
|
|
||||||
"instructions":"Return a concise result.",
|
|
||||||
"success_criteria":[{"id":"done","description":"A result is returned."}],
|
|
||||||
"constraints":{"network":"runtime-managed"},
|
|
||||||
"required_capabilities":[f"agent.runtime.{runtime}"],
|
|
||||||
"max_attempts":3,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def projected(task_id="task-hermes-runtime", runtime="hermes"):
|
|
||||||
return project_online_task(
|
|
||||||
binding(task_id),
|
|
||||||
task_request(task_id,runtime),
|
|
||||||
{
|
|
||||||
"action":"agent_runtime",
|
|
||||||
"adapter":runtime,
|
|
||||||
"capability_scope":[f"agent.runtime.{runtime}"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_secret_env(monkeypatch):
|
|
||||||
for key in list(os.environ):
|
|
||||||
upper=key.upper()
|
|
||||||
if any(marker in upper for marker in (
|
|
||||||
"TOKEN","SECRET","PASSWORD","CREDENTIAL","AUTHORIZATION","PRIVATE_KEY"
|
|
||||||
)):
|
|
||||||
monkeypatch.delenv(key,raising=False)
|
|
||||||
monkeypatch.setenv("CREDENTIALS_DIRECTORY","/definitely-not-present-aios-test")
|
|
||||||
|
|
||||||
|
|
||||||
def make_helper(tmp_path):
|
|
||||||
helper=tmp_path/"runtime_helper.py"
|
|
||||||
helper.write_text(
|
|
||||||
"import json,sys\n"
|
|
||||||
"value=json.load(sys.stdin)\n"
|
|
||||||
"print(json.dumps({'answer':'ok','goal':value['goal']},sort_keys=True))\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
return helper
|
|
||||||
|
|
||||||
|
|
||||||
def make_profile(tmp_path,runtime,helper):
|
|
||||||
root=tmp_path/"profiles";root.mkdir(exist_ok=True)
|
|
||||||
(root/f"{runtime}.json").write_text(
|
|
||||||
json.dumps({
|
|
||||||
"schema":"aios.external-agent-runtime.profile.v1",
|
|
||||||
"runtime":runtime,
|
|
||||||
"argv":["/usr/bin/python3",str(helper)],
|
|
||||||
"input_mode":"stdin_json",
|
|
||||||
"output_mode":"stdout_json",
|
|
||||||
"timeout_seconds":30,
|
|
||||||
"max_output_bytes":65536,
|
|
||||||
}),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
return root
|
|
||||||
|
|
||||||
|
|
||||||
def test_agent_runtime_projection_is_read_only_and_task_bound():
|
|
||||||
value=projected()
|
|
||||||
execution=value["metadata"]["execution"]
|
|
||||||
assert execution["action"]=="agent_runtime"
|
|
||||||
assert execution["adapter"]=="hermes"
|
|
||||||
assert execution["runtime_input"]["goal"]==value["goal"]
|
|
||||||
assert execution["runtime_input"]["instructions"]==value["instructions"]
|
|
||||||
assert execution["governance"]["writer_lease"] is False
|
|
||||||
assert execution["change_set"]==[]
|
|
||||||
assert execution["verification"]==[]
|
|
||||||
validate_execution_envelope(
|
|
||||||
execution,
|
|
||||||
binding=binding(),
|
|
||||||
worker_capabilities=["agent.runtime.hermes"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_agent_runtime_projection_requires_runtime_capability():
|
|
||||||
with pytest.raises(ExecutionProjectionError,match="AGENT_RUNTIME_CAPABILITY_REQUIRED"):
|
|
||||||
project_online_task(
|
|
||||||
binding(),
|
|
||||||
task_request("task-hermes-runtime","hermes"),
|
|
||||||
{
|
|
||||||
"action":"agent_runtime",
|
|
||||||
"adapter":"hermes",
|
|
||||||
"capability_scope":["git.read"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_agent_runtime_projection_rejects_mutation():
|
|
||||||
with pytest.raises(ExecutionProjectionError,match="AGENT_RUNTIME_MUTATION_DENY"):
|
|
||||||
project_online_task(
|
|
||||||
binding(),
|
|
||||||
task_request("task-hermes-runtime","hermes"),
|
|
||||||
{
|
|
||||||
"action":"agent_runtime",
|
|
||||||
"adapter":"hermes",
|
|
||||||
"capability_scope":["agent.runtime.hermes"],
|
|
||||||
"change_set":[{"path":"x.py","operation":"write_file","content":"x=1\n"}],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_agent_runtime_projection_rejects_secret_constraint_keys():
|
|
||||||
request=task_request("task-hermes-runtime","hermes")
|
|
||||||
request["constraints"]={"api_token":"do-not-project"}
|
|
||||||
with pytest.raises(ExecutionProjectionError,match="AGENT_RUNTIME_SECRET_FIELD_DENY"):
|
|
||||||
project_online_task(
|
|
||||||
binding(),
|
|
||||||
request,
|
|
||||||
{
|
|
||||||
"action":"agent_runtime",
|
|
||||||
"adapter":"hermes",
|
|
||||||
"capability_scope":["agent.runtime.hermes"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("runtime",["hermes","openclaw"])
|
|
||||||
def test_external_executor_runs_root_owned_profile_without_shell(tmp_path,monkeypatch,runtime):
|
|
||||||
clear_secret_env(monkeypatch)
|
|
||||||
helper=make_helper(tmp_path)
|
|
||||||
profile_root=make_profile(tmp_path,runtime,helper)
|
|
||||||
request={
|
|
||||||
"schema":"aios.external-agent-runtime.request.v1",
|
|
||||||
"request_id":f"external-{runtime}-0123456789abcdef",
|
|
||||||
"runtime":runtime,
|
|
||||||
"task_id":f"task-{runtime}-runtime",
|
|
||||||
"execution_digest":"d"*64,
|
|
||||||
"payload":{
|
|
||||||
"goal":"Return ok.",
|
|
||||||
"instructions":"No side effects.",
|
|
||||||
"success_criteria":[],
|
|
||||||
"constraints":{},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
request_path=tmp_path/"request.json"
|
|
||||||
result_path=tmp_path/"result.json"
|
|
||||||
request_path.write_text(json.dumps(request),encoding="utf-8")
|
|
||||||
execute_request(request_path,result_path,profile_root=profile_root)
|
|
||||||
result=json.loads(result_path.read_text())
|
|
||||||
assert result["runtime"]==runtime
|
|
||||||
assert result["exit_code"]==0
|
|
||||||
assert result["output_json"]["answer"]=="ok"
|
|
||||||
assert result["credential_material_exposed"] is False
|
|
||||||
assert result["control_plane_access_granted"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_profile_rejects_shell_mode(tmp_path):
|
|
||||||
root=tmp_path/"profiles";root.mkdir()
|
|
||||||
(root/"hermes.json").write_text(json.dumps({
|
|
||||||
"schema":"aios.external-agent-runtime.profile.v1",
|
|
||||||
"runtime":"hermes",
|
|
||||||
"argv":["/usr/bin/python3","-c","print('x')"],
|
|
||||||
"input_mode":"stdin_text",
|
|
||||||
"output_mode":"stdout_text",
|
|
||||||
}))
|
|
||||||
with pytest.raises(ExternalAgentRuntimeError,match="EXTERNAL_AGENT_SHELL_MODE_DENY"):
|
|
||||||
RuntimeProfile.load("hermes",root)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("runtime,capability",[
|
|
||||||
("hermes","agent.runtime.hermes"),
|
|
||||||
("openclaw","agent.runtime.openclaw"),
|
|
||||||
])
|
|
||||||
def test_worker_external_adapter_builds_readonly_result(monkeypatch,tmp_path,runtime,capability):
|
|
||||||
mapped=projected(f"task-{runtime}-runtime",runtime)
|
|
||||||
envelope=worker.ExecutionEnvelope.from_task(
|
|
||||||
mapped,
|
|
||||||
worker_capabilities=(capability,),
|
|
||||||
)
|
|
||||||
class FakeExchange:
|
|
||||||
def __init__(self,*args,**kwargs): pass
|
|
||||||
def execute(self,**kwargs):
|
|
||||||
return {
|
|
||||||
"schema":"aios.external-agent-runtime.result.v1",
|
|
||||||
"request_id":kwargs["request_id"],
|
|
||||||
"request_sha256":"e"*64,
|
|
||||||
"runtime":runtime,
|
|
||||||
"task_id":kwargs["task_id"],
|
|
||||||
"execution_digest":kwargs["execution_digest"],
|
|
||||||
"exit_code":0,
|
|
||||||
"duration_ms":10,
|
|
||||||
"output_text":"done",
|
|
||||||
"output_sha256":"f"*64,
|
|
||||||
"stderr_sha256":"0"*64,
|
|
||||||
"credential_material_exposed":False,
|
|
||||||
"control_plane_access_granted":False,
|
|
||||||
"result_sha256":"1"*64,
|
|
||||||
}
|
|
||||||
monkeypatch.setattr(worker,"ExternalAgentExchange",FakeExchange)
|
|
||||||
monkeypatch.setattr(worker,"audit",lambda *a,**k:None)
|
|
||||||
executor=worker.HermesExecutor() if runtime=="hermes" else worker.OpenClawExecutor()
|
|
||||||
execution=executor.execute(envelope,tmp_path)
|
|
||||||
result=worker.build_agent_runtime_result(envelope.task_id,envelope,execution)
|
|
||||||
assert result["status"]=="SUCCEEDED"
|
|
||||||
assert result["classification"]=="EXTERNAL_AGENT_RUNTIME_SUCCEEDED"
|
|
||||||
assert result["execution_mode"]=="agent_runtime"
|
|
||||||
assert result["agent_runtime"]==runtime
|
|
||||||
assert result["writer_lease"]=="NONE"
|
|
||||||
assert result["changed_files"]==[]
|
|
||||||
assert result["control_plane_access_granted_to_runtime"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_worker_adapter_filter_defaults_exclude_external_runtimes():
|
|
||||||
assert "hermes" not in worker.WORKER_ALLOWED_ADAPTERS
|
|
||||||
assert "openclaw" not in worker.WORKER_ALLOWED_ADAPTERS
|
|
||||||
|
|
||||||
|
|
||||||
def test_external_executor_systemd_has_no_control_credentials_or_repository_access():
|
|
||||||
unit=(ROOT/"deploy/systemd/aios-external-agent-executor.service").read_text()
|
|
||||||
assert "User=aios-engineering-executor" in unit
|
|
||||||
assert "LoadCredential" not in unit
|
|
||||||
assert "InaccessiblePaths=/run/credentials" in unit
|
|
||||||
assert "InaccessiblePaths=/etc/aios-gitops" in unit
|
|
||||||
assert "InaccessiblePaths=/data/aios-gitops" in unit
|
|
||||||
assert "IPAddressDeny=127.0.0.0/8" in unit
|
|
||||||
assert "IPAddressDeny=::1/128" in unit
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"unit_name,capability,adapter,state_root",
|
|
||||||
[
|
|
||||||
(
|
|
||||||
"aios-hermes-online-agent-worker.service",
|
|
||||||
"agent.runtime.hermes",
|
|
||||||
"hermes",
|
|
||||||
"/var/lib/aios-online-agent-hermes",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"aios-openclaw-online-agent-worker.service",
|
|
||||||
"agent.runtime.openclaw",
|
|
||||||
"openclaw",
|
|
||||||
"/var/lib/aios-online-agent-openclaw",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_dedicated_worker_units_are_capability_and_adapter_scoped(
|
|
||||||
unit_name,capability,adapter,state_root
|
|
||||||
):
|
|
||||||
unit=(ROOT/"deploy/systemd"/unit_name).read_text()
|
|
||||||
assert f"Environment=AIOS_WORKER_CAPABILITIES={capability}" in unit
|
|
||||||
assert f"Environment=AIOS_WORKER_ALLOWED_ADAPTERS={adapter}" in unit
|
|
||||||
assert f"Environment=AIOS_WORKER_STATE_ROOT={state_root}" in unit
|
|
||||||
assert "AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892" in unit
|
|
||||||
assert "LoadCredentialEncrypted=agent_token:" in unit
|
|
||||||
|
|
||||||
|
|
||||||
def test_example_profiles_are_non_authoritative_examples_only():
|
|
||||||
for runtime in ("hermes","openclaw"):
|
|
||||||
value=json.loads(
|
|
||||||
(ROOT/"config/external-agent-runtime.d"/f"{runtime}.example.json").read_text()
|
|
||||||
)
|
|
||||||
assert value["schema"]=="aios.external-agent-runtime.profile.v1"
|
|
||||||
assert value["runtime"]==runtime
|
|
||||||
assert value["input_mode"]=="stdin_text"
|
|
||||||
assert value["output_mode"]=="stdout_text"
|
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
APP = Path(__file__).resolve().parents[1] / "active-runtime" / "app"
|
||||||
|
sys.path.insert(0, str(APP))
|
||||||
|
|
||||||
|
import governed_production_release as release # noqa: E402
|
||||||
|
from version_registry import build_source_registry_entry, source_registry_json # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _git(work: Path, *args: str) -> str:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", "-C", str(work), *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _commit(work: Path, message: str) -> str:
|
||||||
|
subprocess.run(["git", "-C", str(work), "add", "-A"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "commit", "-qm", message], check=True)
|
||||||
|
return _git(work, "rev-parse", "HEAD")
|
||||||
|
|
||||||
|
|
||||||
|
def test_artifact_uses_pinned_source_registry_commit_when_main_advances(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
if shutil.which("zstd") is None:
|
||||||
|
pytest.skip("zstd is required for governed artifact integration")
|
||||||
|
work = tmp_path / "work"
|
||||||
|
bare = tmp_path / "repo.git"
|
||||||
|
subprocess.run(["git", "init", "-q", "-b", "main", str(work)], check=True)
|
||||||
|
_git(work, "config", "user.name", "test")
|
||||||
|
_git(work, "config", "user.email", "test@example.invalid")
|
||||||
|
(work / "active-runtime/app").mkdir(parents=True)
|
||||||
|
(work / "active-runtime/app/deployment_state.py").write_text("state = 'candidate'\n")
|
||||||
|
(work / "active-runtime/app/v2r1c6_privacy_tools.py").write_text("def main(): return 0\n")
|
||||||
|
target_oid = _commit(work, "release target")
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(work), "tag", "-a", "V2R2C1", target_oid, "-m", "release"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
tag_oid = _git(work, "rev-parse", "refs/tags/V2R2C1")
|
||||||
|
(work / "evidence.txt").write_text("evidence\n")
|
||||||
|
evidence_oid = _commit(work, "evidence closure")
|
||||||
|
record = build_source_registry_entry(
|
||||||
|
version="V2R2C1",
|
||||||
|
tag_oid=tag_oid,
|
||||||
|
target_oid=target_oid,
|
||||||
|
peeled_target_oid=target_oid,
|
||||||
|
evidence_closure_oid=evidence_oid,
|
||||||
|
approval_plan_id="sha256:" + "4" * 64,
|
||||||
|
)
|
||||||
|
source_path = work / "versions/V2R2C1.json"
|
||||||
|
source_path.parent.mkdir(parents=True)
|
||||||
|
source_path.write_bytes(source_registry_json(record))
|
||||||
|
main_oid = _commit(work, "source registry convergence")
|
||||||
|
subprocess.run(["git", "clone", "-q", "--bare", str(work), str(bare)], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "remote", "add", "authority", str(bare)], check=True)
|
||||||
|
monkeypatch.setenv("AIOS_TEST_MODE", "1")
|
||||||
|
monkeypatch.setenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", str(bare))
|
||||||
|
|
||||||
|
monkeypatch.setattr(release, "TAG_OID", tag_oid)
|
||||||
|
monkeypatch.setattr(release, "TARGET_OID", target_oid)
|
||||||
|
monkeypatch.setattr(release, "EVIDENCE_CLOSURE_OID", evidence_oid)
|
||||||
|
monkeypatch.setattr(release, "APPROVAL_PLAN_ID", record["approval_plan_id"])
|
||||||
|
binding = release.ReleaseBinding(
|
||||||
|
tag_oid=tag_oid,
|
||||||
|
target_oid=target_oid,
|
||||||
|
evidence_closure_oid=evidence_oid,
|
||||||
|
approval_plan_id=record["approval_plan_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
original_payload = release._payload_file
|
||||||
|
advanced = False
|
||||||
|
|
||||||
|
def advance_after_first_payload(git_dir: Path, oid: str, path: str) -> bytes:
|
||||||
|
nonlocal advanced
|
||||||
|
result = original_payload(git_dir, oid, path)
|
||||||
|
if not advanced and path == "active-runtime/app/deployment_state.py":
|
||||||
|
updated = dict(record)
|
||||||
|
updated["review_marker"] = "main-advanced-after-validation"
|
||||||
|
source_path.write_bytes(source_registry_json(updated))
|
||||||
|
new_main = _commit(work, "advance main after source validation")
|
||||||
|
subprocess.run(["git", "-C", str(work), "push", "-q", "authority", "main"], check=True)
|
||||||
|
assert new_main != main_oid
|
||||||
|
advanced = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
monkeypatch.setattr(release, "_payload_file", advance_after_first_payload)
|
||||||
|
receipt = release.build_production_artifact(bare, binding, tmp_path / "artifacts")
|
||||||
|
assert advanced is True
|
||||||
|
assert receipt["release_target_oid"] == target_oid
|
||||||
|
assert receipt["source_registry_main_oid"] == main_oid
|
||||||
|
assert receipt["source_registry_path"] == "versions/V2R2C1.json"
|
||||||
|
|
||||||
|
expected_bytes = subprocess.run(
|
||||||
|
["git", f"--git-dir={bare}", "show", f"{main_oid}:versions/V2R2C1.json"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
).stdout
|
||||||
|
assert receipt["source_registry_sha256"] == __import__("hashlib").sha256(expected_bytes).hexdigest()
|
||||||
|
compressed = Path(receipt["artifact"]).read_bytes()
|
||||||
|
raw_tar = subprocess.run(["zstd", "-dc", "-"], input=compressed, capture_output=True, check=True).stdout
|
||||||
|
with tarfile.open(fileobj=io.BytesIO(raw_tar), mode="r:") as archive:
|
||||||
|
packaged = archive.extractfile("payload/versions/V2R2C1.json").read()
|
||||||
|
metadata = json.loads(archive.extractfile("payload/BUILD-METADATA.json").read())
|
||||||
|
assert packaged == expected_bytes
|
||||||
|
assert metadata["source_registry_main_oid"] == main_oid
|
||||||
|
assert metadata["source_registry_sha256"] == receipt["source_registry_sha256"]
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
import hashlib,json,os,tarfile,sys,tempfile
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
ROOT=Path(__file__).resolve().parents[1]
|
|
||||||
sys.path.insert(0,str(ROOT/"active-runtime/app"))
|
|
||||||
import aios_isolated_executor as mod
|
|
||||||
|
|
||||||
def make_archive(tmp_path):
|
|
||||||
root=tmp_path/"src"; root.mkdir()
|
|
||||||
(root/"hello.py").write_text("VALUE = 1\n")
|
|
||||||
archive=tmp_path/"source.tgz"
|
|
||||||
with tarfile.open(archive,"w:gz") as tf: tf.add(root/"hello.py",arcname="hello.py")
|
|
||||||
return archive
|
|
||||||
|
|
||||||
def test_isolated_executor_applies_hash_bound_change(tmp_path,monkeypatch):
|
|
||||||
archive=make_archive(tmp_path)
|
|
||||||
monkeypatch.delenv("CREDENTIALS_DIRECTORY",raising=False)
|
|
||||||
monkeypatch.setattr(mod,"assert_uncredentialed",lambda:None)
|
|
||||||
request={
|
|
||||||
"schema":"aios.isolated-execution.request.v1",
|
|
||||||
"request_id":"task-req-0000000000000000",
|
|
||||||
"source_archive":str(archive),
|
|
||||||
"source_archive_sha256":mod.sha256(archive.read_bytes()),
|
|
||||||
"change_set":[{
|
|
||||||
"path":"hello.py","operation":"replace_text","old":"VALUE = 1","new":"VALUE = 2","count":1,
|
|
||||||
"expected_sha256":mod.sha256(b"VALUE = 1\n")
|
|
||||||
}],
|
|
||||||
"verification":[{"label":"compile","argv":["/usr/bin/python3","-m","py_compile","hello.py"],"cwd":".","timeout_seconds":30}]
|
|
||||||
}
|
|
||||||
req=tmp_path/"req.json"; res=tmp_path/"res.json"
|
|
||||||
req.write_text(json.dumps(request))
|
|
||||||
mod.execute_request(req,res)
|
|
||||||
out=json.loads(res.read_text())
|
|
||||||
assert out["request_id"]=="task-req-0000000000000000"
|
|
||||||
assert out["credential_isolation"]["credential_env_absent"] is True
|
|
||||||
changed=out["changed_files"][0]
|
|
||||||
assert changed["path"]=="hello.py" and changed["deleted"] is False
|
|
||||||
|
|
||||||
def test_isolated_executor_source_drift_denied(tmp_path,monkeypatch):
|
|
||||||
archive=make_archive(tmp_path); monkeypatch.setattr(mod,"assert_uncredentialed",lambda:None)
|
|
||||||
request={
|
|
||||||
"schema":"aios.isolated-execution.request.v1","request_id":"task-req-0000000000000001",
|
|
||||||
"source_archive":str(archive),
|
|
||||||
"source_archive_sha256":mod.sha256(archive.read_bytes()),
|
|
||||||
"change_set":[{"path":"hello.py","operation":"write_file","content":"x\n","expected_sha256":"0"*64}],
|
|
||||||
"verification":[]
|
|
||||||
}
|
|
||||||
req=tmp_path/"req.json"; res=tmp_path/"res.json"; req.write_text(json.dumps(request))
|
|
||||||
with pytest.raises(mod.IsolatedExecutorError,match="SOURCE_DRIFT"):
|
|
||||||
mod.execute_request(req,res)
|
|
||||||
|
|
||||||
def test_credential_boundary_eacces_is_accepted(monkeypatch):
|
|
||||||
class DeniedDirectory:
|
|
||||||
def exists(self): return True
|
|
||||||
def is_symlink(self): return False
|
|
||||||
def is_dir(self): return True
|
|
||||||
def iterdir(self): raise PermissionError(13,"permission denied")
|
|
||||||
monkeypatch.setattr(mod.os,"access",lambda *_args,**_kwargs:False)
|
|
||||||
assert mod._credential_exposure(DeniedDirectory()) is None
|
|
||||||
|
|
||||||
def test_systemd_unit_has_no_credentials_and_private_network():
|
|
||||||
unit=(ROOT/"deploy/systemd/aios-online-agent-isolated-executor.service").read_text()
|
|
||||||
assert "PrivateNetwork=true" in unit
|
|
||||||
assert "InaccessiblePaths=/run/credentials" in unit
|
|
||||||
assert "LoadCredential" not in unit
|
|
||||||
assert "SupplementaryGroups=gitops" not in unit
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import datetime as dt
|
|
||||||
import pathlib
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
APP = pathlib.Path(__file__).resolve().parents[1] / "active-runtime" / "app"
|
|
||||||
if str(APP) not in sys.path:
|
|
||||||
sys.path.insert(0, str(APP))
|
|
||||||
|
|
||||||
from message_handoff import (
|
|
||||||
ConfidentialityScopeDenied,
|
|
||||||
DeliveryContext,
|
|
||||||
DurableHandoffStore,
|
|
||||||
HandoffFabric,
|
|
||||||
IdempotencyConflict,
|
|
||||||
MessageEnvelope,
|
|
||||||
MessageExpired,
|
|
||||||
MessageScopeMismatch,
|
|
||||||
SequenceGap,
|
|
||||||
authorize_delivery,
|
|
||||||
bind_envelope_scope,
|
|
||||||
stable_event_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def actor(**overrides):
|
|
||||||
values = dict(
|
|
||||||
tenant_id="tenant-a", user_id="user-a", project_id="project-a",
|
|
||||||
conversation_id="conversation-a", run_id="run-a",
|
|
||||||
capabilities=("task.read", "message.read"),
|
|
||||||
)
|
|
||||||
values.update(overrides)
|
|
||||||
return DeliveryContext(**values)
|
|
||||||
|
|
||||||
|
|
||||||
def envelope(**overrides):
|
|
||||||
values = dict(
|
|
||||||
source="agent-a", target="agent-b", message_type="handoff.request", payload={"value": 1},
|
|
||||||
tenant_id="tenant-a", user_id="user-a", project_id="project-a",
|
|
||||||
conversation_id="conversation-a", task_id="task-a", run_id="run-a",
|
|
||||||
sequence=1, idempotency_key="idem-1", capability_scope=("message.read",),
|
|
||||||
confidentiality_scope="private",
|
|
||||||
)
|
|
||||||
values.update(overrides)
|
|
||||||
return MessageEnvelope(**values)
|
|
||||||
|
|
||||||
|
|
||||||
def test_MESSAGE_TARGET_COLLISION_DENY():
|
|
||||||
a = stable_event_id(source="a", target="user-a", message_type="event", payload={"x": 1},
|
|
||||||
tenant_id="tenant-a", sequence=1, idempotency_key="same")
|
|
||||||
b = stable_event_id(source="a", target="user-b", message_type="event", payload={"x": 1},
|
|
||||||
tenant_id="tenant-a", sequence=1, idempotency_key="same")
|
|
||||||
assert a != b
|
|
||||||
|
|
||||||
|
|
||||||
def test_MESSAGE_SCOPE_MISMATCH_DENY():
|
|
||||||
with pytest.raises(MessageScopeMismatch, match="MESSAGE_SCOPE_MISMATCH_DENY"):
|
|
||||||
bind_envelope_scope(envelope(tenant_id="tenant-b"), actor())
|
|
||||||
|
|
||||||
|
|
||||||
def test_HANDOFF_CROSS_TENANT_DENY(tmp_path):
|
|
||||||
store = DurableHandoffStore(tmp_path / "handoff.sqlite3")
|
|
||||||
env = envelope()
|
|
||||||
store.put(env)
|
|
||||||
with pytest.raises(MessageScopeMismatch, match="MESSAGE_SCOPE_MISMATCH_DENY"):
|
|
||||||
store.get(env.envelope_id, actor=actor(tenant_id="tenant-b"))
|
|
||||||
|
|
||||||
|
|
||||||
def test_MESSAGE_CONFIDENTIALITY_SCOPE_ENFORCE():
|
|
||||||
with pytest.raises(ConfidentialityScopeDenied, match="MESSAGE_CONFIDENTIALITY_SCOPE_ENFORCE"):
|
|
||||||
authorize_delivery(actor(project_id="project-b"), envelope(confidentiality_scope="project"))
|
|
||||||
with pytest.raises(ConfidentialityScopeDenied, match="MESSAGE_CONFIDENTIALITY_SCOPE_ENFORCE"):
|
|
||||||
authorize_delivery(actor(), envelope(capability_scope=("message.read", "secret.read")))
|
|
||||||
|
|
||||||
|
|
||||||
def test_MESSAGE_IDEMPOTENCY_REPLAY(tmp_path):
|
|
||||||
store = DurableHandoffStore(tmp_path / "handoff.sqlite3")
|
|
||||||
env = envelope()
|
|
||||||
assert store.put(env)[0] == "CREATED"
|
|
||||||
status, replay = store.put(env)
|
|
||||||
assert status == "REPLAY"
|
|
||||||
assert replay.envelope_id == env.envelope_id
|
|
||||||
with pytest.raises(IdempotencyConflict, match="MESSAGE_IDEMPOTENCY_CONFLICT"):
|
|
||||||
store.put(envelope(payload={"value": 2}))
|
|
||||||
|
|
||||||
|
|
||||||
def test_MESSAGE_SEQUENCE_GAP_DETECT(tmp_path):
|
|
||||||
store = DurableHandoffStore(tmp_path / "handoff.sqlite3")
|
|
||||||
store.put(envelope(sequence=1, idempotency_key="one"))
|
|
||||||
with pytest.raises(SequenceGap, match="MESSAGE_SEQUENCE_GAP_DETECT"):
|
|
||||||
store.put(envelope(sequence=3, idempotency_key="three"))
|
|
||||||
|
|
||||||
|
|
||||||
def test_sequence_advances_and_scope_filters_pending(tmp_path):
|
|
||||||
store = DurableHandoffStore(tmp_path / "handoff.sqlite3")
|
|
||||||
store.put(envelope(sequence=1, idempotency_key="one"))
|
|
||||||
store.put(envelope(sequence=2, idempotency_key="two", payload={"value": 2}))
|
|
||||||
assert [item.sequence for item in store.pending(target="agent-b", actor=actor())] == [1, 2]
|
|
||||||
assert store.pending(target="agent-b", actor=actor(tenant_id="tenant-b")) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_expired_message_rejected(tmp_path):
|
|
||||||
expired = (dt.datetime.now(dt.timezone.utc) - dt.timedelta(seconds=1)).isoformat()
|
|
||||||
with pytest.raises(MessageExpired, match="MESSAGE_EXPIRED"):
|
|
||||||
DurableHandoffStore(tmp_path / "handoff.sqlite3").put(envelope(expires_at=expired))
|
|
||||||
|
|
||||||
|
|
||||||
def test_server_actor_binding_fills_scope_and_recomputes_identity():
|
|
||||||
public = MessageEnvelope(
|
|
||||||
source="agent-a", target="agent-b", message_type="handoff.request", payload={"value": 1},
|
|
||||||
sequence=1, idempotency_key="bind-1", confidentiality_scope="public",
|
|
||||||
)
|
|
||||||
bound = bind_envelope_scope(public, actor())
|
|
||||||
assert (bound.tenant_id, bound.user_id, bound.project_id, bound.run_id) == (
|
|
||||||
"tenant-a", "user-a", "project-a", "run-a"
|
|
||||||
)
|
|
||||||
assert bound.envelope_id != public.envelope_id
|
|
||||||
|
|
||||||
|
|
||||||
def test_handoff_fabric_publish_is_durable(tmp_path):
|
|
||||||
store = DurableHandoffStore(tmp_path / "handoff.sqlite3")
|
|
||||||
status, env = HandoffFabric(store).publish(envelope(), actor=actor())
|
|
||||||
assert status == "CREATED"
|
|
||||||
assert store.get(env.envelope_id, actor=actor()).payload == {"value": 1}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user