808 lines
31 KiB
Python
808 lines
31 KiB
Python
"""AIOS V2 production-candidate application entrypoint."""
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import json
|
|
import os
|
|
import stat
|
|
import urllib.parse
|
|
from enum import Enum
|
|
from http import HTTPStatus
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from typing import Any, Mapping
|
|
|
|
from aios_hermes import HermesAdapter, HermesJobState, HermesResult, SQLiteHermesStore
|
|
from aios_openclaw import (
|
|
Ed25519CommandSigner,
|
|
HTTPRuntimeTransport,
|
|
OpenClawAdapter,
|
|
)
|
|
from aios_openclaw.callback import AuditCallbackSink, EventPublisher
|
|
|
|
from .console import CONSOLE_HTML
|
|
from .control_plane import AuditEvent, RequestContext, Unauthorized
|
|
from .dispatch_store import SQLiteDispatchRepository
|
|
from .execution_tracking import ExecutionEvidence, ExecutionTracker
|
|
from .gateway import GatewayApplication, error_response
|
|
from .gitops_status import collect_gitops_status
|
|
from .orchestrator import ExecutionOrchestrator
|
|
from .persistence import DurableControlPlaneService, SQLiteSnapshotRepository
|
|
from .provenance_operations import build_provenance_writer_from_env
|
|
|
|
|
|
def _json_value(value: Any) -> Any:
|
|
if dataclasses.is_dataclass(value):
|
|
return {
|
|
item.name: _json_value(getattr(value, item.name))
|
|
for item in dataclasses.fields(value)
|
|
}
|
|
if isinstance(value, Enum):
|
|
return value.value
|
|
if isinstance(value, Mapping):
|
|
return {str(key): _json_value(item) for key, item in value.items()}
|
|
if isinstance(value, (list, tuple, set, frozenset)):
|
|
return [_json_value(item) for item in value]
|
|
return value
|
|
|
|
|
|
def _enabled(value: str | None) -> bool:
|
|
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def _header(headers: Mapping[str, str], name: str) -> str | None:
|
|
target = name.lower()
|
|
for key, value in headers.items():
|
|
if str(key).lower() == target:
|
|
normalized = str(value or "").strip()
|
|
return normalized or None
|
|
return None
|
|
|
|
|
|
def _provenance_context(
|
|
headers: Mapping[str, str],
|
|
payload: Mapping[str, Any],
|
|
) -> tuple[str | None, str | None]:
|
|
header_provenance = _header(headers, "X-AIOS-Provenance-ID")
|
|
header_run = _header(headers, "X-AIOS-Run-ID")
|
|
body_provenance = str(payload.get("provenance_id") or "").strip() or None
|
|
body_run = str(payload.get("run_id") or "").strip() or None
|
|
if header_provenance and body_provenance and header_provenance != body_provenance:
|
|
raise ValueError("provenance_id header/body mismatch")
|
|
if header_run and body_run and header_run != body_run:
|
|
raise ValueError("run_id header/body mismatch")
|
|
return header_provenance or body_provenance, header_run or body_run
|
|
|
|
|
|
def _read_protected_file(path: Path, *, private: bool) -> bytes:
|
|
flags = os.O_RDONLY
|
|
if hasattr(os, "O_NOFOLLOW"):
|
|
flags |= os.O_NOFOLLOW
|
|
descriptor = os.open(str(path), flags)
|
|
try:
|
|
metadata = os.fstat(descriptor)
|
|
if not stat.S_ISREG(metadata.st_mode):
|
|
raise RuntimeError("key reference is not a regular file")
|
|
denied = 0o077 if private else 0o022
|
|
if metadata.st_mode & denied:
|
|
raise RuntimeError(
|
|
"private key file permissions must be 0600 or stricter"
|
|
if private
|
|
else "public key file must not be group/world writable"
|
|
)
|
|
chunks: list[bytes] = []
|
|
while True:
|
|
chunk = os.read(descriptor, 65536)
|
|
if not chunk:
|
|
break
|
|
chunks.append(chunk)
|
|
value = b"".join(chunks)
|
|
if not value:
|
|
raise RuntimeError("key file is empty")
|
|
return value
|
|
finally:
|
|
os.close(descriptor)
|
|
|
|
|
|
class FileCommandSignerResolver:
|
|
"""Resolve tenant/key-id scoped OpenClaw Ed25519 signing material."""
|
|
|
|
def __init__(self, path: str | Path, *, configured_key_id: str) -> None:
|
|
self.path = Path(path).resolve()
|
|
self.configured_key_id = str(configured_key_id or "").strip()
|
|
if not self.configured_key_id:
|
|
raise ValueError("configured_key_id is required")
|
|
|
|
def __call__(self, tenant_id: str, key_id: str) -> Ed25519CommandSigner:
|
|
if not str(tenant_id or "").strip():
|
|
raise RuntimeError("tenant_id is required for command signing")
|
|
if key_id != self.configured_key_id:
|
|
raise RuntimeError("OpenClaw signing key_id mismatch")
|
|
return Ed25519CommandSigner.from_bytes(
|
|
_read_protected_file(self.path, private=True),
|
|
key_id=key_id,
|
|
)
|
|
|
|
|
|
class ControlPlaneEventPublisher(EventPublisher):
|
|
"""Append Runtime callback events to the tenant audit stream.
|
|
|
|
Callback audit remains informational. Authoritative task reconciliation is
|
|
performed only by the orchestrator after dispatch/result validation.
|
|
"""
|
|
|
|
def __init__(self, service: DurableControlPlaneService) -> None:
|
|
self.service = service
|
|
|
|
def publish(self, event: Mapping[str, Any]) -> None:
|
|
tenant_id = str(event.get("tenant_id") or "").strip()
|
|
trace_id = str(event.get("trace_id") or "").strip()
|
|
command_id = str(event.get("command_id") or "").strip()
|
|
event_type = str(event.get("event_type") or "OPENCLAW_EVENT").strip()
|
|
if not tenant_id or not trace_id or not command_id:
|
|
raise ValueError("runtime audit event is missing tenant identity")
|
|
state_value = str(event.get("state") or "unknown").lower()
|
|
outcome = "allowed" if state_value == "completed" else state_value
|
|
runtime = str(event.get("runtime") or "openclaw").strip().lower()
|
|
principal_id = str(
|
|
event.get("principal_id") or f"{runtime}-runtime"
|
|
).strip()
|
|
audit = AuditEvent(
|
|
event_id=f"runtime_{command_id}_{len(self.service.store.audit_events) + 1}",
|
|
tenant_id=tenant_id,
|
|
principal_id=principal_id,
|
|
trace_id=trace_id,
|
|
action=event_type,
|
|
resource_type=f"{runtime}_execution",
|
|
resource_id=command_id,
|
|
outcome=outcome,
|
|
policy_version="runtime-v2",
|
|
details=dict(event.get("details") or {}),
|
|
)
|
|
with self.service.store.lock:
|
|
self.service.store.audit_events.append(audit)
|
|
self.service.repository.save(self.service._snapshot())
|
|
|
|
|
|
class CandidateApplication:
|
|
def __init__(
|
|
self,
|
|
gateway: GatewayApplication,
|
|
orchestrator: ExecutionOrchestrator,
|
|
tracker: ExecutionTracker,
|
|
hermes: HermesAdapter,
|
|
openclaw: OpenClawAdapter | None,
|
|
*,
|
|
hermes_lifecycle_api_enabled: bool = False,
|
|
) -> None:
|
|
self.gateway = gateway
|
|
self.orchestrator = orchestrator
|
|
self.tracker = tracker
|
|
self.hermes = hermes
|
|
self.openclaw = openclaw
|
|
self.hermes_lifecycle_api_enabled = bool(hermes_lifecycle_api_enabled)
|
|
self.runtime_events = ControlPlaneEventPublisher(orchestrator.control_plane)
|
|
|
|
def context(self, headers: Mapping[str, str]) -> RequestContext:
|
|
return self.gateway.context_from_headers(headers)
|
|
|
|
@staticmethod
|
|
def _require_runtime_role(context: RequestContext) -> None:
|
|
if not ({"runtime", "platform_admin"} & set(context.roles)):
|
|
raise Unauthorized("runtime reconciliation role is required")
|
|
|
|
@staticmethod
|
|
def _require_control_plane_cancel(context: RequestContext) -> None:
|
|
required = {"runtime", "platform_admin"}
|
|
if not required.issubset(set(context.roles)):
|
|
raise Unauthorized(
|
|
"Hermes cancellation requires trusted AIOS control-plane authority"
|
|
)
|
|
|
|
@staticmethod
|
|
def _required_identity(payload: Mapping[str, Any], name: str) -> str:
|
|
value = str(payload.get(name) or "").strip()
|
|
if not value:
|
|
raise ValueError(f"{name} is required")
|
|
return value
|
|
|
|
def _record_hermes_lifecycle(
|
|
self,
|
|
context: RequestContext,
|
|
dispatch: Any,
|
|
job: Any,
|
|
*,
|
|
action: str,
|
|
worker_id: str | None,
|
|
provenance_id: str,
|
|
run_id: str,
|
|
) -> None:
|
|
executor = worker_id or "aios-control-plane"
|
|
evidence = ExecutionEvidence(
|
|
execution_id=job.job_id,
|
|
tenant_id=job.tenant_id,
|
|
task_id=job.task_id,
|
|
agent_id=dispatch.agent_id,
|
|
executor=executor,
|
|
branch="runtime/hermes",
|
|
status=job.state.value.lower(),
|
|
progress_completed=0,
|
|
progress_total=3,
|
|
current_step=action,
|
|
provenance_id=provenance_id,
|
|
run_id=run_id,
|
|
)
|
|
current_evidence = self.tracker.claim(
|
|
evidence,
|
|
principal_id=context.principal_id,
|
|
provenance_id=provenance_id,
|
|
run_id=run_id,
|
|
)
|
|
if job.state in {
|
|
HermesJobState.COMPLETED,
|
|
HermesJobState.FAILED,
|
|
HermesJobState.CANCELLED,
|
|
}:
|
|
if current_evidence.completed_at is None:
|
|
self.tracker.complete(
|
|
tenant_id=job.tenant_id,
|
|
execution_id=job.job_id,
|
|
status=job.state.value.lower(),
|
|
test_status="runtime_terminal",
|
|
test_summary={"lifecycle_action": action},
|
|
result_summary={
|
|
"state": job.state.value,
|
|
"worker_id": worker_id,
|
|
"dispatch_id": dispatch.dispatch_id,
|
|
},
|
|
principal_id=context.principal_id,
|
|
provenance_id=provenance_id,
|
|
run_id=run_id,
|
|
)
|
|
else:
|
|
self.tracker.heartbeat(
|
|
tenant_id=job.tenant_id,
|
|
execution_id=job.job_id,
|
|
status=job.state.value.lower(),
|
|
current_step=action,
|
|
progress_completed=(1 if job.state == HermesJobState.RUNNING else 0),
|
|
progress_total=3,
|
|
principal_id=context.principal_id,
|
|
provenance_id=provenance_id,
|
|
run_id=run_id,
|
|
)
|
|
self.runtime_events.publish(
|
|
{
|
|
"runtime": "hermes",
|
|
"tenant_id": job.tenant_id,
|
|
"principal_id": context.principal_id,
|
|
"trace_id": job.trace_id,
|
|
"command_id": job.job_id,
|
|
"event_type": f"HERMES_{action.upper()}",
|
|
"state": job.state.value,
|
|
"details": {
|
|
"dispatch_id": dispatch.dispatch_id,
|
|
"task_id": job.task_id,
|
|
"worker_id": worker_id,
|
|
"cell_id": job.cell_id,
|
|
"profile_id": job.profile_id,
|
|
"revision": job.revision,
|
|
"provenance_id": provenance_id,
|
|
"run_id": run_id,
|
|
},
|
|
}
|
|
)
|
|
|
|
def handle_json(
|
|
self,
|
|
method: str,
|
|
raw_path: str,
|
|
headers: Mapping[str, str],
|
|
body: Any,
|
|
) -> tuple[int, Mapping[str, Any]]:
|
|
parsed = urllib.parse.urlparse(raw_path)
|
|
path = parsed.path.rstrip("/") or "/"
|
|
method = method.upper()
|
|
|
|
if method == "GET" and path == "/api/v2/gitops":
|
|
context = self.context(headers)
|
|
return HTTPStatus.OK, {
|
|
"tenant_id": context.tenant_id,
|
|
"gitops": collect_gitops_status().as_dict(),
|
|
}
|
|
|
|
if method == "GET" and path == "/api/v2/runtime":
|
|
context = self.context(headers)
|
|
runtimes: list[dict[str, Any]] = []
|
|
if self.openclaw is None:
|
|
runtimes.append(
|
|
{
|
|
"name": "openclaw",
|
|
"status": "disabled",
|
|
"details": {"reason": "runtime_not_configured"},
|
|
}
|
|
)
|
|
else:
|
|
try:
|
|
health = self.openclaw.health()
|
|
runtimes.append(
|
|
{
|
|
"name": "openclaw",
|
|
"status": health.status,
|
|
"details": _json_value(health),
|
|
}
|
|
)
|
|
except Exception as exc:
|
|
runtimes.append(
|
|
{
|
|
"name": "openclaw",
|
|
"status": "unavailable",
|
|
"details": {"message": str(exc)},
|
|
}
|
|
)
|
|
hermes_health = self.hermes.health()
|
|
runtimes.append(
|
|
{
|
|
"name": "hermes",
|
|
"status": hermes_health.status,
|
|
"details": _json_value(hermes_health),
|
|
}
|
|
)
|
|
return HTTPStatus.OK, {
|
|
"tenant_id": context.tenant_id,
|
|
"runtimes": runtimes,
|
|
}
|
|
|
|
if method == "GET" and path == "/api/v2/executions":
|
|
context = self.context(headers)
|
|
return HTTPStatus.OK, {
|
|
"executions": _json_value(
|
|
self.tracker.list(tenant_id=context.tenant_id)
|
|
)
|
|
}
|
|
|
|
dispatch_match = __import__("re").fullmatch(
|
|
r"/api/v2/tasks/([^/]+)/dispatch", path
|
|
)
|
|
if method == "POST" and dispatch_match:
|
|
context = self.context(headers)
|
|
task_id = urllib.parse.unquote(dispatch_match.group(1))
|
|
payload = body if isinstance(body, Mapping) else {}
|
|
timeout = payload.get("timeout_seconds")
|
|
provenance_id, run_id = _provenance_context(headers, payload)
|
|
record = self.orchestrator.dispatch(
|
|
context,
|
|
task_id=task_id,
|
|
timeout_seconds=float(timeout) if timeout is not None else None,
|
|
provenance_id=provenance_id,
|
|
run_id=run_id,
|
|
)
|
|
return HTTPStatus.ACCEPTED, {"dispatch": _json_value(record)}
|
|
|
|
lifecycle_match = __import__("re").fullmatch(
|
|
r"/api/v2/internal/runtime/hermes/lifecycle/"
|
|
r"(claim|start|heartbeat|complete|fail|cancel|status)",
|
|
path,
|
|
)
|
|
if method == "POST" and lifecycle_match:
|
|
if not self.hermes_lifecycle_api_enabled:
|
|
return HTTPStatus.NOT_FOUND, {
|
|
"error": "not_found",
|
|
"message": "route not found",
|
|
}
|
|
context = self.context(headers)
|
|
self._require_runtime_role(context)
|
|
if not isinstance(body, Mapping):
|
|
raise ValueError("JSON body must be an object")
|
|
payload = body
|
|
action = lifecycle_match.group(1)
|
|
identity = {
|
|
name: self._required_identity(payload, name)
|
|
for name in (
|
|
"dispatch_id",
|
|
"job_id",
|
|
"tenant_id",
|
|
"task_id",
|
|
"trace_id",
|
|
"principal_id",
|
|
"provenance_id",
|
|
"run_id",
|
|
"cell_id",
|
|
"profile_id",
|
|
)
|
|
}
|
|
header_provenance, header_run = _provenance_context(headers, payload)
|
|
if header_provenance != identity["provenance_id"]:
|
|
raise ValueError("provenance_id context mismatch")
|
|
if header_run != identity["run_id"]:
|
|
raise ValueError("run_id context mismatch")
|
|
requeued_job_ids = self.hermes.requeue_expired()
|
|
dispatch, job = self.orchestrator.hermes_lifecycle_context(
|
|
context,
|
|
dispatch_id=identity["dispatch_id"],
|
|
job_id=identity["job_id"],
|
|
tenant_id=identity["tenant_id"],
|
|
task_id=identity["task_id"],
|
|
trace_id=identity["trace_id"],
|
|
principal_id=identity["principal_id"],
|
|
provenance_id=identity["provenance_id"],
|
|
run_id=identity["run_id"],
|
|
)
|
|
if job.cell_id != identity["cell_id"]:
|
|
raise ValueError("cell_id mismatch")
|
|
if job.profile_id != identity["profile_id"]:
|
|
raise ValueError("profile_id mismatch")
|
|
|
|
if action == "status":
|
|
if job.job_id in requeued_job_ids:
|
|
self._record_hermes_lifecycle(
|
|
context,
|
|
dispatch,
|
|
job,
|
|
action="lease_expired_requeue",
|
|
worker_id=None,
|
|
provenance_id=identity["provenance_id"],
|
|
run_id=identity["run_id"],
|
|
)
|
|
task = self.orchestrator.control_plane.get_task(
|
|
context, dispatch.task_id
|
|
)
|
|
response: dict[str, Any] = {
|
|
"job": _json_value(job),
|
|
"dispatch": _json_value(dispatch),
|
|
"task": _json_value(task),
|
|
}
|
|
if job.state in {
|
|
HermesJobState.COMPLETED,
|
|
HermesJobState.FAILED,
|
|
HermesJobState.CANCELLED,
|
|
}:
|
|
response["result"] = _json_value(
|
|
self.hermes.result(job.job_id, tenant_id=job.tenant_id)
|
|
)
|
|
return HTTPStatus.OK, response
|
|
|
|
expected_revision = payload.get("expected_revision")
|
|
expected = (
|
|
int(expected_revision) if expected_revision is not None else None
|
|
)
|
|
if action == "cancel":
|
|
self._require_control_plane_cancel(context)
|
|
if payload.get("worker_id") is not None:
|
|
raise Unauthorized("Hermes workers may observe but not initiate cancel")
|
|
if payload.get("cancellation_source") != "aios-control-plane":
|
|
raise ValueError(
|
|
"cancellation_source must be aios-control-plane"
|
|
)
|
|
result = self.hermes.cancel(
|
|
job.job_id,
|
|
tenant_id=job.tenant_id,
|
|
expected_revision=expected,
|
|
)
|
|
else:
|
|
worker_id = self._required_identity(payload, "worker_id")
|
|
if action == "claim":
|
|
claimed = self.hermes.claim(
|
|
tenant_id=job.tenant_id,
|
|
cell_id=job.cell_id,
|
|
profile_id=job.profile_id,
|
|
worker_id=worker_id,
|
|
lease_seconds=(
|
|
int(payload["lease_seconds"])
|
|
if payload.get("lease_seconds") is not None
|
|
else None
|
|
),
|
|
job_id=job.job_id,
|
|
)
|
|
if claimed is None:
|
|
raise ValueError("Hermes job is not claimable")
|
|
bound = self.orchestrator.bind_hermes_worker(
|
|
context,
|
|
claimed,
|
|
dispatch_id=dispatch.dispatch_id,
|
|
provenance_id=identity["provenance_id"],
|
|
run_id=identity["run_id"],
|
|
worker_id=worker_id,
|
|
)
|
|
self._record_hermes_lifecycle(
|
|
context,
|
|
bound,
|
|
claimed,
|
|
action="claim",
|
|
worker_id=worker_id,
|
|
provenance_id=identity["provenance_id"],
|
|
run_id=identity["run_id"],
|
|
)
|
|
return HTTPStatus.OK, {
|
|
"job": _json_value(claimed),
|
|
"dispatch": _json_value(bound),
|
|
}
|
|
if action == "start":
|
|
started = self.hermes.start(
|
|
job.job_id,
|
|
tenant_id=job.tenant_id,
|
|
worker_id=worker_id,
|
|
expected_revision=expected,
|
|
)
|
|
self._record_hermes_lifecycle(
|
|
context,
|
|
dispatch,
|
|
started,
|
|
action="start",
|
|
worker_id=worker_id,
|
|
provenance_id=identity["provenance_id"],
|
|
run_id=identity["run_id"],
|
|
)
|
|
return HTTPStatus.OK, {"job": _json_value(started)}
|
|
if action == "heartbeat":
|
|
heartbeat = self.hermes.heartbeat(
|
|
job.job_id,
|
|
tenant_id=job.tenant_id,
|
|
worker_id=worker_id,
|
|
lease_seconds=(
|
|
int(payload["lease_seconds"])
|
|
if payload.get("lease_seconds") is not None
|
|
else None
|
|
),
|
|
)
|
|
self._record_hermes_lifecycle(
|
|
context,
|
|
dispatch,
|
|
heartbeat,
|
|
action="heartbeat",
|
|
worker_id=worker_id,
|
|
provenance_id=identity["provenance_id"],
|
|
run_id=identity["run_id"],
|
|
)
|
|
return HTTPStatus.OK, {"job": _json_value(heartbeat)}
|
|
if action == "complete":
|
|
output = payload.get("output")
|
|
if not isinstance(output, Mapping):
|
|
raise ValueError("output must be an object")
|
|
result = self.hermes.complete(
|
|
job.job_id,
|
|
tenant_id=job.tenant_id,
|
|
worker_id=worker_id,
|
|
output=output,
|
|
expected_revision=expected,
|
|
)
|
|
elif action == "fail":
|
|
error = payload.get("error")
|
|
if not isinstance(error, Mapping):
|
|
raise ValueError("error must be an object")
|
|
result = self.hermes.fail(
|
|
job.job_id,
|
|
tenant_id=job.tenant_id,
|
|
worker_id=worker_id,
|
|
error=error,
|
|
expected_revision=expected,
|
|
)
|
|
else:
|
|
raise ValueError("unsupported Hermes lifecycle action")
|
|
|
|
task = self.orchestrator.reconcile_hermes(
|
|
context,
|
|
result,
|
|
dispatch_id=dispatch.dispatch_id,
|
|
provenance_id=identity["provenance_id"],
|
|
run_id=identity["run_id"],
|
|
)
|
|
updated_dispatch = self.orchestrator.dispatch_repository.get(
|
|
dispatch.dispatch_id, tenant_id=dispatch.tenant_id
|
|
)
|
|
terminal_job = self.hermes.get(job.job_id, tenant_id=job.tenant_id)
|
|
self._record_hermes_lifecycle(
|
|
context,
|
|
updated_dispatch,
|
|
terminal_job,
|
|
action=action,
|
|
worker_id=result.worker_id,
|
|
provenance_id=identity["provenance_id"],
|
|
run_id=identity["run_id"],
|
|
)
|
|
return HTTPStatus.OK, {
|
|
"result": _json_value(result),
|
|
"task": _json_value(task),
|
|
"dispatch": _json_value(updated_dispatch),
|
|
}
|
|
|
|
if method == "POST" and path == "/api/v2/internal/runtime/hermes/reconcile":
|
|
context = self.context(headers)
|
|
self._require_runtime_role(context)
|
|
if not isinstance(body, Mapping):
|
|
raise ValueError("JSON body must be an object")
|
|
provenance_id, run_id = _provenance_context(headers, body)
|
|
if not provenance_id or not run_id:
|
|
raise ValueError("provenance_id and run_id are required")
|
|
dispatch_id = str(body.get("dispatch_id") or "").strip()
|
|
if not dispatch_id:
|
|
raise ValueError("dispatch_id is required")
|
|
result = HermesResult(
|
|
job_id=str(body["job_id"]),
|
|
tenant_id=str(body["tenant_id"]),
|
|
task_id=str(body["task_id"]),
|
|
trace_id=str(body["trace_id"]),
|
|
state=HermesJobState(str(body["state"])),
|
|
worker_id=(
|
|
str(body["worker_id"])
|
|
if body.get("worker_id") is not None
|
|
else None
|
|
),
|
|
output=body.get("output"),
|
|
error=body.get("error"),
|
|
started_at=body.get("started_at"),
|
|
finished_at=body.get("finished_at"),
|
|
)
|
|
task = self.orchestrator.reconcile_hermes(
|
|
context,
|
|
result,
|
|
dispatch_id=dispatch_id,
|
|
provenance_id=provenance_id,
|
|
run_id=run_id,
|
|
)
|
|
return HTTPStatus.OK, {"task": _json_value(task)}
|
|
|
|
if method == "POST" and path == "/api/v2/hermes/results/reconcile":
|
|
return HTTPStatus.GONE, {
|
|
"error": "RuntimeReconcileEndpointMoved",
|
|
"message": "use /api/v2/internal/runtime/hermes/reconcile",
|
|
}
|
|
|
|
return self.gateway.handle(method, raw_path, headers, body)
|
|
|
|
|
|
class CandidateRequestHandler(BaseHTTPRequestHandler):
|
|
server_version = "AIOSV2Candidate/2.0"
|
|
max_body_bytes = 1_048_576
|
|
|
|
@property
|
|
def application(self) -> CandidateApplication:
|
|
return self.server.application # type: ignore[attr-defined]
|
|
|
|
def _read_body(self) -> Any:
|
|
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
if length < 0 or length > self.max_body_bytes:
|
|
raise ValueError("request body is too large")
|
|
if length == 0:
|
|
return {}
|
|
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip()
|
|
if content_type != "application/json":
|
|
raise ValueError("Content-Type must be application/json")
|
|
return json.loads(self.rfile.read(length).decode("utf-8"))
|
|
|
|
def _security_headers(self) -> None:
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("X-Content-Type-Options", "nosniff")
|
|
self.send_header("X-Frame-Options", "DENY")
|
|
self.send_header("Referrer-Policy", "no-referrer")
|
|
self.send_header(
|
|
"Content-Security-Policy",
|
|
"default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'",
|
|
)
|
|
|
|
def _send_json(self, status_code: int, payload: Mapping[str, Any]) -> None:
|
|
encoded = json.dumps(
|
|
_json_value(payload), ensure_ascii=False, separators=(",", ":")
|
|
).encode("utf-8")
|
|
self.send_response(int(status_code))
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(encoded)))
|
|
self._security_headers()
|
|
self.end_headers()
|
|
self.wfile.write(encoded)
|
|
|
|
def _send_console(self) -> None:
|
|
encoded = CONSOLE_HTML.encode("utf-8")
|
|
self.send_response(HTTPStatus.OK)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(encoded)))
|
|
self._security_headers()
|
|
self.end_headers()
|
|
self.wfile.write(encoded)
|
|
|
|
def _dispatch(self, method: str) -> None:
|
|
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
|
if method == "GET" and path in {"/", "/console"}:
|
|
self._send_console()
|
|
return
|
|
try:
|
|
body = self._read_body() if method == "POST" else {}
|
|
status_code, payload = self.application.handle_json(
|
|
method,
|
|
self.path,
|
|
{key: value for key, value in self.headers.items()},
|
|
body,
|
|
)
|
|
except Exception as exc:
|
|
status_code, payload = error_response(exc)
|
|
self._send_json(status_code, payload)
|
|
|
|
def do_GET(self) -> None: # noqa: N802
|
|
self._dispatch("GET")
|
|
|
|
def do_POST(self) -> None: # noqa: N802
|
|
self._dispatch("POST")
|
|
|
|
|
|
class CandidateHTTPServer(ThreadingHTTPServer):
|
|
daemon_threads = True
|
|
allow_reuse_address = True
|
|
|
|
def __init__(self, address: tuple[str, int], application: CandidateApplication) -> None:
|
|
self.application = application
|
|
super().__init__(address, CandidateRequestHandler)
|
|
|
|
|
|
def build_application() -> CandidateApplication:
|
|
state_dir = Path(os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2")).resolve()
|
|
state_dir.mkdir(parents=True, exist_ok=True)
|
|
repository = SQLiteSnapshotRepository(state_dir / "control-plane.sqlite3")
|
|
dispatch_repository = SQLiteDispatchRepository(state_dir / "dispatches.sqlite3")
|
|
service = DurableControlPlaneService(repository)
|
|
require_provenance = _enabled(os.getenv("AIOS_REQUIRE_RUN_PROVENANCE", "0"))
|
|
provenance_writer = build_provenance_writer_from_env(
|
|
state_dir=state_dir, required=require_provenance
|
|
)
|
|
tracker = ExecutionTracker(
|
|
state_dir / "executions",
|
|
provenance_writer=provenance_writer,
|
|
require_provenance=require_provenance,
|
|
)
|
|
hermes = HermesAdapter(store=SQLiteHermesStore(state_dir / "hermes.sqlite3"))
|
|
|
|
openclaw_url = os.getenv("AIOS_OPENCLAW_URL", "").strip()
|
|
signing_file = os.getenv("AIOS_OPENCLAW_SIGNING_MATERIAL_FILE", "").strip()
|
|
signing_key_id = os.getenv(
|
|
"AIOS_OPENCLAW_SIGNING_KEY_ID", "openclaw-v2r1-candidate-ed25519"
|
|
).strip()
|
|
openclaw = None
|
|
resolver = None
|
|
if openclaw_url and signing_file:
|
|
resolver = FileCommandSignerResolver(
|
|
signing_file,
|
|
configured_key_id=signing_key_id,
|
|
)
|
|
transport = HTTPRuntimeTransport(
|
|
openclaw_url,
|
|
credential_ref=os.getenv("AIOS_OPENCLAW_CREDENTIAL_REF") or None,
|
|
)
|
|
openclaw = OpenClawAdapter(
|
|
transport,
|
|
resolver,
|
|
callback_sink=AuditCallbackSink(ControlPlaneEventPublisher(service)),
|
|
)
|
|
|
|
gateway = GatewayApplication(service)
|
|
orchestrator = ExecutionOrchestrator(
|
|
service,
|
|
openclaw=openclaw,
|
|
hermes=hermes,
|
|
openclaw_signing_material_resolver=resolver,
|
|
openclaw_key_id=signing_key_id,
|
|
provenance_writer=provenance_writer,
|
|
require_provenance=require_provenance,
|
|
dispatch_repository=dispatch_repository,
|
|
)
|
|
return CandidateApplication(
|
|
gateway,
|
|
orchestrator,
|
|
tracker,
|
|
hermes,
|
|
openclaw,
|
|
hermes_lifecycle_api_enabled=_enabled(
|
|
os.getenv("AIOS_HERMES_LIFECYCLE_API_ENABLED", "0")
|
|
),
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
host = os.getenv("AIOS_V2_HOST", "127.0.0.1")
|
|
port = int(os.getenv("AIOS_V2_PORT", "18920"))
|
|
server = CandidateHTTPServer((host, port), build_application())
|
|
print(f"AIOS V2 candidate listening on {host}:{port}")
|
|
server.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|