Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11f97face6 | ||
|
|
f71d678c2d | ||
|
|
f8e1022a41 | ||
|
|
2e5996463d |
@@ -0,0 +1,315 @@
|
||||
#!/usr/bin/env python3
|
||||
"""HTTP API for AIOS external-agent online task distribution.
|
||||
|
||||
The service is intended to bind to loopback and be published through the
|
||||
existing TLS reverse proxy. Control operations use a server-managed control
|
||||
Bearer token. Agents receive individual Bearer tokens at registration; only
|
||||
token hashes are persisted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any, Mapping
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from aios_agent_task_protocol import TaskProtocolError, TaskStore
|
||||
|
||||
|
||||
MAX_BODY = 1024 * 1024
|
||||
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class APIConfig:
|
||||
db_path: Path
|
||||
control_token_path: Path
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 18940
|
||||
token_pepper: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "APIConfig":
|
||||
return cls(
|
||||
db_path=Path(os.getenv(
|
||||
"AIOS_AGENT_TASK_DB",
|
||||
"/var/lib/aios-agent-task/state.sqlite3",
|
||||
)),
|
||||
control_token_path=Path(os.getenv(
|
||||
"AIOS_AGENT_TASK_CONTROL_TOKEN_FILE",
|
||||
"/run/credentials/aios-agent-task-api.service/control_token",
|
||||
)),
|
||||
host=os.getenv("AIOS_AGENT_TASK_HOST", "127.0.0.1"),
|
||||
port=int(os.getenv("AIOS_AGENT_TASK_PORT", "18940")),
|
||||
token_pepper=os.getenv("AIOS_AGENT_TASK_TOKEN_PEPPER", ""),
|
||||
)
|
||||
|
||||
|
||||
class AgentTaskApplication:
|
||||
def __init__(self, config: APIConfig) -> None:
|
||||
self.config = config
|
||||
self.store = TaskStore(
|
||||
config.db_path,
|
||||
token_pepper=config.token_pepper,
|
||||
)
|
||||
|
||||
def _control_token(self) -> str:
|
||||
path = self.config.control_token_path
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise TaskProtocolError("CONTROL_CREDENTIAL_UNAVAILABLE", 503)
|
||||
value = path.read_text(encoding="utf-8").strip()
|
||||
if len(value) < 32:
|
||||
raise TaskProtocolError("CONTROL_CREDENTIAL_INVALID", 503)
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _bearer(headers: Mapping[str, str]) -> str:
|
||||
value = str(headers.get("Authorization") or "")
|
||||
if not value.startswith("Bearer "):
|
||||
return ""
|
||||
return value[7:].strip()
|
||||
|
||||
def authenticate_control(self, headers: Mapping[str, str]) -> None:
|
||||
supplied = self._bearer(headers)
|
||||
expected = self._control_token()
|
||||
if not supplied or not hmac.compare_digest(supplied, expected):
|
||||
raise TaskProtocolError("UNAUTHORIZED", 401)
|
||||
|
||||
def authenticate_agent(self, headers: Mapping[str, str]) -> dict[str, Any]:
|
||||
return self.store.authenticate_agent(self._bearer(headers))
|
||||
|
||||
def principal(self, headers: Mapping[str, str]) -> tuple[str, dict[str, Any] | None]:
|
||||
supplied = self._bearer(headers)
|
||||
if not supplied:
|
||||
raise TaskProtocolError("UNAUTHORIZED", 401)
|
||||
try:
|
||||
expected = self._control_token()
|
||||
except TaskProtocolError:
|
||||
expected = ""
|
||||
if expected and hmac.compare_digest(supplied, expected):
|
||||
return "control", None
|
||||
return "agent", self.store.authenticate_agent(supplied)
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"schema": "aios.agent-task.v1",
|
||||
"task_states": [
|
||||
"QUEUED", "CLAIMED", "RUNNING", "VERIFYING",
|
||||
"SUCCEEDED", "FAILED", "EXPIRED", "CANCELLED",
|
||||
],
|
||||
"lease_recovery": True,
|
||||
"atomic_claim": True,
|
||||
"idempotent_result_submission": True,
|
||||
"server_side_verification_required": True,
|
||||
"plaintext_credentials_persisted": False,
|
||||
}
|
||||
|
||||
def register_agent(self, headers: Mapping[str, str], body: Mapping[str, Any]) -> dict[str, Any]:
|
||||
self.authenticate_control(headers)
|
||||
return self.store.register_agent(
|
||||
agent_id=str(body.get("agent_id") or ""),
|
||||
agent_type=str(body.get("agent_type") or ""),
|
||||
agent_version=str(body.get("agent_version") or ""),
|
||||
capabilities=body.get("capabilities") or [],
|
||||
metadata=body.get("metadata") if isinstance(body.get("metadata"), Mapping) else {},
|
||||
)
|
||||
|
||||
def create_task(self, headers: Mapping[str, str], body: Mapping[str, Any]) -> dict[str, Any]:
|
||||
self.authenticate_control(headers)
|
||||
return self.store.create_task(
|
||||
task_id=str(body.get("task_id") or "") or None,
|
||||
goal=str(body.get("goal") or ""),
|
||||
instructions=str(body.get("instructions") or ""),
|
||||
success_criteria=body.get("success_criteria"),
|
||||
required_capabilities=body.get("required_capabilities") or [],
|
||||
constraints=body.get("constraints") if isinstance(body.get("constraints"), Mapping) else {},
|
||||
metadata=body.get("metadata") if isinstance(body.get("metadata"), Mapping) else {},
|
||||
deadline=str(body.get("deadline") or "") or None,
|
||||
priority=body.get("priority", 0),
|
||||
max_attempts=body.get("max_attempts", 3),
|
||||
idempotency_key=str(body.get("idempotency_key") or ""),
|
||||
)
|
||||
|
||||
def poll(self, headers: Mapping[str, str], body: Mapping[str, Any]) -> dict[str, Any]:
|
||||
agent = self.authenticate_agent(headers)
|
||||
tasks = self.store.poll_tasks(
|
||||
agent_id=agent["agent_id"],
|
||||
limit=int(body.get("limit", 20)),
|
||||
)
|
||||
return {"agent_id": agent["agent_id"], "tasks": tasks}
|
||||
|
||||
def claim(self, task_id: str, headers: Mapping[str, str], body: Mapping[str, Any]) -> dict[str, Any]:
|
||||
agent = self.authenticate_agent(headers)
|
||||
return self.store.claim_task(
|
||||
task_id=task_id,
|
||||
agent_id=agent["agent_id"],
|
||||
lease_seconds=int(body.get("lease_seconds", 300)),
|
||||
)
|
||||
|
||||
def heartbeat(self, task_id: str, headers: Mapping[str, str], body: Mapping[str, Any]) -> dict[str, Any]:
|
||||
agent = self.authenticate_agent(headers)
|
||||
return self.store.heartbeat(
|
||||
task_id=task_id,
|
||||
agent_id=agent["agent_id"],
|
||||
lease_token=str(body.get("lease_token") or ""),
|
||||
progress=body.get("progress") if isinstance(body.get("progress"), Mapping) else {},
|
||||
lease_seconds=int(body.get("lease_seconds", 300)),
|
||||
)
|
||||
|
||||
def result(self, task_id: str, headers: Mapping[str, str], body: Mapping[str, Any]) -> dict[str, Any]:
|
||||
agent = self.authenticate_agent(headers)
|
||||
result = body.get("result")
|
||||
if not isinstance(result, Mapping):
|
||||
raise TaskProtocolError("RESULT_INVALID", 400)
|
||||
return self.store.submit_result(
|
||||
task_id=task_id,
|
||||
agent_id=agent["agent_id"],
|
||||
lease_token=str(body.get("lease_token") or ""),
|
||||
idempotency_key=str(body.get("idempotency_key") or ""),
|
||||
result=result,
|
||||
)
|
||||
|
||||
def verify(self, task_id: str, headers: Mapping[str, str], body: Mapping[str, Any]) -> dict[str, Any]:
|
||||
self.authenticate_control(headers)
|
||||
return self.store.verify_task(
|
||||
task_id=task_id,
|
||||
decision=str(body.get("decision") or ""),
|
||||
verification=body.get("verification") if isinstance(body.get("verification"), Mapping) else {},
|
||||
)
|
||||
|
||||
def cancel(self, task_id: str, headers: Mapping[str, str], body: Mapping[str, Any]) -> dict[str, Any]:
|
||||
self.authenticate_control(headers)
|
||||
return self.store.cancel_task(task_id=task_id, reason=str(body.get("reason") or ""))
|
||||
|
||||
def get_task(self, task_id: str, headers: Mapping[str, str]) -> dict[str, Any]:
|
||||
kind, agent = self.principal(headers)
|
||||
task = self.store.get_task(task_id)
|
||||
if kind == "agent" and task.get("claimed_by") != agent["agent_id"]:
|
||||
raise TaskProtocolError("FORBIDDEN", 403)
|
||||
return task
|
||||
|
||||
def events(self, task_id: str, headers: Mapping[str, str]) -> dict[str, Any]:
|
||||
self.authenticate_control(headers)
|
||||
return {"task_id": task_id, "events": self.store.get_events(task_id)}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
app: AgentTaskApplication | None = None
|
||||
server_version = "AIOSAgentTaskAPI/1"
|
||||
|
||||
def log_message(self, _format: str, *_args: Any) -> None:
|
||||
return
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {key: value for key, value in self.headers.items()}
|
||||
|
||||
def _json(self, value: Any, status: int = 200) -> None:
|
||||
payload = json.dumps(value, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def _body(self) -> dict[str, Any]:
|
||||
try:
|
||||
size = int(self.headers.get("Content-Length", "0"))
|
||||
except ValueError as exc:
|
||||
raise TaskProtocolError("CONTENT_LENGTH_INVALID", 400) from exc
|
||||
if size < 0 or size > MAX_BODY:
|
||||
raise TaskProtocolError("REQUEST_TOO_LARGE", 413)
|
||||
raw = self.rfile.read(size) if size else b"{}"
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise TaskProtocolError("JSON_INVALID", 400) from exc
|
||||
if not isinstance(value, dict):
|
||||
raise TaskProtocolError("JSON_OBJECT_REQUIRED", 400)
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _task_route(path: str) -> tuple[str, str | None]:
|
||||
match = re.fullmatch(
|
||||
r"/v1/tasks/([A-Za-z0-9][A-Za-z0-9._:-]{2,179})(?:/(claim|heartbeat|result|verify|cancel|events))?",
|
||||
path,
|
||||
)
|
||||
if match:
|
||||
return match.group(1), match.group(2)
|
||||
return "", None
|
||||
|
||||
def _run(self, callback) -> None:
|
||||
try:
|
||||
callback()
|
||||
except TaskProtocolError as exc:
|
||||
self._json({"error": exc.code}, exc.http_status)
|
||||
except Exception as exc:
|
||||
self._json({"error": "INTERNAL_ERROR", "detail": type(exc).__name__}, 500)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
def action() -> None:
|
||||
if self.app is None:
|
||||
raise TaskProtocolError("SERVICE_UNAVAILABLE", 503)
|
||||
path = urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path == "/health":
|
||||
self._json(self.app.health())
|
||||
return
|
||||
task_id, suffix = self._task_route(path)
|
||||
if task_id and suffix is None:
|
||||
self._json(self.app.get_task(task_id, self._headers()))
|
||||
return
|
||||
if task_id and suffix == "events":
|
||||
self._json(self.app.events(task_id, self._headers()))
|
||||
return
|
||||
self._json({"error": "NOT_FOUND"}, 404)
|
||||
self._run(action)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
def action() -> None:
|
||||
if self.app is None:
|
||||
raise TaskProtocolError("SERVICE_UNAVAILABLE", 503)
|
||||
path = urlparse(self.path).path.rstrip("/") or "/"
|
||||
body = self._body()
|
||||
if path == "/v1/agents/register":
|
||||
self._json(self.app.register_agent(self._headers(), body), 201)
|
||||
return
|
||||
if path == "/v1/tasks":
|
||||
self._json(self.app.create_task(self._headers(), body), 201)
|
||||
return
|
||||
if path == "/v1/tasks/poll":
|
||||
self._json(self.app.poll(self._headers(), body))
|
||||
return
|
||||
task_id, suffix = self._task_route(path)
|
||||
if not task_id:
|
||||
self._json({"error": "NOT_FOUND"}, 404)
|
||||
return
|
||||
if suffix == "claim":
|
||||
self._json(self.app.claim(task_id, self._headers(), body))
|
||||
elif suffix == "heartbeat":
|
||||
self._json(self.app.heartbeat(task_id, self._headers(), body))
|
||||
elif suffix == "result":
|
||||
self._json(self.app.result(task_id, self._headers(), body))
|
||||
elif suffix == "verify":
|
||||
self._json(self.app.verify(task_id, self._headers(), body))
|
||||
elif suffix == "cancel":
|
||||
self._json(self.app.cancel(task_id, self._headers(), body))
|
||||
else:
|
||||
self._json({"error": "NOT_FOUND"}, 404)
|
||||
self._run(action)
|
||||
|
||||
|
||||
def serve(config: APIConfig | None = None) -> None:
|
||||
config = config or APIConfig.from_env()
|
||||
application = AgentTaskApplication(config)
|
||||
Handler.app = application
|
||||
ThreadingHTTPServer((config.host, config.port), Handler).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve()
|
||||
@@ -0,0 +1,827 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Durable online task protocol for external AIOS agents.
|
||||
|
||||
The store is deliberately independent from Git/Gitea mutation. It provides a
|
||||
small durable state machine for publishing work, atomic claim/lease ownership,
|
||||
heartbeat, idempotent result submission, explicit server-side verification,
|
||||
lease recovery, and audit events.
|
||||
|
||||
No plaintext agent credential or task lease token is persisted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Iterable, Mapping
|
||||
import uuid
|
||||
|
||||
|
||||
TERMINAL_STATES = frozenset({"SUCCEEDED", "FAILED", "EXPIRED", "CANCELLED"})
|
||||
ACTIVE_LEASE_STATES = frozenset({"CLAIMED", "RUNNING"})
|
||||
TASK_STATES = frozenset({
|
||||
"QUEUED", "CLAIMED", "RUNNING", "VERIFYING",
|
||||
"SUCCEEDED", "FAILED", "EXPIRED", "CANCELLED",
|
||||
})
|
||||
VERIFY_DECISIONS = frozenset({"SUCCEEDED", "NEEDS_REWORK", "FAILED"})
|
||||
|
||||
|
||||
class TaskProtocolError(RuntimeError):
|
||||
def __init__(self, code: str, http_status: int = 409) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.http_status = http_status
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Principal:
|
||||
kind: str
|
||||
identifier: str
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def iso(value: datetime) -> str:
|
||||
return value.astimezone(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def parse_time(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
parsed = datetime.fromisoformat(value)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def digest_json(value: Any) -> str:
|
||||
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _string_list(value: Iterable[object] | None, field: str, *, max_items: int = 128) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (str, bytes)) or not isinstance(value, Iterable):
|
||||
raise TaskProtocolError(field.upper() + "_INVALID", 400)
|
||||
result: list[str] = []
|
||||
for item in value:
|
||||
text = str(item).strip()
|
||||
if not text or len(text) > 160:
|
||||
raise TaskProtocolError(field.upper() + "_INVALID", 400)
|
||||
result.append(text)
|
||||
if len(result) > max_items:
|
||||
raise TaskProtocolError(field.upper() + "_TOO_LARGE", 400)
|
||||
return sorted(set(result))
|
||||
|
||||
|
||||
def _json_object(value: object | None, field: str, *, max_bytes: int = 128 * 1024) -> dict[str, Any]:
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, Mapping):
|
||||
raise TaskProtocolError(field.upper() + "_INVALID", 400)
|
||||
result = dict(value)
|
||||
if len(canonical_json(result).encode("utf-8")) > max_bytes:
|
||||
raise TaskProtocolError(field.upper() + "_TOO_LARGE", 413)
|
||||
return result
|
||||
|
||||
|
||||
def _criteria(value: object | None) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (str, bytes)) or not isinstance(value, list):
|
||||
raise TaskProtocolError("SUCCESS_CRITERIA_INVALID", 400)
|
||||
if len(value) > 128:
|
||||
raise TaskProtocolError("SUCCESS_CRITERIA_TOO_LARGE", 413)
|
||||
if len(canonical_json(value).encode("utf-8")) > 128 * 1024:
|
||||
raise TaskProtocolError("SUCCESS_CRITERIA_TOO_LARGE", 413)
|
||||
return list(value)
|
||||
|
||||
|
||||
class TaskStore:
|
||||
"""SQLite-backed durable task store.
|
||||
|
||||
SQLite BEGIN IMMEDIATE serializes claims so two agents cannot successfully
|
||||
claim the same queued task. WAL mode allows concurrent readers.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
now: Callable[[], datetime] = utcnow,
|
||||
token_pepper: str = "",
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.now = now
|
||||
self.token_pepper = token_pepper
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=10, isolation_level=None)
|
||||
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=10000")
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
agent_id TEXT PRIMARY KEY,
|
||||
agent_type TEXT NOT NULL,
|
||||
agent_version TEXT NOT NULL,
|
||||
capabilities_json TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
goal TEXT NOT NULL,
|
||||
instructions TEXT NOT NULL,
|
||||
success_criteria_json TEXT NOT NULL,
|
||||
required_capabilities_json TEXT NOT NULL,
|
||||
constraints_json TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL,
|
||||
deadline TEXT,
|
||||
priority INTEGER NOT NULL,
|
||||
max_attempts INTEGER NOT NULL,
|
||||
attempt INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL,
|
||||
claimed_by TEXT,
|
||||
lease_token_hash TEXT,
|
||||
lease_expires_at TEXT,
|
||||
result_json TEXT,
|
||||
result_digest TEXT,
|
||||
result_idempotency_key TEXT,
|
||||
verification_json TEXT,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(claimed_by) REFERENCES agents(agent_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS idempotency (
|
||||
scope TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
payload_digest TEXT NOT NULL,
|
||||
response_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(scope, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT,
|
||||
agent_id TEXT,
|
||||
event_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_queue
|
||||
ON tasks(status, priority DESC, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_lease
|
||||
ON tasks(status, lease_expires_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_task
|
||||
ON events(task_id, event_id);
|
||||
"""
|
||||
)
|
||||
|
||||
def _token_hash(self, token: str) -> str:
|
||||
return hashlib.sha256((self.token_pepper + "\0" + token).encode("utf-8")).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _row_task(row: sqlite3.Row, *, include_result: bool = True) -> dict[str, Any]:
|
||||
value: dict[str, Any] = {
|
||||
"task_id": row["task_id"],
|
||||
"goal": row["goal"],
|
||||
"instructions": row["instructions"],
|
||||
"success_criteria": json.loads(row["success_criteria_json"]),
|
||||
"required_capabilities": json.loads(row["required_capabilities_json"]),
|
||||
"constraints": json.loads(row["constraints_json"]),
|
||||
"metadata": json.loads(row["metadata_json"]),
|
||||
"deadline": row["deadline"],
|
||||
"priority": row["priority"],
|
||||
"max_attempts": row["max_attempts"],
|
||||
"attempt": row["attempt"],
|
||||
"status": row["status"],
|
||||
"claimed_by": row["claimed_by"],
|
||||
"lease_expires_at": row["lease_expires_at"],
|
||||
"revision": row["revision"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
if include_result:
|
||||
value["result"] = json.loads(row["result_json"]) if row["result_json"] else None
|
||||
value["verification"] = (
|
||||
json.loads(row["verification_json"]) if row["verification_json"] else None
|
||||
)
|
||||
return value
|
||||
|
||||
def _event(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
event_type: str,
|
||||
*,
|
||||
task_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
payload: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
connection.execute(
|
||||
"INSERT INTO events(task_id,agent_id,event_type,payload_json,created_at)"
|
||||
" VALUES(?,?,?,?,?)",
|
||||
(task_id, agent_id, event_type, canonical_json(dict(payload or {})), iso(self.now())),
|
||||
)
|
||||
|
||||
def register_agent(
|
||||
self,
|
||||
*,
|
||||
agent_id: str,
|
||||
agent_type: str,
|
||||
agent_version: str,
|
||||
capabilities: Iterable[object],
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
agent_id = agent_id.strip()
|
||||
agent_type = agent_type.strip()
|
||||
agent_version = agent_version.strip()
|
||||
if not agent_id or len(agent_id) > 160 or not agent_type or not agent_version:
|
||||
raise TaskProtocolError("AGENT_IDENTITY_INVALID", 400)
|
||||
caps = _string_list(capabilities, "capabilities")
|
||||
meta = _json_object(metadata, "metadata")
|
||||
token = "agt_" + secrets.token_urlsafe(32)
|
||||
now = iso(self.now())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"INSERT INTO agents(agent_id,agent_type,agent_version,capabilities_json,"
|
||||
"metadata_json,token_hash,status,created_at,last_seen_at)"
|
||||
" VALUES(?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
agent_id,
|
||||
agent_type,
|
||||
agent_version,
|
||||
canonical_json(caps),
|
||||
canonical_json(meta),
|
||||
self._token_hash(token),
|
||||
"ACTIVE",
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"AGENT_REGISTERED",
|
||||
agent_id=agent_id,
|
||||
payload={"agent_type": agent_type, "agent_version": agent_version},
|
||||
)
|
||||
connection.commit()
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise TaskProtocolError("AGENT_ALREADY_EXISTS", 409) from exc
|
||||
return {
|
||||
"agent_id": agent_id,
|
||||
"agent_type": agent_type,
|
||||
"agent_version": agent_version,
|
||||
"capabilities": caps,
|
||||
"agent_token": token,
|
||||
"credential_returned_once": True,
|
||||
}
|
||||
|
||||
def authenticate_agent(self, token: str) -> dict[str, Any]:
|
||||
if not token:
|
||||
raise TaskProtocolError("UNAUTHORIZED", 401)
|
||||
token_hash = self._token_hash(token)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM agents WHERE token_hash=? AND status='ACTIVE'",
|
||||
(token_hash,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("UNAUTHORIZED", 401)
|
||||
connection.execute(
|
||||
"UPDATE agents SET last_seen_at=? WHERE agent_id=?",
|
||||
(iso(self.now()), row["agent_id"]),
|
||||
)
|
||||
return {
|
||||
"agent_id": row["agent_id"],
|
||||
"agent_type": row["agent_type"],
|
||||
"agent_version": row["agent_version"],
|
||||
"capabilities": json.loads(row["capabilities_json"]),
|
||||
"metadata": json.loads(row["metadata_json"]),
|
||||
}
|
||||
|
||||
def _idempotent_lookup(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
scope: str,
|
||||
key: str,
|
||||
payload_digest: str,
|
||||
) -> dict[str, Any] | None:
|
||||
row = connection.execute(
|
||||
"SELECT payload_digest,response_json FROM idempotency WHERE scope=? AND key=?",
|
||||
(scope, key),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
if not hmac.compare_digest(row["payload_digest"], payload_digest):
|
||||
raise TaskProtocolError("IDEMPOTENCY_CONFLICT", 409)
|
||||
return json.loads(row["response_json"])
|
||||
|
||||
def _idempotent_store(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
scope: str,
|
||||
key: str,
|
||||
payload_digest: str,
|
||||
response: Mapping[str, Any],
|
||||
) -> None:
|
||||
connection.execute(
|
||||
"INSERT INTO idempotency(scope,key,payload_digest,response_json,created_at)"
|
||||
" VALUES(?,?,?,?,?)",
|
||||
(scope, key, payload_digest, canonical_json(dict(response)), iso(self.now())),
|
||||
)
|
||||
|
||||
def create_task(
|
||||
self,
|
||||
*,
|
||||
task_id: str | None,
|
||||
goal: str,
|
||||
instructions: str,
|
||||
success_criteria: object | None,
|
||||
required_capabilities: Iterable[object] | None = None,
|
||||
constraints: Mapping[str, Any] | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
deadline: str | None = None,
|
||||
priority: int = 0,
|
||||
max_attempts: int = 3,
|
||||
idempotency_key: str,
|
||||
) -> dict[str, Any]:
|
||||
task_id = (task_id or ("task-" + uuid.uuid4().hex)).strip()
|
||||
goal = goal.strip()
|
||||
instructions = instructions.strip()
|
||||
idempotency_key = idempotency_key.strip()
|
||||
if not task_id or len(task_id) > 180 or not goal or not instructions:
|
||||
raise TaskProtocolError("TASK_BINDING_INVALID", 400)
|
||||
if not idempotency_key or len(idempotency_key) > 160:
|
||||
raise TaskProtocolError("IDEMPOTENCY_KEY_INVALID", 400)
|
||||
if not isinstance(priority, int) or isinstance(priority, bool) or not -100 <= priority <= 100:
|
||||
raise TaskProtocolError("PRIORITY_INVALID", 400)
|
||||
if not isinstance(max_attempts, int) or isinstance(max_attempts, bool) or not 1 <= max_attempts <= 20:
|
||||
raise TaskProtocolError("MAX_ATTEMPTS_INVALID", 400)
|
||||
parsed_deadline = parse_time(deadline)
|
||||
if parsed_deadline and parsed_deadline <= self.now():
|
||||
raise TaskProtocolError("DEADLINE_INVALID", 400)
|
||||
criteria = _criteria(success_criteria)
|
||||
required = _string_list(required_capabilities, "required_capabilities")
|
||||
constraints_value = _json_object(constraints, "constraints")
|
||||
metadata_value = _json_object(metadata, "metadata")
|
||||
payload = {
|
||||
"task_id": task_id,
|
||||
"goal": goal,
|
||||
"instructions": instructions,
|
||||
"success_criteria": criteria,
|
||||
"required_capabilities": required,
|
||||
"constraints": constraints_value,
|
||||
"metadata": metadata_value,
|
||||
"deadline": iso(parsed_deadline) if parsed_deadline else None,
|
||||
"priority": priority,
|
||||
"max_attempts": max_attempts,
|
||||
}
|
||||
payload_digest = digest_json(payload)
|
||||
now = iso(self.now())
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
replay = self._idempotent_lookup(
|
||||
connection,
|
||||
scope="create_task",
|
||||
key=idempotency_key,
|
||||
payload_digest=payload_digest,
|
||||
)
|
||||
if replay is not None:
|
||||
connection.commit()
|
||||
return {**replay, "idempotent": True}
|
||||
try:
|
||||
connection.execute(
|
||||
"INSERT INTO tasks(task_id,goal,instructions,success_criteria_json,"
|
||||
"required_capabilities_json,constraints_json,metadata_json,deadline,priority,"
|
||||
"max_attempts,attempt,status,revision,created_at,updated_at)"
|
||||
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
task_id,
|
||||
goal,
|
||||
instructions,
|
||||
canonical_json(criteria),
|
||||
canonical_json(required),
|
||||
canonical_json(constraints_value),
|
||||
canonical_json(metadata_value),
|
||||
payload["deadline"],
|
||||
priority,
|
||||
max_attempts,
|
||||
0,
|
||||
"QUEUED",
|
||||
1,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise TaskProtocolError("TASK_ID_CONFLICT", 409) from exc
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
result = self._row_task(row)
|
||||
self._idempotent_store(
|
||||
connection,
|
||||
scope="create_task",
|
||||
key=idempotency_key,
|
||||
payload_digest=payload_digest,
|
||||
response=result,
|
||||
)
|
||||
self._event(connection, "TASK_CREATED", task_id=task_id, payload={"priority": priority})
|
||||
connection.commit()
|
||||
return result
|
||||
|
||||
def _expire_locked(self, connection: sqlite3.Connection) -> None:
|
||||
now_dt = self.now()
|
||||
now = iso(now_dt)
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM tasks WHERE status IN ('QUEUED','CLAIMED','RUNNING')"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
deadline = parse_time(row["deadline"])
|
||||
if deadline is not None and deadline <= now_dt:
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status='EXPIRED',lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(now, row["task_id"]),
|
||||
)
|
||||
self._event(connection, "TASK_EXPIRED", task_id=row["task_id"])
|
||||
continue
|
||||
if row["status"] in ACTIVE_LEASE_STATES:
|
||||
lease_expires = parse_time(row["lease_expires_at"])
|
||||
if lease_expires is not None and lease_expires <= now_dt:
|
||||
if row["attempt"] >= row["max_attempts"]:
|
||||
next_state = "FAILED"
|
||||
else:
|
||||
next_state = "QUEUED"
|
||||
if next_state == "FAILED":
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status=?,lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(next_state, now, row["task_id"]),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status=?,claimed_by=NULL,lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(next_state, now, row["task_id"]),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"LEASE_EXPIRED",
|
||||
task_id=row["task_id"],
|
||||
agent_id=row["claimed_by"],
|
||||
payload={"next_state": next_state, "attempt": row["attempt"]},
|
||||
)
|
||||
|
||||
def expire_leases(self) -> int:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
before = connection.total_changes
|
||||
self._expire_locked(connection)
|
||||
changed = connection.total_changes - before
|
||||
connection.commit()
|
||||
return changed
|
||||
|
||||
def poll_tasks(self, *, agent_id: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
if not 1 <= int(limit) <= 100:
|
||||
raise TaskProtocolError("LIMIT_INVALID", 400)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._expire_locked(connection)
|
||||
agent = connection.execute(
|
||||
"SELECT capabilities_json FROM agents WHERE agent_id=? AND status='ACTIVE'",
|
||||
(agent_id,),
|
||||
).fetchone()
|
||||
if agent is None:
|
||||
raise TaskProtocolError("AGENT_NOT_FOUND", 404)
|
||||
capabilities = set(json.loads(agent["capabilities_json"]))
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM tasks WHERE status='QUEUED' ORDER BY priority DESC,created_at ASC"
|
||||
).fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
required = set(json.loads(row["required_capabilities_json"]))
|
||||
if required.issubset(capabilities):
|
||||
result.append(self._row_task(row, include_result=False))
|
||||
if len(result) >= int(limit):
|
||||
break
|
||||
connection.commit()
|
||||
return result
|
||||
|
||||
def _agent_capabilities(self, connection: sqlite3.Connection, agent_id: str) -> set[str]:
|
||||
row = connection.execute(
|
||||
"SELECT capabilities_json FROM agents WHERE agent_id=? AND status='ACTIVE'",
|
||||
(agent_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("AGENT_NOT_FOUND", 404)
|
||||
return set(json.loads(row["capabilities_json"]))
|
||||
|
||||
def claim_task(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_seconds: int = 300,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(lease_seconds, int) or isinstance(lease_seconds, bool) or not 30 <= lease_seconds <= 3600:
|
||||
raise TaskProtocolError("LEASE_SECONDS_INVALID", 400)
|
||||
lease_token = "lease_" + secrets.token_urlsafe(32)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._expire_locked(connection)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["status"] != "QUEUED":
|
||||
raise TaskProtocolError("TASK_NOT_CLAIMABLE", 409)
|
||||
capabilities = self._agent_capabilities(connection, agent_id)
|
||||
required = set(json.loads(row["required_capabilities_json"]))
|
||||
if not required.issubset(capabilities):
|
||||
raise TaskProtocolError("AGENT_CAPABILITY_MISMATCH", 403)
|
||||
attempt = int(row["attempt"]) + 1
|
||||
if attempt > int(row["max_attempts"]):
|
||||
raise TaskProtocolError("TASK_ATTEMPTS_EXHAUSTED", 409)
|
||||
expires = self.now() + timedelta(seconds=lease_seconds)
|
||||
now = iso(self.now())
|
||||
updated = connection.execute(
|
||||
"UPDATE tasks SET status='CLAIMED',claimed_by=?,lease_token_hash=?,"
|
||||
"lease_expires_at=?,attempt=?,revision=revision+1,updated_at=? "
|
||||
"WHERE task_id=? AND status='QUEUED'",
|
||||
(
|
||||
agent_id,
|
||||
self._token_hash(lease_token),
|
||||
iso(expires),
|
||||
attempt,
|
||||
now,
|
||||
task_id,
|
||||
),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
raise TaskProtocolError("TASK_CLAIM_CONFLICT", 409)
|
||||
self._event(
|
||||
connection,
|
||||
"TASK_CLAIMED",
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
payload={"attempt": attempt, "lease_expires_at": iso(expires)},
|
||||
)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return {
|
||||
**self._row_task(row, include_result=False),
|
||||
"lease_token": lease_token,
|
||||
"lease_token_returned_once": True,
|
||||
}
|
||||
|
||||
def _validate_lease(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_token: str,
|
||||
allowed_states: frozenset[str] = ACTIVE_LEASE_STATES,
|
||||
) -> sqlite3.Row:
|
||||
self._expire_locked(connection)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["status"] not in allowed_states:
|
||||
raise TaskProtocolError("TASK_LEASE_NOT_ACTIVE", 409)
|
||||
if row["claimed_by"] != agent_id:
|
||||
raise TaskProtocolError("TASK_LEASE_OWNER_MISMATCH", 403)
|
||||
if not row["lease_token_hash"] or not hmac.compare_digest(
|
||||
row["lease_token_hash"], self._token_hash(lease_token)
|
||||
):
|
||||
raise TaskProtocolError("TASK_LEASE_TOKEN_INVALID", 403)
|
||||
expires = parse_time(row["lease_expires_at"])
|
||||
if expires is None or expires <= self.now():
|
||||
raise TaskProtocolError("TASK_LEASE_EXPIRED", 409)
|
||||
return row
|
||||
|
||||
def heartbeat(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_token: str,
|
||||
progress: Mapping[str, Any] | None = None,
|
||||
lease_seconds: int = 300,
|
||||
) -> dict[str, Any]:
|
||||
if not 30 <= int(lease_seconds) <= 3600:
|
||||
raise TaskProtocolError("LEASE_SECONDS_INVALID", 400)
|
||||
progress_value = _json_object(progress, "progress", max_bytes=64 * 1024)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._validate_lease(
|
||||
connection,
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
lease_token=lease_token,
|
||||
)
|
||||
expires = self.now() + timedelta(seconds=int(lease_seconds))
|
||||
now = iso(self.now())
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status='RUNNING',lease_expires_at=?,revision=revision+1,"
|
||||
"updated_at=? WHERE task_id=?",
|
||||
(iso(expires), now, task_id),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"TASK_HEARTBEAT",
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
payload={"progress": progress_value, "lease_expires_at": iso(expires)},
|
||||
)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return self._row_task(row, include_result=False)
|
||||
|
||||
def submit_result(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_token: str,
|
||||
idempotency_key: str,
|
||||
result: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
idempotency_key = idempotency_key.strip()
|
||||
if not idempotency_key or len(idempotency_key) > 160:
|
||||
raise TaskProtocolError("IDEMPOTENCY_KEY_INVALID", 400)
|
||||
result_value = _json_object(result, "result", max_bytes=1024 * 1024)
|
||||
result_digest = digest_json(result_value)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["result_idempotency_key"] == idempotency_key:
|
||||
if not row["result_digest"] or not hmac.compare_digest(row["result_digest"], result_digest):
|
||||
raise TaskProtocolError("IDEMPOTENCY_CONFLICT", 409)
|
||||
connection.commit()
|
||||
return {**self._row_task(row), "idempotent": True}
|
||||
self._validate_lease(
|
||||
connection,
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
lease_token=lease_token,
|
||||
)
|
||||
now = iso(self.now())
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status='VERIFYING',result_json=?,result_digest=?,"
|
||||
"result_idempotency_key=?,lease_token_hash=NULL,lease_expires_at=NULL,"
|
||||
"revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(
|
||||
canonical_json(result_value),
|
||||
result_digest,
|
||||
idempotency_key,
|
||||
now,
|
||||
task_id,
|
||||
),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"RESULT_SUBMITTED",
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
payload={"result_digest": result_digest, "idempotency_key": idempotency_key},
|
||||
)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return self._row_task(row)
|
||||
|
||||
def verify_task(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
decision: str,
|
||||
verification: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
decision = decision.strip().upper()
|
||||
if decision not in VERIFY_DECISIONS:
|
||||
raise TaskProtocolError("VERIFICATION_DECISION_INVALID", 400)
|
||||
verification_value = _json_object(verification, "verification")
|
||||
verification_value = {"decision": decision, **verification_value}
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["status"] != "VERIFYING":
|
||||
raise TaskProtocolError("TASK_NOT_VERIFYING", 409)
|
||||
if decision == "SUCCEEDED":
|
||||
next_state = "SUCCEEDED"
|
||||
elif decision == "FAILED":
|
||||
next_state = "FAILED"
|
||||
elif int(row["attempt"]) >= int(row["max_attempts"]):
|
||||
next_state = "FAILED"
|
||||
verification_value["reason"] = verification_value.get(
|
||||
"reason", "MAX_ATTEMPTS_EXHAUSTED"
|
||||
)
|
||||
else:
|
||||
next_state = "QUEUED"
|
||||
now = iso(self.now())
|
||||
if next_state in {"SUCCEEDED", "FAILED"}:
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status=?,lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,verification_json=?,revision=revision+1,updated_at=? "
|
||||
"WHERE task_id=?",
|
||||
(next_state, canonical_json(verification_value), now, task_id),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status=?,claimed_by=NULL,lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,verification_json=?,revision=revision+1,updated_at=? "
|
||||
"WHERE task_id=?",
|
||||
(next_state, canonical_json(verification_value), now, task_id),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"TASK_VERIFIED",
|
||||
task_id=task_id,
|
||||
agent_id=row["claimed_by"],
|
||||
payload={"decision": decision, "next_state": next_state},
|
||||
)
|
||||
updated = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return self._row_task(updated)
|
||||
|
||||
def cancel_task(self, *, task_id: str, reason: str = "") -> dict[str, Any]:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["status"] in TERMINAL_STATES:
|
||||
connection.commit()
|
||||
return self._row_task(row)
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status='CANCELLED',lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(iso(self.now()), task_id),
|
||||
)
|
||||
self._event(connection, "TASK_CANCELLED", task_id=task_id, payload={"reason": reason})
|
||||
updated = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return self._row_task(updated)
|
||||
|
||||
def get_task(self, task_id: str) -> dict[str, Any]:
|
||||
self.expire_leases()
|
||||
with self._connect() as connection:
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
return self._row_task(row)
|
||||
|
||||
def get_events(self, task_id: str, *, limit: int = 200) -> list[dict[str, Any]]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM events WHERE task_id=? ORDER BY event_id DESC LIMIT ?",
|
||||
(task_id, max(1, min(int(limit), 1000))),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"event_id": row["event_id"],
|
||||
"task_id": row["task_id"],
|
||||
"agent_id": row["agent_id"],
|
||||
"event_type": row["event_type"],
|
||||
"payload": json.loads(row["payload_json"]),
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
for row in reversed(rows)
|
||||
]
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Private Unix-socket caller projection and rehydration client.
|
||||
|
||||
The broker response body is deliberately public metadata only. The short-lived
|
||||
credential projection is delivered in a private response header, consumed
|
||||
inside the model API process, and never returned by an HTTP API or persisted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
from typing import Any, Mapping
|
||||
|
||||
CALLER = "chatgpt-governed-implementer-singlehost"
|
||||
SCOPE = "release:candidate"
|
||||
GOVERNED_TASK_SCOPE = "governed-task:project"
|
||||
PROJECTED_SCOPES = frozenset({GOVERNED_TASK_SCOPE, SCOPE})
|
||||
CREDENTIAL_HEADER = "X-AIOS-Caller-Projection-Credential"
|
||||
|
||||
|
||||
class CallerProjectionError(RuntimeError):
|
||||
def __init__(self, code: str, status: int = 503) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectionResult:
|
||||
public: dict[str, Any]
|
||||
credential_loaded: bool
|
||||
|
||||
|
||||
class _UnixHTTPConnection(http.client.HTTPConnection):
|
||||
def __init__(self, socket_path: Path, timeout: float) -> None:
|
||||
super().__init__("localhost", timeout=timeout)
|
||||
self.socket_path = socket_path
|
||||
|
||||
def connect(self) -> None:
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.settimeout(self.timeout)
|
||||
try:
|
||||
sock.connect(str(self.socket_path))
|
||||
except Exception:
|
||||
sock.close()
|
||||
raise
|
||||
self.sock = sock
|
||||
|
||||
|
||||
class CallerProjectionClient:
|
||||
def __init__(self, socket_path: Path, *, timeout: float = 2.0) -> None:
|
||||
self.socket_path = Path(socket_path)
|
||||
self.timeout = timeout
|
||||
|
||||
@staticmethod
|
||||
def _idempotency_key(caller: str, scope: str) -> str:
|
||||
digest = hashlib.sha256((caller + "\0" + scope).encode()).hexdigest()
|
||||
return "caller-projection-" + digest[:32] + "-v1"
|
||||
|
||||
def rehydrate(
|
||||
self,
|
||||
caller: str,
|
||||
scope: str,
|
||||
request: Mapping[str, Any] | None = None,
|
||||
) -> ProjectionResult:
|
||||
if caller != CALLER:
|
||||
raise CallerProjectionError("CALLER_MISMATCH", 403)
|
||||
if scope not in PROJECTED_SCOPES:
|
||||
raise CallerProjectionError("SCOPE_MISMATCH", 403)
|
||||
body = {
|
||||
"caller": caller,
|
||||
"scope": scope,
|
||||
"idempotency_key": self._idempotency_key(caller, scope),
|
||||
"request": dict(request or {}),
|
||||
}
|
||||
connection = _UnixHTTPConnection(self.socket_path, self.timeout)
|
||||
try:
|
||||
connection.request(
|
||||
"POST",
|
||||
"/v1/project-caller",
|
||||
body=json.dumps(body, sort_keys=True, separators=(",", ":")).encode(),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"X-AIOS-Account": caller,
|
||||
},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
status = response.status
|
||||
raw = response.read()
|
||||
credential = response.getheader(CREDENTIAL_HEADER, "")
|
||||
except (OSError, TimeoutError, http.client.HTTPException) as exc:
|
||||
raise CallerProjectionError("PROJECTION_BROKER_UNAVAILABLE", 503) from exc
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise CallerProjectionError("PROJECTION_RESPONSE_INVALID", 503) from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise CallerProjectionError("PROJECTION_RESPONSE_INVALID", 503)
|
||||
if status != 200:
|
||||
code = str(value.get("error") or "PROJECTION_DENIED")
|
||||
raise CallerProjectionError(code, 403 if status < 500 else 503)
|
||||
if any(
|
||||
str(key).lower() in {
|
||||
"credential",
|
||||
"credential_material",
|
||||
"token",
|
||||
"bearer",
|
||||
"secret",
|
||||
"password",
|
||||
}
|
||||
for key in value
|
||||
):
|
||||
raise CallerProjectionError("PROJECTION_SECRET_FIELD_REJECTED", 503)
|
||||
if value.get("caller") != CALLER or value.get("scope") != SCOPE:
|
||||
raise CallerProjectionError("PROJECTION_BINDING_INVALID", 503)
|
||||
if value.get("projection_state") != "ACKED":
|
||||
raise CallerProjectionError("PROJECTION_STATE_INVALID", 503)
|
||||
if value.get("credential_state") != "REHYDRATED":
|
||||
raise CallerProjectionError("CREDENTIAL_REHYDRATION_INVALID", 503)
|
||||
if value.get("rehydration_state") not in {"GENERATED", "RECOVERED"}:
|
||||
raise CallerProjectionError("REHYDRATION_STATE_INVALID", 503)
|
||||
effective_scopes = value.get("effective_scopes")
|
||||
if (
|
||||
not isinstance(effective_scopes, list)
|
||||
or len(effective_scopes) != len(set(effective_scopes))
|
||||
or not all(isinstance(item, str) for item in effective_scopes)
|
||||
or scope not in effective_scopes
|
||||
or not set(effective_scopes).issubset(PROJECTED_SCOPES)
|
||||
):
|
||||
raise CallerProjectionError("PROJECTION_SCOPE_INVALID", 503)
|
||||
if value.get("secret_exposed") is not False:
|
||||
raise CallerProjectionError("PROJECTION_SECRET_EXPOSURE", 503)
|
||||
if not isinstance(credential, str) or len(credential) < 32:
|
||||
raise CallerProjectionError("CREDENTIAL_REHYDRATION_MISSING", 503)
|
||||
return ProjectionResult(public=dict(value), credential_loaded=True)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,15 @@ from __future__ import annotations
|
||||
import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, urllib.request, uuid
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
from aios_caller_admission_projection import (
|
||||
CALLER,
|
||||
SCOPE,
|
||||
GOVERNED_TASK_SCOPE,
|
||||
PROJECTED_SCOPES,
|
||||
CallerProjectionClient,
|
||||
CallerProjectionError,
|
||||
)
|
||||
from baseline_authority import BaselineAuthority, BaselineAuthorityError
|
||||
from typing import Any
|
||||
|
||||
@@ -89,6 +98,11 @@ SILENT_RELEASE_PUBLIC_KEYS = Path(os.getenv(
|
||||
'AIOS_SILENT_RELEASE_PUBLIC_KEYS',
|
||||
'/etc/aios-gitops/silent-release-owner-public-keys.json',
|
||||
))
|
||||
CALLER_PROJECTION_SOCKET = Path(os.getenv(
|
||||
'AIOS_OWNER_AUTH_BROKER_SOCKET',
|
||||
'/run/aios-owner-authorization-broker/broker.sock',
|
||||
))
|
||||
CALLER_PROJECTION_CLIENT = CallerProjectionClient(CALLER_PROJECTION_SOCKET)
|
||||
monitor = ActionMonitor()
|
||||
|
||||
BASE_MODELS = [
|
||||
@@ -112,6 +126,9 @@ READ_ONLY_GET_PATHS = frozenset({
|
||||
'/admin/ai-evolution/api/data',
|
||||
'/api/release-consistency', '/api/tag-publication-status',
|
||||
'/api/release-acceptance',
|
||||
'/api/governed-write-capabilities', '/api/control-plane-snapshot',
|
||||
'/api/caller-capabilities',
|
||||
'/api/online-agent-task', '/api/online-agent-task-events',
|
||||
'/getReleaseCandidate', '/getReleasePlan', '/getReleaseAcceptance',
|
||||
'/getApprovalEnvelope', '/getReleaseSaga', '/getCapabilityStatus',
|
||||
'/getRecoveryStatus',
|
||||
@@ -407,14 +424,113 @@ class H(BaseHTTPRequestHandler):
|
||||
except Exception: return {'_raw':raw}
|
||||
def actor(self):
|
||||
auth=self.headers.get('Authorization',''); tok=auth[7:].strip() if auth.startswith('Bearer ') else self.headers.get('X-AIOS-Token','')
|
||||
rec=authenticate(tok)
|
||||
if rec: return {'ok':True,'account':safe(rec.get('account') or rec.get('name') or 'token-user'),'role':rec.get('role','user'),'scopes':set(rec.get('scopes') or [])}
|
||||
return {'ok':False,'account':'anonymous','role':'anonymous','scopes':set()}
|
||||
def need(self, scope):
|
||||
a=self.actor()
|
||||
if not a['ok']: self.sendj({'error':'unauthorized','hint':'Bearer token required'},401); return None
|
||||
if '*' not in a['scopes'] and scope not in a['scopes']: self.sendj({'error':'forbidden','required_scope':scope,'account':a['account']},403); return None
|
||||
try:
|
||||
rec=authenticate(tok)
|
||||
except Exception:
|
||||
rec=None
|
||||
if not isinstance(rec, dict) or not rec:
|
||||
return {
|
||||
'ok': False,
|
||||
'account': 'anonymous',
|
||||
'role': 'anonymous',
|
||||
'scopes': set(),
|
||||
'static_scopes': set(),
|
||||
'projection': None,
|
||||
'credential_loaded': False,
|
||||
}
|
||||
scopes = set(rec.get('scopes') or [])
|
||||
return {
|
||||
'ok': True,
|
||||
'account': safe(rec.get('account') or rec.get('name') or 'token-user'),
|
||||
'role': rec.get('role', 'user'),
|
||||
'scopes': scopes,
|
||||
'static_scopes': set(scopes),
|
||||
'projection': None,
|
||||
'credential_loaded': False,
|
||||
}
|
||||
def need(self, scope, *, projection_request=None):
|
||||
a = self.actor()
|
||||
if not a['ok']:
|
||||
self.sendj({'error': 'unauthorized', 'hint': 'Bearer token required'}, 401)
|
||||
return None
|
||||
if a['account'] == CALLER and scope in PROJECTED_SCOPES:
|
||||
try:
|
||||
result = CALLER_PROJECTION_CLIENT.rehydrate(
|
||||
a['account'],
|
||||
scope,
|
||||
projection_request,
|
||||
)
|
||||
except CallerProjectionError as exc:
|
||||
self.sendj(
|
||||
{
|
||||
'error': 'caller_projection_denied',
|
||||
'projection_state': 'DENIED',
|
||||
'detail': exc.code,
|
||||
'account': a['account'],
|
||||
},
|
||||
exc.status,
|
||||
)
|
||||
return None
|
||||
projection = result.public
|
||||
a['scopes'].update(projection.get('effective_scopes') or [])
|
||||
a['projection'] = projection
|
||||
a['credential_loaded'] = result.credential_loaded
|
||||
if '*' not in a['scopes'] and scope not in a['scopes']:
|
||||
self.sendj(
|
||||
{
|
||||
'error': 'forbidden',
|
||||
'required_scope': scope,
|
||||
'account': a['account'],
|
||||
},
|
||||
403,
|
||||
)
|
||||
return None
|
||||
return a
|
||||
def caller_capabilities(self):
|
||||
actor = self.actor()
|
||||
if not actor['ok']:
|
||||
return self.sendj(
|
||||
{'error': 'unauthorized', 'hint': 'Bearer token required'},
|
||||
401,
|
||||
)
|
||||
effective = set(actor['static_scopes'])
|
||||
projection_state = 'NOT_REQUIRED'
|
||||
credential_state = 'NOT_REQUIRED'
|
||||
rehydration_state = 'NOT_APPLICABLE'
|
||||
admitted = True
|
||||
projection_error = None
|
||||
if actor['account'] == CALLER:
|
||||
try:
|
||||
result = CALLER_PROJECTION_CLIENT.rehydrate(
|
||||
actor['account'],
|
||||
GOVERNED_TASK_SCOPE,
|
||||
{},
|
||||
)
|
||||
except CallerProjectionError as exc:
|
||||
admitted = False
|
||||
projection_state = 'DENIED'
|
||||
credential_state = 'NOT_REHYDRATED'
|
||||
rehydration_state = 'FAILED'
|
||||
projection_error = exc.code
|
||||
else:
|
||||
effective.update(result.public.get('effective_scopes') or [])
|
||||
projection_state = result.public['projection_state']
|
||||
credential_state = result.public['credential_state']
|
||||
rehydration_state = result.public['rehydration_state']
|
||||
return self.sendj(
|
||||
{
|
||||
'schema': 'aios.caller-capability-introspection.v1',
|
||||
'caller_identity': actor['account'],
|
||||
'admitted': admitted,
|
||||
'effective_scopes': sorted(effective),
|
||||
'projection_state': projection_state,
|
||||
'credential_state': credential_state,
|
||||
'rehydration_state': rehydration_state,
|
||||
'projection_error': projection_error,
|
||||
'secret_exposed': False,
|
||||
}
|
||||
)
|
||||
|
||||
def bridge_release(self, path, data):
|
||||
route = {
|
||||
'/createTagApprovalRequest': '/v2/tag/approval-request',
|
||||
@@ -446,24 +562,183 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj(body, exc.code)
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
return self.sendj({'error': 'GOVERNED_BRIDGE_UNAVAILABLE', 'detail': type(exc).__name__}, 503)
|
||||
def do_POST(self):
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
if path in {
|
||||
'/createTagApprovalRequest', '/requestOrCreateTag', '/getTagPublicationStatus',
|
||||
'/buildProductionArtifact', '/executeApprovedProductionRelease',
|
||||
'/getReleaseConsistency', '/runReleaseAcceptance',
|
||||
}:
|
||||
actor = self.need('tag:create')
|
||||
if not actor:
|
||||
return
|
||||
return self.bridge_release(path, self.body())
|
||||
if reject_non_read_http(self, 'POST', path):
|
||||
return
|
||||
return self.sendj({'error': 'not_found', 'path': path}, 404)
|
||||
def bridge_v2_json(self, route, data=None, method='POST'):
|
||||
base = os.getenv('AIOS_BRIDGE_V2_URL', 'http://127.0.0.1:18892').rstrip('/')
|
||||
payload = None if data is None else json.dumps(
|
||||
data, sort_keys=True, separators=(',', ':')
|
||||
).encode('utf-8')
|
||||
request = urllib.request.Request(
|
||||
base + route,
|
||||
data=payload,
|
||||
headers={'Content-Type': 'application/json'} if payload is not None else {},
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
result = json.loads(response.read().decode('utf-8'))
|
||||
return self.sendj(result, response.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
body = json.loads(exc.read().decode('utf-8'))
|
||||
except Exception:
|
||||
body = {'error': 'bridge_http_error'}
|
||||
return self.sendj(body, exc.code)
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
return self.sendj(
|
||||
{'error': 'GOVERNED_BRIDGE_UNAVAILABLE', 'detail': type(exc).__name__},
|
||||
503,
|
||||
)
|
||||
|
||||
def control_plane_snapshot(self):
|
||||
"""Build a non-sensitive snapshot locally when Bridge v2 has no snapshot HTTP route."""
|
||||
base = os.getenv('AIOS_BRIDGE_V2_URL', 'http://127.0.0.1:18892').rstrip('/')
|
||||
|
||||
def bridge_get(route):
|
||||
request = urllib.request.Request(
|
||||
base + route,
|
||||
headers={'Accept': 'application/json'},
|
||||
method='GET',
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
if response.status != 200:
|
||||
raise RuntimeError('bridge_read_http_' + str(response.status))
|
||||
value = json.loads(response.read().decode('utf-8'))
|
||||
if not isinstance(value, dict):
|
||||
raise RuntimeError('bridge_read_not_object')
|
||||
return value
|
||||
|
||||
try:
|
||||
runtime = bridge_get('/v2/runtime')
|
||||
capabilities = bridge_get('/v2/capabilities')
|
||||
repository = repository_snapshot(
|
||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||
repository_identity=REPOSITORY_IDENTITY,
|
||||
)
|
||||
if not repository.get('repository_available'):
|
||||
return self.sendj({
|
||||
'error': 'AUTHORITATIVE_REPOSITORY_UNAVAILABLE',
|
||||
'query_error': repository.get('query_error'),
|
||||
}, 503)
|
||||
baseline = dict(BASELINE_AUTHORITY.read())
|
||||
except BaselineAuthorityError as exc:
|
||||
return self.sendj({
|
||||
'error': 'RECONCILIATION_REQUIRED',
|
||||
'detail': str(exc),
|
||||
}, 503)
|
||||
except urllib.error.HTTPError as exc:
|
||||
return self.sendj({
|
||||
'error': 'GOVERNED_BRIDGE_UNAVAILABLE',
|
||||
'detail': 'HTTP_' + str(exc.code),
|
||||
}, 503)
|
||||
except (urllib.error.URLError, TimeoutError, OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
|
||||
return self.sendj({
|
||||
'error': 'GOVERNED_BRIDGE_UNAVAILABLE',
|
||||
'detail': type(exc).__name__,
|
||||
}, 503)
|
||||
|
||||
legacy = capabilities.get('legacy_writes_enabled')
|
||||
if legacy is None:
|
||||
legacy = capabilities.get('legacy_global_writes_enabled')
|
||||
payload = {
|
||||
'schema': 'aios.control-plane-snapshot.aggregate.v1',
|
||||
'snapshot_source': 'model-api-local-aggregate',
|
||||
'version': {
|
||||
'target_release': runtime.get('control_plane_release_version'),
|
||||
},
|
||||
'runtime': {
|
||||
'bridge_schema_version': runtime.get('bridge_schema_version'),
|
||||
'control_plane_release_version': runtime.get('control_plane_release_version'),
|
||||
'runtime_release_commit': runtime.get('runtime_release_commit'),
|
||||
'runtime_path': runtime.get('runtime_path'),
|
||||
'artifact_sha256': runtime.get('artifact_sha256'),
|
||||
'drift_status': runtime.get('drift_status'),
|
||||
'deployment_state': runtime.get('deployment_state'),
|
||||
},
|
||||
'repository': {
|
||||
'identity': runtime.get('repository_identity') or runtime.get('repository') or REPOSITORY_IDENTITY,
|
||||
'authority_git_dir': runtime.get('authoritative_git_dir') or repository.get('authoritative_git_dir'),
|
||||
'authority_main_oid': repository.get('authoritative_head_oid'),
|
||||
},
|
||||
'production': {
|
||||
'baseline_version': baseline.get('current_baseline'),
|
||||
'target_oid': baseline.get('target_oid'),
|
||||
'deployment_state': baseline.get('status'),
|
||||
'baseline_generation': baseline.get('generation'),
|
||||
'baseline_sha256': baseline.get('authority_sha256'),
|
||||
},
|
||||
'task': {},
|
||||
'capabilities': {
|
||||
'legacy_writes_enabled': legacy,
|
||||
'governed_writes_enabled': capabilities.get('governed_writes_enabled'),
|
||||
'force_push': capabilities.get('force_push'),
|
||||
'main_write': capabilities.get('main_write'),
|
||||
'tag_write': capabilities.get('tag_write'),
|
||||
'baseline_write': capabilities.get('baseline_write'),
|
||||
'production_write': capabilities.get('production_write'),
|
||||
},
|
||||
'credential_broker': {
|
||||
'status': 'SERVER_SIDE_ONLY',
|
||||
'provider': 'BridgeV2RuntimeCredentialProjection',
|
||||
},
|
||||
'secret_material_exposed': False,
|
||||
}
|
||||
return self.sendj(payload)
|
||||
|
||||
def agent_task_control(self, method, route, data=None):
|
||||
token_path = Path(os.getenv(
|
||||
'AIOS_AGENT_TASK_CONTROL_TOKEN_FILE',
|
||||
'/run/credentials/aios-gitops-model-api-v2r2c1-staging.service/agent_task_control_token',
|
||||
))
|
||||
if token_path.is_symlink() or not token_path.is_file():
|
||||
return self.sendj({'error': 'AGENT_TASK_CONTROL_CREDENTIAL_UNAVAILABLE'}, 503)
|
||||
token = token_path.read_text(encoding='utf-8').strip()
|
||||
if len(token) < 32:
|
||||
return self.sendj({'error': 'AGENT_TASK_CONTROL_CREDENTIAL_INVALID'}, 503)
|
||||
base = os.getenv('AIOS_AGENT_TASK_API_URL', 'http://127.0.0.1:18940').rstrip('/')
|
||||
payload = None if data is None else json.dumps(
|
||||
data, sort_keys=True, separators=(',', ':')
|
||||
).encode('utf-8')
|
||||
headers = {'Authorization': 'Bearer ' + token, 'Accept': 'application/json'}
|
||||
if payload is not None:
|
||||
headers['Content-Type'] = 'application/json'
|
||||
request = urllib.request.Request(
|
||||
base + route, data=payload, headers=headers, method=method
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
result = json.loads(response.read().decode('utf-8'))
|
||||
return self.sendj(result, response.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
body = json.loads(exc.read().decode('utf-8'))
|
||||
except Exception:
|
||||
body = {'error': 'agent_task_http_error'}
|
||||
return self.sendj(body, exc.code)
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
return self.sendj(
|
||||
{'error': 'AGENT_TASK_API_UNAVAILABLE', 'detail': type(exc).__name__},
|
||||
503,
|
||||
)
|
||||
|
||||
def do_GET(self):
|
||||
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path
|
||||
if path not in READ_ONLY_GET_PATHS: return self.sendj({'error':'not_found','path':path},404)
|
||||
q=urllib.parse.parse_qs(u.query)
|
||||
if path == '/api/governed-write-capabilities':
|
||||
return self.bridge_v2_json('/v2/capabilities', method='GET')
|
||||
if path == '/api/control-plane-snapshot':
|
||||
return self.control_plane_snapshot()
|
||||
if path == '/api/caller-capabilities':
|
||||
return self.caller_capabilities()
|
||||
if path in ('/api/online-agent-task', '/api/online-agent-task-events'):
|
||||
actor = self.need('agent-task:control')
|
||||
if not actor:
|
||||
return
|
||||
task_id = (q.get('task_id') or [''])[0]
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._:-]{2,179}', task_id):
|
||||
return self.sendj({'error': 'task_id_invalid'}, 400)
|
||||
suffix = '/events' if path.endswith('-events') else ''
|
||||
return self.agent_task_control('GET', '/v1/tasks/' + task_id + suffix)
|
||||
try:
|
||||
if path in ('/health','/api/health'):
|
||||
return self.sendj({
|
||||
@@ -700,12 +975,60 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj({'error':'not_found','path':path},404)
|
||||
def do_POST(self):
|
||||
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
||||
governed_routes = {
|
||||
'/createGovernedTask': ('/v2/governed-task', 'branch:create'),
|
||||
'/acquireWriterLease': ('/v2/writer-lease', 'file:write'),
|
||||
'/commitFileGoverned': ('/v2/commit-file', 'file:write'),
|
||||
'/commitFilesGoverned': ('/v2/commit-files', 'file:write'),
|
||||
'/getGitOpsJob': ('/v2/job', 'file:write'),
|
||||
'/resumeGitOpsJob': ('/v2/job/resume', 'file:write'),
|
||||
'/reconcileGitOpsJob': ('/v2/job/reconcile', 'file:write'),
|
||||
'/createTagApprovalRequest': ('/v2/tag/approval-request', 'tag:create'),
|
||||
'/requestOrCreateTag': ('/v2/tag/publish', 'tag:create'),
|
||||
'/getTagPublicationStatus': ('/v2/tag/status', 'tag:create'),
|
||||
'/buildProductionArtifact': ('/v2/release/artifact', 'tag:create'),
|
||||
'/executeApprovedProductionRelease': ('/v2/release/execute', 'tag:create'),
|
||||
'/getReleaseConsistency': ('/v2/release/consistency', 'tag:create'),
|
||||
'/runReleaseAcceptance': ('/v2/release/acceptance', 'tag:create'),
|
||||
}
|
||||
if path in governed_routes:
|
||||
route, scope = governed_routes[path]
|
||||
if path == '/createGovernedTask' and self.actor()['account'] == CALLER:
|
||||
scope = GOVERNED_TASK_SCOPE
|
||||
actor = self.need(scope)
|
||||
if not actor:
|
||||
return
|
||||
return self.bridge_v2_json(route, self.body(), method='POST')
|
||||
if path in {
|
||||
'/createOnlineAgentTask', '/registerOnlineAgent',
|
||||
'/verifyOnlineAgentTask', '/cancelOnlineAgentTask',
|
||||
}:
|
||||
actor = self.need('agent-task:control')
|
||||
if not actor:
|
||||
return
|
||||
data = self.body()
|
||||
if path == '/createOnlineAgentTask':
|
||||
return self.agent_task_control('POST', '/v1/tasks', data)
|
||||
if path == '/registerOnlineAgent':
|
||||
return self.agent_task_control('POST', '/v1/agents/register', data)
|
||||
task_id = str(data.pop('task_id', '') or '')
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._:-]{2,179}', task_id):
|
||||
return self.sendj({'error': 'task_id_invalid'}, 400)
|
||||
suffix = '/verify' if path == '/verifyOnlineAgentTask' else '/cancel'
|
||||
return self.agent_task_control('POST', '/v1/tasks/' + task_id + suffix, data)
|
||||
if reject_non_read_http(self, 'POST', path): return
|
||||
data=self.body()
|
||||
try:
|
||||
if path == '/createReleaseCandidate':
|
||||
a=self.need('release:candidate')
|
||||
if not a: return
|
||||
a = self.need(
|
||||
'release:candidate',
|
||||
projection_request={
|
||||
'source_commit': str(data.get('source_commit') or ''),
|
||||
'version': str(data.get('version') or ''),
|
||||
},
|
||||
)
|
||||
if not a:
|
||||
return
|
||||
return self.sendj(
|
||||
silent_release_service().create_release_candidate(data)
|
||||
)
|
||||
@@ -996,7 +1319,7 @@ def api_spec():
|
||||
return {
|
||||
'version': 'V2R2C1-IMMUTABLE-STAGING',
|
||||
'openapi': '/api/openapi.yaml',
|
||||
'read': ['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],
|
||||
'read': ['GET /api/caller-capabilities','GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],
|
||||
'write': ['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl','POST /createReleaseCandidate','POST /submitOwnerApproval','POST /startDeployment'],
|
||||
'policy': {
|
||||
'bearer_required_for_write': True,
|
||||
@@ -1011,6 +1334,9 @@ def api_spec():
|
||||
'silent_release_shadow_default': True,
|
||||
'runtime_mode': 'IMMUTABLE_STAGING',
|
||||
'governed_release_routing': 'bridge-v2',
|
||||
'caller_admission_projection': 'durable-owner-policy-server-side-ack',
|
||||
'caller_projection_scope': SCOPE,
|
||||
'caller_projection_caller': CALLER,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# External agent ingress. TLS termination is assumed on the enclosing server.
|
||||
# Control endpoints (/v1/agents/register, POST /v1/tasks, verify/cancel/events)
|
||||
# are intentionally NOT exposed here; the AIOS control plane reaches them on
|
||||
# loopback with its server-side credential.
|
||||
|
||||
location = /agent-tasks/health {
|
||||
rewrite ^/agent-tasks/(.*)$ /$1 break;
|
||||
proxy_pass http://127.0.0.1:18940;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /agent-tasks/v1/tasks/poll {
|
||||
limit_except POST { deny all; }
|
||||
rewrite ^/agent-tasks/(.*)$ /$1 break;
|
||||
proxy_pass http://127.0.0.1:18940;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 1m;
|
||||
proxy_read_timeout 35s;
|
||||
}
|
||||
|
||||
location ~ ^/agent-tasks/v1/tasks/[A-Za-z0-9][A-Za-z0-9._:-]{2,179}/(claim|heartbeat|result)$ {
|
||||
limit_except POST { deny all; }
|
||||
rewrite ^/agent-tasks/(.*)$ /$1 break;
|
||||
proxy_pass http://127.0.0.1:18940;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 1m;
|
||||
proxy_read_timeout 35s;
|
||||
}
|
||||
|
||||
location ~ ^/agent-tasks/v1/tasks/[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$ {
|
||||
limit_except GET { deny all; }
|
||||
rewrite ^/agent-tasks/(.*)$ /$1 break;
|
||||
proxy_pass http://127.0.0.1:18940;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 35s;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
[Unit]
|
||||
Description=AIOS Online Agent Task API
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios-agent-task
|
||||
Group=aios-agent-task
|
||||
WorkingDirectory=@RUNTIME_ROOT@
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
Environment=PYTHONPATH=@RUNTIME_ROOT@/app
|
||||
Environment=AIOS_AGENT_TASK_DB=/var/lib/aios-agent-task/state.sqlite3
|
||||
Environment=AIOS_AGENT_TASK_HOST=127.0.0.1
|
||||
Environment=AIOS_AGENT_TASK_PORT=18940
|
||||
Environment=AIOS_AGENT_TASK_CONTROL_TOKEN_FILE=/run/credentials/aios-agent-task-api.service/control_token
|
||||
LoadCredential=control_token:/etc/aios-gitops/agent-task-control.token
|
||||
ExecStart=/usr/bin/python3 @RUNTIME_ROOT@/app/aios_agent_task_api.py
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
ReadOnlyPaths=@RUNTIME_ROOT@
|
||||
ReadWritePaths=/var/lib/aios-agent-task
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,7 @@
|
||||
# Existing model API drop-in. Install only with the governed runtime package.
|
||||
[Service]
|
||||
SupplementaryGroups=gitops aios-executor-exchange aios-autonomous-agent-worker
|
||||
Environment=AIOS_OWNER_AUTH_BROKER_SOCKET=/run/aios-owner-authorization-broker/broker.sock
|
||||
Environment=AIOS_OWNER_AUTH_BROKER_ACCOUNT=chatgpt-governed-implementer-singlehost
|
||||
Environment=AIOS_OWNER_AUTH_BROKER_CALLER=chatgpt-governed-implementer-singlehost
|
||||
Environment=AIOS_OWNER_AUTH_BROKER_SCOPE=release:candidate
|
||||
@@ -28,6 +28,9 @@ Environment=AIOS_GITOPS_MODEL_API_SERVICE=aios-gitops-model-api-v2r2c1-staging.s
|
||||
Environment=AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
||||
Environment=AIOS_SILENT_RELEASE_STATE_ROOT=/var/lib/aios-gitops/model-api-staging/silent-release
|
||||
Environment=AIOS_BRIDGE_V2_URL=http://127.0.0.1:18892
|
||||
Environment=AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940
|
||||
Environment=AIOS_AGENT_TASK_CONTROL_TOKEN_FILE=/run/credentials/aios-gitops-model-api-v2r2c1-staging.service/agent_task_control_token
|
||||
LoadCredential=agent_task_control_token:/etc/aios-gitops/agent-task-control.token
|
||||
Environment=AIOS_GITOPS_HOST=127.0.0.1
|
||||
Environment=AIOS_GITOPS_PORT=18893
|
||||
Environment=PYTHONPATH=@RUNTIME_ROOT@/app:@RUNTIME_ROOT@/runtime-agent-os
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
[Unit]
|
||||
Description=AIOS durable Owner authorization broker
|
||||
After=network.target aios-chatgpt-bridge-v2-staging.service
|
||||
Wants=aios-chatgpt-bridge-v2-staging.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=aios-executor-exchange
|
||||
RuntimeDirectory=aios-owner-authorization-broker
|
||||
RuntimeDirectoryMode=0750
|
||||
StateDirectory=aios-owner-authorization-broker
|
||||
StateDirectoryMode=0750
|
||||
LoadCredential=agent_task_control_token:/etc/aios-gitops/agent-task-control.token
|
||||
Environment=AIOS_AGENT_TASK_CONTROL_TOKEN_FILE=%d/agent_task_control_token
|
||||
Environment=AIOS_OWNER_DURABLE_AUTHORIZATION_POLICY=/etc/aios-gitops/owner-durable-authorization.json
|
||||
Environment=AIOS_OWNER_DURABLE_AUTHORIZATION_REVOCATIONS=/etc/aios-gitops/owner-durable-authorization-revocations.json
|
||||
Environment=AIOS_OWNER_AUTH_BROKER_ACCOUNT=chatgpt-governed-implementer-singlehost
|
||||
Environment=AIOS_OWNER_AUTH_BROKER_ALLOWED_USERS=aios-autonomous-agent-worker,aiman
|
||||
Environment=AIOS_OWNER_AUTH_BROKER_SOCKET=%t/aios-owner-authorization-broker/broker.sock
|
||||
ExecStart=/usr/bin/python3 -B @RUNTIME_ROOT@/active-runtime/app/aios_durable_owner_authorization.py
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectClock=yes
|
||||
LockPersonality=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=127.0.0.0/8
|
||||
ReadOnlyPaths=/etc/aios-gitops/owner-durable-authorization.json
|
||||
ReadOnlyPaths=/etc/aios-gitops/owner-durable-authorization-revocations.json
|
||||
ReadOnlyPaths=@RUNTIME_ROOT@
|
||||
InaccessiblePaths=/data/aios-gitops /home
|
||||
UMask=0007
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,154 @@
|
||||
# Caller admission projection and Bridge execution rehydration
|
||||
|
||||
This runbook covers the exact durable Owner policy and exact API caller:
|
||||
|
||||
- caller: chatgpt-governed-implementer-singlehost
|
||||
- durable policy: /etc/aios-gitops/owner-durable-authorization.json
|
||||
- revocations: /etc/aios-gitops/owner-durable-authorization-revocations.json
|
||||
- grant: owner-permanent-preparation-20260819
|
||||
- durable capabilities: governed-task:project and release:candidate
|
||||
|
||||
## Admission source
|
||||
|
||||
The model API authenticates the caller with the existing static token registry
|
||||
only to identify the caller and retain the existing static scopes. For the
|
||||
exact caller, release:candidate and governed-task:project are not permanent
|
||||
static-token grants. The model API asks the Unix-socket Owner broker to
|
||||
rehydrate the requested durable capability.
|
||||
|
||||
The broker reloads the durable policy and revocation file on every request,
|
||||
checks the exact caller, requested capability, policy constraints, optional
|
||||
expiry and revocation state, and rejects all capabilities outside the durable
|
||||
policy. It never uses a release-worker identity or credential.
|
||||
|
||||
## Projection lifecycle and ACK semantics
|
||||
|
||||
The server-side flow is:
|
||||
|
||||
durable Owner policy
|
||||
-> exact caller and capability validation
|
||||
-> caller projection rows for the policy capabilities
|
||||
-> in-memory short-lived credential generation
|
||||
-> projection ACKED / credential REHYDRATED
|
||||
-> model API admission
|
||||
-> Bridge governed-task admission and execution envelope
|
||||
|
||||
The existing caller_projections table stores only projection metadata. A
|
||||
rehydration request atomically projects the currently authorized caller
|
||||
capabilities, so a valid current policy containing both capabilities returns
|
||||
both
|
||||
governed-task:project and release:candidate in effective_scopes. No raw
|
||||
credential, bearer, token, or credential hash is stored.
|
||||
|
||||
ACKED means the broker completed the server-side projection transaction and
|
||||
validated the returned public state. It is not an Owner Approval, release
|
||||
approval, deployment authorization, or release-worker identity.
|
||||
|
||||
## Credential rehydration, expiry and revocation
|
||||
|
||||
Credential material is generated server-side, sent only in the private Unix
|
||||
socket response header to the model API process, consumed for admission, and
|
||||
discarded. The public response and introspection never contain it.
|
||||
|
||||
Each request reloads policy state. Missing, malformed, expired or revoked
|
||||
policy, caller mismatch, capability mismatch, invalid projection state,
|
||||
credential generation failure, or broker outage fails closed. Projection rows
|
||||
are short-lived metadata and are rehydrated after process restart; the
|
||||
rehydration state is GENERATED or RECOVERED.
|
||||
|
||||
The exact caller uses governed-task:project for createGovernedTask admission.
|
||||
Other callers keep their existing branch:create path. The existing
|
||||
agent-task:control, branch:create and file:write static behavior remains
|
||||
available and is not expanded by this repair.
|
||||
|
||||
## Bridge execution projection
|
||||
|
||||
A governed task created through the existing Owner broker -> Bridge path
|
||||
receives metadata.execution with schema aios.online-agent.execution.v2 and an
|
||||
execution_projection record. A source_capture placeholder with write
|
||||
capabilities is intentionally not claimable. The existing deterministic plan
|
||||
registry and execution-rebind CAS path must produce the deterministic plan
|
||||
before a worker can claim it.
|
||||
|
||||
The legitimate execution chain is:
|
||||
|
||||
QUEUED -> PROJECTED -> deterministic-plan/rebind CAS -> CLAIMED
|
||||
-> heartbeat -> Writer Lease -> CAS branch/file writes -> tests
|
||||
-> official verifier -> immutable runtime evidence.
|
||||
|
||||
Never write metadata.execution, task events, plan records, ACKs, outbox rows or
|
||||
broker database rows by hand. Never bypass the Bridge with SSH/root or a
|
||||
release-worker credential.
|
||||
|
||||
## Restart recovery and failure states
|
||||
|
||||
After broker or model API restart, repeat the read-only capability check and
|
||||
make one normal admission request. The broker must regenerate a short-lived
|
||||
credential from the durable policy and report RECOVERED without manual token
|
||||
operations.
|
||||
|
||||
Useful fail-closed states include:
|
||||
|
||||
- PROJECTION_BROKER_UNAVAILABLE
|
||||
- POLICY_FILE_INVALID
|
||||
- POLICY_EXPIRED
|
||||
- OWNER_GRANT_REVOKED
|
||||
- CALLER_MISMATCH
|
||||
- SCOPE_MISMATCH
|
||||
- CAPABILITY_NOT_AUTHORIZED
|
||||
- PROJECTION_STATE_INVALID
|
||||
- CREDENTIAL_REHYDRATION_MISSING
|
||||
- TASK_EXECUTION_PROJECTION_MISSING
|
||||
- TASK_PERSISTED_BINDING_CONFLICT
|
||||
- EXECUTION_REBIND_CAS_MISMATCH
|
||||
|
||||
A rejected state is not repaired by editing SQLite, projection_outbox,
|
||||
static token registry, task metadata or task events.
|
||||
|
||||
## Whoami and effective-scope diagnosis
|
||||
|
||||
Use the read-only model API with the caller's existing credential:
|
||||
|
||||
GET /api/caller-capabilities
|
||||
|
||||
The response is safe to display and contains:
|
||||
|
||||
- caller_identity
|
||||
- admitted
|
||||
- effective_scopes
|
||||
- projection_state
|
||||
- credential_state
|
||||
- rehydration_state
|
||||
- projection_error
|
||||
- secret_exposed=false
|
||||
|
||||
For the authorized current policy, effective_scopes must contain:
|
||||
|
||||
- agent-task:control
|
||||
- branch:create
|
||||
- file:write
|
||||
- governed-task:project
|
||||
- release:candidate
|
||||
|
||||
No credential, token, bearer, password or secret value may appear in the
|
||||
response, logs, diagnostics, Git diff or Task metadata.
|
||||
|
||||
For broker-level diagnosis use the read-only /v1/capabilities endpoint through
|
||||
the authorized Unix peer. It must identify the exact account and grant and
|
||||
show both durable capabilities; it must not be used to create manual ACKs.
|
||||
|
||||
## Release:candidate troubleshooting
|
||||
|
||||
For createReleaseCandidate, the caller must pass only source_commit and
|
||||
version to the model API. The durable policy constraint requires an exact
|
||||
40-hex source commit and version beginning with V2R2C. The model API first
|
||||
rehydrates release:candidate and then invokes the existing release service.
|
||||
|
||||
If release admission fails, check caller-capabilities, policy validity,
|
||||
revocation state, projection_state, credential_state and rehydration_state.
|
||||
Do not add release:candidate to static tokens and do not use
|
||||
online-release-builder-cn-network-mkt-20260819.
|
||||
|
||||
Creating and reading a Release Candidate is the end of this repair. Owner
|
||||
Approval, Deployment, Host Seed, Host Activation, Projector, continuation
|
||||
Task, Tag and Baseline actions are outside this scope.
|
||||
@@ -0,0 +1,295 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: AIOS Online Agent Task API
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Durable online task distribution for OpenClaw, Hermes, and other external
|
||||
agents. Agent credentials and lease tokens are bearer credentials and must
|
||||
never be committed to Git.
|
||||
servers:
|
||||
- url: /agent-tasks
|
||||
paths:
|
||||
/health:
|
||||
get:
|
||||
operationId: agentTaskHealth
|
||||
responses:
|
||||
"200":
|
||||
description: Service health
|
||||
/v1/agents/register:
|
||||
post:
|
||||
operationId: registerOnlineAgent
|
||||
security:
|
||||
- bearerAuth: []
|
||||
description: Control-plane only. Returns the agent token once.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RegisterAgentRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Registered
|
||||
/v1/tasks:
|
||||
post:
|
||||
operationId: createOnlineAgentTask
|
||||
security:
|
||||
- bearerAuth: []
|
||||
description: Control-plane only.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateTaskRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Task created
|
||||
/v1/tasks/poll:
|
||||
post:
|
||||
operationId: pollOnlineAgentTasks
|
||||
security:
|
||||
- bearerAuth: []
|
||||
description: Agent operation. Returns capability-compatible queued tasks.
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 20
|
||||
responses:
|
||||
"200":
|
||||
description: Available tasks
|
||||
/v1/tasks/{task_id}:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaskId"
|
||||
get:
|
||||
operationId: getOnlineAgentTaskDirect
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: Task status
|
||||
/v1/tasks/{task_id}/claim:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaskId"
|
||||
post:
|
||||
operationId: claimOnlineAgentTask
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
lease_seconds:
|
||||
type: integer
|
||||
minimum: 30
|
||||
maximum: 3600
|
||||
default: 300
|
||||
responses:
|
||||
"200":
|
||||
description: >
|
||||
Claim succeeded. The lease_token is returned once and must be kept
|
||||
in runtime memory/secret storage, never Git.
|
||||
"409":
|
||||
description: Already claimed or otherwise not claimable
|
||||
/v1/tasks/{task_id}/heartbeat:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaskId"
|
||||
post:
|
||||
operationId: heartbeatOnlineAgentTask
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HeartbeatRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Lease renewed
|
||||
/v1/tasks/{task_id}/result:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaskId"
|
||||
post:
|
||||
operationId: submitOnlineAgentResult
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ResultRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Result accepted into VERIFYING
|
||||
/v1/tasks/{task_id}/verify:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaskId"
|
||||
post:
|
||||
operationId: verifyOnlineAgentTaskDirect
|
||||
security:
|
||||
- bearerAuth: []
|
||||
description: Control-plane only.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/VerifyRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Verification decision applied
|
||||
/v1/tasks/{task_id}/cancel:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaskId"
|
||||
post:
|
||||
operationId: cancelOnlineAgentTaskDirect
|
||||
security:
|
||||
- bearerAuth: []
|
||||
description: Control-plane only.
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
reason:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Cancelled or already terminal
|
||||
/v1/tasks/{task_id}/events:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaskId"
|
||||
get:
|
||||
operationId: getOnlineAgentTaskEventsDirect
|
||||
security:
|
||||
- bearerAuth: []
|
||||
description: Control-plane only.
|
||||
responses:
|
||||
"200":
|
||||
description: Audit event list
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
parameters:
|
||||
TaskId:
|
||||
name: task_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$"
|
||||
schemas:
|
||||
RegisterAgentRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [agent_id, agent_type, agent_version, capabilities]
|
||||
properties:
|
||||
agent_id:
|
||||
type: string
|
||||
agent_type:
|
||||
type: string
|
||||
examples: [openclaw]
|
||||
agent_version:
|
||||
type: string
|
||||
capabilities:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
CreateTaskRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- goal
|
||||
- instructions
|
||||
- success_criteria
|
||||
- idempotency_key
|
||||
properties:
|
||||
task_id:
|
||||
type: string
|
||||
goal:
|
||||
type: string
|
||||
instructions:
|
||||
type: string
|
||||
success_criteria:
|
||||
type: array
|
||||
items: {}
|
||||
required_capabilities:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
constraints:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
deadline:
|
||||
type: string
|
||||
format: date-time
|
||||
priority:
|
||||
type: integer
|
||||
minimum: -100
|
||||
maximum: 100
|
||||
default: 0
|
||||
max_attempts:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 20
|
||||
default: 3
|
||||
idempotency_key:
|
||||
type: string
|
||||
HeartbeatRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [lease_token]
|
||||
properties:
|
||||
lease_token:
|
||||
type: string
|
||||
lease_seconds:
|
||||
type: integer
|
||||
minimum: 30
|
||||
maximum: 3600
|
||||
default: 300
|
||||
progress:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
ResultRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [lease_token, idempotency_key, result]
|
||||
properties:
|
||||
lease_token:
|
||||
type: string
|
||||
idempotency_key:
|
||||
type: string
|
||||
result:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
VerifyRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [decision]
|
||||
properties:
|
||||
decision:
|
||||
type: string
|
||||
enum: [SUCCEEDED, NEEDS_REWORK, FAILED]
|
||||
verification:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,326 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: AIOS ChatGPT Governed Write and Agent Task Extension
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Merge these paths and schemas into docs/gitops/openapi-chatgpt-actions.yaml.
|
||||
This extension deliberately exposes governed Bridge v2 operations rather
|
||||
than reopening legacy AIOS_GITOPS_WRITES_ENABLED.
|
||||
paths:
|
||||
/api/governed-write-capabilities:
|
||||
get:
|
||||
operationId: getGovernedWriteCapabilities
|
||||
responses:
|
||||
"200":
|
||||
description: Governed and legacy write capability status
|
||||
/api/control-plane-snapshot:
|
||||
get:
|
||||
operationId: getControlPlaneSnapshot
|
||||
responses:
|
||||
"200":
|
||||
description: Non-sensitive control-plane snapshot
|
||||
/createGovernedTask:
|
||||
post:
|
||||
operationId: createGovernedTask
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateGovernedTaskRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Governed task binding
|
||||
/acquireWriterLease:
|
||||
post:
|
||||
operationId: acquireWriterLease
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/WriterLeaseRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Writer lease binding
|
||||
/commitFileGoverned:
|
||||
post:
|
||||
operationId: commitFileGoverned
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CommitFileGovernedRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Durable GitOps job receipt
|
||||
/commitFilesGoverned:
|
||||
post:
|
||||
operationId: commitFilesGoverned
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CommitFilesGovernedRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Durable GitOps job receipt
|
||||
/getGitOpsJob:
|
||||
post:
|
||||
operationId: getGitOpsJob
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [job_id]
|
||||
properties:
|
||||
job_id: {type: string}
|
||||
responses:
|
||||
"200":
|
||||
description: Job receipt
|
||||
/resumeGitOpsJob:
|
||||
post:
|
||||
operationId: resumeGitOpsJob
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [job_id, expected_revision]
|
||||
properties:
|
||||
job_id: {type: string}
|
||||
expected_revision: {type: integer}
|
||||
responses:
|
||||
"200":
|
||||
description: Resumed job receipt
|
||||
/reconcileGitOpsJob:
|
||||
post:
|
||||
operationId: reconcileGitOpsJob
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [job_id, expected_revision]
|
||||
properties:
|
||||
job_id: {type: string}
|
||||
expected_revision: {type: integer}
|
||||
responses:
|
||||
"200":
|
||||
description: Reconciliation receipt
|
||||
/createOnlineAgentTask:
|
||||
post:
|
||||
operationId: createOnlineAgentTask
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateOnlineAgentTaskRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Online task created
|
||||
"201":
|
||||
description: Online task created
|
||||
/registerOnlineAgent:
|
||||
post:
|
||||
operationId: registerOnlineAgent
|
||||
x-openai-isConsequential: true
|
||||
description: >
|
||||
Control operation. The returned agent token is secret material and must
|
||||
be delivered through runtime secret storage, never Git.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [agent_id, agent_type, agent_version, capabilities]
|
||||
properties:
|
||||
agent_id: {type: string}
|
||||
agent_type: {type: string}
|
||||
agent_version: {type: string}
|
||||
capabilities:
|
||||
type: array
|
||||
items: {type: string}
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
responses:
|
||||
"201":
|
||||
description: Agent registered
|
||||
/api/online-agent-task:
|
||||
get:
|
||||
operationId: getOnlineAgentTask
|
||||
parameters:
|
||||
- name: task_id
|
||||
in: query
|
||||
required: true
|
||||
schema: {type: string}
|
||||
responses:
|
||||
"200":
|
||||
description: Task status
|
||||
/api/online-agent-task-events:
|
||||
get:
|
||||
operationId: getOnlineAgentTaskEvents
|
||||
parameters:
|
||||
- name: task_id
|
||||
in: query
|
||||
required: true
|
||||
schema: {type: string}
|
||||
responses:
|
||||
"200":
|
||||
description: Task events
|
||||
/verifyOnlineAgentTask:
|
||||
post:
|
||||
operationId: verifyOnlineAgentTask
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [task_id, decision]
|
||||
properties:
|
||||
task_id: {type: string}
|
||||
decision:
|
||||
type: string
|
||||
enum: [SUCCEEDED, NEEDS_REWORK, FAILED]
|
||||
verification:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
responses:
|
||||
"200":
|
||||
description: Verification applied
|
||||
/cancelOnlineAgentTask:
|
||||
post:
|
||||
operationId: cancelOnlineAgentTask
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [task_id]
|
||||
properties:
|
||||
task_id: {type: string}
|
||||
reason: {type: string}
|
||||
responses:
|
||||
"200":
|
||||
description: Task cancelled
|
||||
components:
|
||||
schemas:
|
||||
Oid40:
|
||||
type: string
|
||||
pattern: "^[0-9a-f]{40}$"
|
||||
CreateGovernedTaskRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
[task_id, repository, base_oid, target_branch, risk, description, idempotency_key]
|
||||
properties:
|
||||
task_id: {type: string}
|
||||
repository: {type: string, const: aios/aios}
|
||||
base_oid: {$ref: "#/components/schemas/Oid40"}
|
||||
target_branch:
|
||||
type: string
|
||||
pattern: "^agent/"
|
||||
risk:
|
||||
type: string
|
||||
enum: [low, medium, high, engineering]
|
||||
description: {type: string}
|
||||
idempotency_key: {type: string}
|
||||
expected_branch_oid: {$ref: "#/components/schemas/Oid40"}
|
||||
generation: {type: integer, minimum: 1}
|
||||
WriterLeaseRequest:
|
||||
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}
|
||||
task_id: {type: string}
|
||||
repository: {type: string, const: aios/aios}
|
||||
branch_mode:
|
||||
type: string
|
||||
enum: [CREATE_NEW, CONTINUE_EXISTING]
|
||||
base_oid: {$ref: "#/components/schemas/Oid40"}
|
||||
expected_branch_oid: {$ref: "#/components/schemas/Oid40"}
|
||||
target_branch: {type: string, pattern: "^agent/"}
|
||||
generation: {type: integer, minimum: 1}
|
||||
lease_seconds: {type: integer, minimum: 1, maximum: 86400}
|
||||
CommitFileGovernedRequest:
|
||||
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}
|
||||
repository: {type: string, const: aios/aios}
|
||||
expected_branch_oid: {$ref: "#/components/schemas/Oid40"}
|
||||
target_branch: {type: string, pattern: "^agent/"}
|
||||
generation: {type: integer, minimum: 1}
|
||||
idempotency_key: {type: string}
|
||||
path: {type: string}
|
||||
content_base64: {type: string}
|
||||
message: {type: string}
|
||||
CommitFilesGovernedRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
[task_id, repository, expected_branch_oid, target_branch, generation,
|
||||
idempotency_key, files, message]
|
||||
properties:
|
||||
task_id: {type: string}
|
||||
repository: {type: string, const: aios/aios}
|
||||
expected_branch_oid: {$ref: "#/components/schemas/Oid40"}
|
||||
target_branch: {type: string, pattern: "^agent/"}
|
||||
generation: {type: integer, minimum: 1}
|
||||
idempotency_key: {type: string}
|
||||
files:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Base64 encoded file bytes
|
||||
message: {type: string}
|
||||
CreateOnlineAgentTaskRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [goal, instructions, success_criteria, idempotency_key]
|
||||
properties:
|
||||
task_id: {type: string}
|
||||
goal: {type: string}
|
||||
instructions: {type: string}
|
||||
success_criteria:
|
||||
type: array
|
||||
items: {}
|
||||
required_capabilities:
|
||||
type: array
|
||||
items: {type: string}
|
||||
constraints:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
deadline: {type: string, format: date-time}
|
||||
priority: {type: integer, minimum: -100, maximum: 100}
|
||||
max_attempts: {type: integer, minimum: 1, maximum: 20}
|
||||
idempotency_key: {type: string}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"execution_id": "exec_d75446452723",
|
||||
"user_id": "user1",
|
||||
"visible_scope": "group1",
|
||||
"entry_agent": "openclaw",
|
||||
"path": [
|
||||
"openclaw",
|
||||
"hermes",
|
||||
"codex"
|
||||
],
|
||||
"used_hermes": true,
|
||||
"closed_by": "codex",
|
||||
"duration_ms": 0,
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 200,
|
||||
"total_tokens": 300,
|
||||
"status": "completed",
|
||||
"started_at": "2026-08-09T17:13:34.996470+00:00",
|
||||
"ended_at": "2026-08-09T17:13:34.996489+00:00"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"task_id": "task-command_watcher-CMD-test", "title": "CMD-test", "assigned_by": "command-watcher", "executor": "openclaw", "source": "command_watcher", "status": "pending", "priority": "normal", "created_at": "2026-08-09T17:13:47.285193+00:00", "updated_at": "2026-08-09T17:13:47.285198+00:00", "progress": {"percent": 5, "current_step": "pending", "completed_steps": [], "remaining_steps": [], "blocked_reason": null}, "notification": {"required": true, "sent": false, "channel": null, "sent_at": null}, "evidence": ["ops/openclaw/CMD-test.md"], "metadata": {"bucket": "active"}}
|
||||
{"task_id": "task-agent_execution-e1", "title": "Agent execution e1", "assigned_by": "hermes", "executor": "hermes", "source": "agent_execution", "status": "completed", "priority": "normal", "created_at": "2026-08-09T17:13:47.285822+00:00", "updated_at": "2026-08-09T17:13:47.285826+00:00", "progress": {"percent": 100, "current_step": "completed", "completed_steps": [], "remaining_steps": [], "blocked_reason": null}, "notification": {"required": true, "sent": false, "channel": null, "sent_at": null}, "evidence": ["e1"], "metadata": {"execution_id": "e1", "entry_agent": "hermes", "status": "completed"}}
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small CLI used by OpenClaw/Hermes operators to validate AIOS online tasks."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
def read_token(value: str | None, path: str | None) -> str:
|
||||
if value:
|
||||
return value.strip()
|
||||
if path:
|
||||
return Path(path).read_text(encoding="utf-8").strip()
|
||||
raise SystemExit("token or token-file is required")
|
||||
|
||||
|
||||
def request(base: str, method: str, path: str, token: str, body=None):
|
||||
payload = None if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||||
req = Request(
|
||||
base.rstrip("/") + path,
|
||||
data=payload,
|
||||
method=method,
|
||||
headers={
|
||||
"Authorization": "Bearer " + token,
|
||||
"Accept": "application/json",
|
||||
**({"Content-Type": "application/json"} if payload is not None else {}),
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urlopen(req, timeout=30) as response:
|
||||
return response.status, json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as exc:
|
||||
raw = exc.read()
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except Exception:
|
||||
value = {"error": "HTTP_" + str(exc.code)}
|
||||
return exc.code, value
|
||||
except (URLError, TimeoutError) as exc:
|
||||
raise SystemExit("network_error:" + type(exc).__name__) from exc
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--base", required=True)
|
||||
ap.add_argument("--token")
|
||||
ap.add_argument("--token-file")
|
||||
sub = ap.add_subparsers(dest="command", required=True)
|
||||
|
||||
p = sub.add_parser("poll")
|
||||
p.add_argument("--limit", type=int, default=20)
|
||||
|
||||
p = sub.add_parser("claim")
|
||||
p.add_argument("task_id")
|
||||
p.add_argument("--lease-seconds", type=int, default=300)
|
||||
|
||||
p = sub.add_parser("heartbeat")
|
||||
p.add_argument("task_id")
|
||||
p.add_argument("--lease-token", required=True)
|
||||
p.add_argument("--lease-seconds", type=int, default=300)
|
||||
p.add_argument("--progress-json", default="{}")
|
||||
|
||||
p = sub.add_parser("result")
|
||||
p.add_argument("task_id")
|
||||
p.add_argument("--lease-token", required=True)
|
||||
p.add_argument("--idempotency-key", required=True)
|
||||
p.add_argument("--result-json", required=True)
|
||||
|
||||
p = sub.add_parser("status")
|
||||
p.add_argument("task_id")
|
||||
|
||||
args = ap.parse_args()
|
||||
token = read_token(args.token, args.token_file)
|
||||
|
||||
if args.command == "poll":
|
||||
status, value = request(args.base, "POST", "/v1/tasks/poll", token, {"limit": args.limit})
|
||||
elif args.command == "claim":
|
||||
status, value = request(
|
||||
args.base, "POST", f"/v1/tasks/{args.task_id}/claim", token,
|
||||
{"lease_seconds": args.lease_seconds},
|
||||
)
|
||||
elif args.command == "heartbeat":
|
||||
status, value = request(
|
||||
args.base, "POST", f"/v1/tasks/{args.task_id}/heartbeat", token,
|
||||
{
|
||||
"lease_token": args.lease_token,
|
||||
"lease_seconds": args.lease_seconds,
|
||||
"progress": json.loads(args.progress_json),
|
||||
},
|
||||
)
|
||||
elif args.command == "result":
|
||||
status, value = request(
|
||||
args.base, "POST", f"/v1/tasks/{args.task_id}/result", token,
|
||||
{
|
||||
"lease_token": args.lease_token,
|
||||
"idempotency_key": args.idempotency_key,
|
||||
"result": json.loads(args.result_json),
|
||||
},
|
||||
)
|
||||
else:
|
||||
status, value = request(args.base, "GET", f"/v1/tasks/{args.task_id}", token)
|
||||
|
||||
print(json.dumps({"http_status": status, "response": value}, ensure_ascii=False, indent=2))
|
||||
return 0 if 200 <= status < 300 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end online task feasibility smoke test.
|
||||
|
||||
Runs through the public agent path plus control operations:
|
||||
create -> poll -> claim -> heartbeat -> result -> verify -> readback.
|
||||
It never performs Git, tag, Baseline, or production mutations.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
def token(path: Path) -> str:
|
||||
value = path.read_text(encoding="utf-8").strip()
|
||||
if len(value) < 32:
|
||||
raise SystemExit("credential_invalid:" + str(path))
|
||||
return value
|
||||
|
||||
|
||||
def call(base: str, method: str, route: str, credential: str, body=None):
|
||||
payload = None if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||||
headers = {
|
||||
"Authorization": "Bearer " + credential,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if payload is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = Request(base.rstrip("/") + route, data=payload, headers=headers, method=method)
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
return response.status, json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as exc:
|
||||
try:
|
||||
value = json.loads(exc.read().decode("utf-8"))
|
||||
except Exception:
|
||||
value = {"error": "HTTP_" + str(exc.code)}
|
||||
return exc.code, value
|
||||
except (URLError, TimeoutError) as exc:
|
||||
raise SystemExit("network_error:" + type(exc).__name__) from exc
|
||||
|
||||
|
||||
def require(step: str, status: int, value, expected_status: str | None = None):
|
||||
if not 200 <= status < 300:
|
||||
raise SystemExit(f"{step}_failed:http={status}:body={json.dumps(value, ensure_ascii=False)}")
|
||||
if expected_status and value.get("status") != expected_status:
|
||||
raise SystemExit(
|
||||
f"{step}_state_failed:expected={expected_status}:"
|
||||
f"observed={value.get('status')}"
|
||||
)
|
||||
print(json.dumps({"step": step, "http": status, "response": value}, ensure_ascii=False))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--control-base", required=True,
|
||||
help="Loopback/model-control base that exposes /v1 control routes")
|
||||
parser.add_argument("--agent-base", required=True,
|
||||
help="Public agent base, e.g. https://host/agent-tasks")
|
||||
parser.add_argument("--control-token-file", required=True, type=Path)
|
||||
parser.add_argument("--agent-token-file", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
control = token(args.control_token_file)
|
||||
agent = token(args.agent_token_file)
|
||||
task_id = "TASK-ONLINE-SMOKE-" + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + secrets.token_hex(3)
|
||||
create_key = "smoke-create:" + task_id
|
||||
result_key = "smoke-result:" + task_id
|
||||
|
||||
s, v = call(
|
||||
args.control_base, "POST", "/v1/tasks", control,
|
||||
{
|
||||
"task_id": task_id,
|
||||
"goal": "Validate AIOS online task distribution end to end",
|
||||
"instructions": (
|
||||
"Do not mutate Git or production. Return a structured result "
|
||||
"containing marker ONLINE_TASK_FLOW_OK."
|
||||
),
|
||||
"success_criteria": [
|
||||
{"field": "marker", "equals": "ONLINE_TASK_FLOW_OK"},
|
||||
{"field": "agent_observed_task_id", "equals": task_id},
|
||||
],
|
||||
"required_capabilities": ["online-task-smoke"],
|
||||
"constraints": {
|
||||
"git_mutation": False,
|
||||
"production_mutation": False,
|
||||
"network_scope": "AIOS task endpoint only",
|
||||
},
|
||||
"priority": 100,
|
||||
"max_attempts": 3,
|
||||
"idempotency_key": create_key,
|
||||
},
|
||||
)
|
||||
require("create", s, v, "QUEUED")
|
||||
|
||||
s, v = call(args.agent_base, "POST", "/v1/tasks/poll", agent, {"limit": 100})
|
||||
require("poll", s, v)
|
||||
if task_id not in [item.get("task_id") for item in v.get("tasks", [])]:
|
||||
raise SystemExit("poll_failed:created_task_not_visible")
|
||||
|
||||
s, v = call(
|
||||
args.agent_base, "POST", f"/v1/tasks/{task_id}/claim", agent,
|
||||
{"lease_seconds": 120},
|
||||
)
|
||||
require("claim", s, v, "CLAIMED")
|
||||
lease = v.get("lease_token")
|
||||
if not lease:
|
||||
raise SystemExit("claim_failed:lease_token_missing")
|
||||
|
||||
s, v = call(
|
||||
args.agent_base, "POST", f"/v1/tasks/{task_id}/heartbeat", agent,
|
||||
{
|
||||
"lease_token": lease,
|
||||
"lease_seconds": 120,
|
||||
"progress": {"stage": "executing", "percent": 50},
|
||||
},
|
||||
)
|
||||
require("heartbeat", s, v, "RUNNING")
|
||||
|
||||
s, v = call(
|
||||
args.agent_base, "POST", f"/v1/tasks/{task_id}/result", agent,
|
||||
{
|
||||
"lease_token": lease,
|
||||
"idempotency_key": result_key,
|
||||
"result": {
|
||||
"status": "completed",
|
||||
"marker": "ONLINE_TASK_FLOW_OK",
|
||||
"agent_observed_task_id": task_id,
|
||||
"summary": "Agent received, claimed, heartbeated and returned the online task.",
|
||||
"artifacts": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
require("result", s, v, "VERIFYING")
|
||||
|
||||
# Re-submit the identical result to prove idempotency.
|
||||
s2, v2 = call(
|
||||
args.agent_base, "POST", f"/v1/tasks/{task_id}/result", agent,
|
||||
{
|
||||
"lease_token": lease,
|
||||
"idempotency_key": result_key,
|
||||
"result": {
|
||||
"status": "completed",
|
||||
"marker": "ONLINE_TASK_FLOW_OK",
|
||||
"agent_observed_task_id": task_id,
|
||||
"summary": "Agent received, claimed, heartbeated and returned the online task.",
|
||||
"artifacts": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
require("result_idempotent_replay", s2, v2, "VERIFYING")
|
||||
if v2.get("idempotent") is not True:
|
||||
raise SystemExit("result_idempotency_failed")
|
||||
|
||||
s, v = call(
|
||||
args.control_base, "POST", f"/v1/tasks/{task_id}/verify", control,
|
||||
{
|
||||
"decision": "SUCCEEDED",
|
||||
"verification": {
|
||||
"marker_match": "PASS",
|
||||
"task_id_match": "PASS",
|
||||
"online_round_trip": "PASS",
|
||||
},
|
||||
},
|
||||
)
|
||||
require("verify", s, v, "SUCCEEDED")
|
||||
|
||||
s, v = call(args.control_base, "GET", f"/v1/tasks/{task_id}", control)
|
||||
require("final_control_readback", s, v, "SUCCEEDED")
|
||||
|
||||
# The executing Agent must also be able to observe the final terminal state.
|
||||
s, v = call(args.agent_base, "GET", f"/v1/tasks/{task_id}", agent)
|
||||
require("final_agent_readback", s, v, "SUCCEEDED")
|
||||
|
||||
print(json.dumps({
|
||||
"status": "ONLINE_TASK_FLOW_VERIFIED",
|
||||
"task_id": task_id,
|
||||
"final_state": v.get("status"),
|
||||
"force_push_used": False,
|
||||
"tag_rewritten": False,
|
||||
"direct_main_push": False,
|
||||
"production_changed": False,
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build deterministic immutable staging artifact for AIOS Agent Task API."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
|
||||
REQUIRED = (
|
||||
"active-runtime/app/aios_agent_task_protocol.py",
|
||||
"active-runtime/app/aios_agent_task_api.py",
|
||||
"docs/gitops/openapi-agent-task-v1.yaml",
|
||||
)
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--source-root", required=True, type=Path)
|
||||
parser.add_argument("--output-root", required=True, type=Path)
|
||||
parser.add_argument("--source-oid", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
oid = args.source_oid.strip().lower()
|
||||
if len(oid) != 40 or any(ch not in "0123456789abcdef" for ch in oid):
|
||||
raise SystemExit("source oid must be exact 40 hex")
|
||||
source = args.source_root.resolve(strict=True)
|
||||
output = args.output_root.resolve()
|
||||
for relative in REQUIRED:
|
||||
if not (source / relative).is_file():
|
||||
raise SystemExit("required source missing: " + relative)
|
||||
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
archive = output / f"aios-agent-task-api-{oid}.tar.gz"
|
||||
manifest = {
|
||||
"schema": "aios.agent-task-artifact.v1",
|
||||
"source_oid": oid,
|
||||
"repository_identity": "aios/aios",
|
||||
"deployment_state": "STAGING",
|
||||
"runtime_mode": "IMMUTABLE_STAGING",
|
||||
"entrypoint": "app/aios_agent_task_api.py",
|
||||
"listen": "127.0.0.1:18940",
|
||||
"plaintext_credentials_in_artifact": False,
|
||||
"included_paths": list(REQUIRED),
|
||||
}
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="agent-task-build-", dir=str(output)) as temp:
|
||||
stage = Path(temp) / "runtime"
|
||||
for relative in REQUIRED:
|
||||
src = source / relative
|
||||
if relative.startswith("active-runtime/"):
|
||||
dest = stage / Path(relative).relative_to("active-runtime")
|
||||
else:
|
||||
dest = stage / relative
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(src.read_bytes())
|
||||
(stage / "manifest.json").write_text(
|
||||
json.dumps(manifest, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with tarfile.open(archive, "w:gz", format=tarfile.PAX_FORMAT) as tar:
|
||||
for path in sorted(stage.rglob("*")):
|
||||
arcname = str(path.relative_to(stage.parent)).replace("\\", "/")
|
||||
info = tar.gettarinfo(str(path), arcname=arcname)
|
||||
info.mtime = 0
|
||||
info.uid = info.gid = 0
|
||||
info.uname = info.gname = "root"
|
||||
if path.is_file():
|
||||
with path.open("rb") as fh:
|
||||
tar.addfile(info, fh)
|
||||
else:
|
||||
tar.addfile(info)
|
||||
|
||||
receipt = {
|
||||
**manifest,
|
||||
"artifact_path": str(archive),
|
||||
"artifact_sha256": sha256(archive),
|
||||
}
|
||||
receipt_path = archive.with_suffix(archive.suffix + ".receipt.json")
|
||||
receipt_path.write_text(
|
||||
json.dumps(receipt, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(json.dumps(receipt, sort_keys=True, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,48 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_model_api_exposes_governed_writer_without_legacy_global_enable():
|
||||
source = (ROOT / "active-runtime/app/aios_model_api_staging.py").read_text(encoding="utf-8")
|
||||
assert "'/createGovernedTask': ('/v2/governed-task'" in source
|
||||
assert "'/commitFilesGoverned': ('/v2/commit-files'" in source
|
||||
assert "path == '/api/governed-write-capabilities'" in source
|
||||
assert "AIOS_GITOPS_WRITES_ENABLED" not in source or "legacy_global_writes_enabled" in source
|
||||
assert "'writes_enabled': False" in source
|
||||
assert "bridge_v2_json" in source
|
||||
|
||||
|
||||
def test_model_api_routes_online_task_control_through_loopback_credential():
|
||||
source = (ROOT / "active-runtime/app/aios_model_api_staging.py").read_text(encoding="utf-8")
|
||||
assert "AIOS_AGENT_TASK_API_URL" in source
|
||||
assert "AIOS_AGENT_TASK_CONTROL_TOKEN_FILE" in source
|
||||
assert "/createOnlineAgentTask" in source
|
||||
assert "/verifyOnlineAgentTask" in source
|
||||
assert "/cancelOnlineAgentTask" in source
|
||||
assert "agent-task:control" in source
|
||||
|
||||
|
||||
def test_online_task_service_keeps_control_endpoints_off_public_nginx():
|
||||
nginx = (ROOT / "deploy/nginx/aios-agent-task-api.conf.example").read_text(encoding="utf-8")
|
||||
assert "/agent-tasks/v1/tasks/poll" in nginx
|
||||
assert "(claim|heartbeat|result)" in nginx
|
||||
assert "/v1/agents/register" not in nginx.replace(
|
||||
"# Control endpoints (/v1/agents/register, POST /v1/tasks, verify/cancel/events)", ""
|
||||
)
|
||||
|
||||
|
||||
def test_online_task_systemd_uses_runtime_credential_file():
|
||||
unit = (ROOT / "deploy/systemd/aios-agent-task-api.service").read_text(encoding="utf-8")
|
||||
assert "LoadCredential=control_token:" in unit
|
||||
assert "/run/credentials/aios-agent-task-api.service/control_token" in unit
|
||||
assert "ReadWritePaths=/var/lib/aios-agent-task" in unit
|
||||
assert "127.0.0.1" in unit
|
||||
|
||||
|
||||
def test_control_plane_snapshot_is_local_aggregate_not_missing_bridge_route():
|
||||
source = (ROOT / "active-runtime/app/aios_model_api_staging.py").read_text(encoding="utf-8")
|
||||
assert "def control_plane_snapshot(self):" in source
|
||||
assert "'snapshot_source': 'model-api-local-aggregate'" in source
|
||||
assert "return self.control_plane_snapshot()" in source
|
||||
assert "bridge_v2_json('/v2/control-plane-snapshot'" not in source
|
||||
@@ -0,0 +1,317 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_agent_task_protocol import TaskProtocolError, TaskStore
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self) -> None:
|
||||
self.value = datetime(2026, 8, 9, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
def __call__(self) -> datetime:
|
||||
return self.value
|
||||
|
||||
def advance(self, seconds: int) -> None:
|
||||
self.value += timedelta(seconds=seconds)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path: Path):
|
||||
clock = Clock()
|
||||
store = TaskStore(tmp_path / "tasks.sqlite3", now=clock, token_pepper="test-pepper")
|
||||
a1 = store.register_agent(
|
||||
agent_id="openclaw-1",
|
||||
agent_type="openclaw",
|
||||
agent_version="test",
|
||||
capabilities=["shell", "git", "python"],
|
||||
)
|
||||
a2 = store.register_agent(
|
||||
agent_id="hermes-1",
|
||||
agent_type="hermes",
|
||||
agent_version="test",
|
||||
capabilities=["shell", "git", "python"],
|
||||
)
|
||||
return store, clock, a1, a2
|
||||
|
||||
|
||||
def create_task(store: TaskStore, task_id: str = "TASK-ONLINE-001"):
|
||||
return store.create_task(
|
||||
task_id=task_id,
|
||||
goal="Create a deterministic validation artifact",
|
||||
instructions="Write validation.txt containing ONLINE_TASK_OK.",
|
||||
success_criteria=[
|
||||
{"type": "file_exists", "path": "validation.txt"},
|
||||
{"type": "contains", "path": "validation.txt", "value": "ONLINE_TASK_OK"},
|
||||
],
|
||||
required_capabilities=["shell"],
|
||||
constraints={"network": "optional"},
|
||||
metadata={"suite": "online-task"},
|
||||
priority=10,
|
||||
max_attempts=3,
|
||||
idempotency_key="create:" + task_id,
|
||||
)
|
||||
|
||||
|
||||
def test_full_flow_requires_server_verification(env):
|
||||
store, _clock, a1, _a2 = env
|
||||
create_task(store)
|
||||
|
||||
polled = store.poll_tasks(agent_id=a1["agent_id"])
|
||||
assert [item["task_id"] for item in polled] == ["TASK-ONLINE-001"]
|
||||
|
||||
claim = store.claim_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_seconds=300,
|
||||
)
|
||||
assert claim["status"] == "CLAIMED"
|
||||
assert claim["lease_token"].startswith("lease_")
|
||||
|
||||
running = store.heartbeat(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token=claim["lease_token"],
|
||||
progress={"percent": 50},
|
||||
lease_seconds=300,
|
||||
)
|
||||
assert running["status"] == "RUNNING"
|
||||
|
||||
submitted = store.submit_result(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token=claim["lease_token"],
|
||||
idempotency_key="result:1",
|
||||
result={
|
||||
"status": "completed",
|
||||
"summary": "Created validation.txt",
|
||||
"artifacts": [{"path": "validation.txt", "sha256": "0" * 64}],
|
||||
},
|
||||
)
|
||||
assert submitted["status"] == "VERIFYING"
|
||||
assert submitted["status"] != "SUCCEEDED"
|
||||
|
||||
verified = store.verify_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
decision="SUCCEEDED",
|
||||
verification={"checks": {"content": "PASS"}},
|
||||
)
|
||||
assert verified["status"] == "SUCCEEDED"
|
||||
|
||||
|
||||
def test_atomic_claim_allows_only_one_agent(env):
|
||||
store, _clock, a1, a2 = env
|
||||
create_task(store)
|
||||
outcomes: list[tuple[str, str]] = []
|
||||
barrier = threading.Barrier(2)
|
||||
|
||||
def runner(agent_id: str) -> None:
|
||||
barrier.wait()
|
||||
try:
|
||||
store.claim_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=agent_id,
|
||||
lease_seconds=300,
|
||||
)
|
||||
outcomes.append((agent_id, "CLAIMED"))
|
||||
except TaskProtocolError as exc:
|
||||
outcomes.append((agent_id, exc.code))
|
||||
|
||||
t1 = threading.Thread(target=runner, args=(a1["agent_id"],))
|
||||
t2 = threading.Thread(target=runner, args=(a2["agent_id"],))
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join()
|
||||
t2.join()
|
||||
|
||||
assert sum(result == "CLAIMED" for _, result in outcomes) == 1
|
||||
assert sum(result in {"TASK_NOT_CLAIMABLE", "TASK_CLAIM_CONFLICT"} for _, result in outcomes) == 1
|
||||
|
||||
|
||||
def test_expired_lease_is_requeued_and_reclaimable(env):
|
||||
store, clock, a1, a2 = env
|
||||
create_task(store)
|
||||
first = store.claim_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_seconds=30,
|
||||
)
|
||||
assert first["attempt"] == 1
|
||||
|
||||
clock.advance(31)
|
||||
available = store.poll_tasks(agent_id=a2["agent_id"])
|
||||
assert available[0]["status"] == "QUEUED"
|
||||
|
||||
second = store.claim_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a2["agent_id"],
|
||||
lease_seconds=30,
|
||||
)
|
||||
assert second["attempt"] == 2
|
||||
assert second["claimed_by"] == a2["agent_id"]
|
||||
|
||||
|
||||
def test_wrong_lease_token_is_rejected(env):
|
||||
store, _clock, a1, _a2 = env
|
||||
create_task(store)
|
||||
store.claim_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_seconds=300,
|
||||
)
|
||||
with pytest.raises(TaskProtocolError, match="TASK_LEASE_TOKEN_INVALID"):
|
||||
store.heartbeat(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token="lease_wrong",
|
||||
progress={},
|
||||
)
|
||||
|
||||
|
||||
def test_result_submission_is_idempotent(env):
|
||||
store, _clock, a1, _a2 = env
|
||||
create_task(store)
|
||||
claim = store.claim_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_seconds=300,
|
||||
)
|
||||
payload = {"status": "completed", "summary": "ok"}
|
||||
first = store.submit_result(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token=claim["lease_token"],
|
||||
idempotency_key="result:same",
|
||||
result=payload,
|
||||
)
|
||||
second = store.submit_result(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token=claim["lease_token"],
|
||||
idempotency_key="result:same",
|
||||
result=payload,
|
||||
)
|
||||
assert first["status"] == "VERIFYING"
|
||||
assert second["status"] == "VERIFYING"
|
||||
assert second["idempotent"] is True
|
||||
|
||||
with pytest.raises(TaskProtocolError, match="IDEMPOTENCY_CONFLICT"):
|
||||
store.submit_result(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token=claim["lease_token"],
|
||||
idempotency_key="result:same",
|
||||
result={"status": "completed", "summary": "different"},
|
||||
)
|
||||
|
||||
|
||||
def test_needs_rework_returns_to_queue(env):
|
||||
store, _clock, a1, _a2 = env
|
||||
create_task(store)
|
||||
claim = store.claim_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_seconds=300,
|
||||
)
|
||||
store.submit_result(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token=claim["lease_token"],
|
||||
idempotency_key="result:1",
|
||||
result={"status": "completed"},
|
||||
)
|
||||
result = store.verify_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
decision="NEEDS_REWORK",
|
||||
verification={"reason": "success criteria not met"},
|
||||
)
|
||||
assert result["status"] == "QUEUED"
|
||||
assert result["claimed_by"] is None
|
||||
|
||||
|
||||
def test_capability_filter(env):
|
||||
store, _clock, a1, _a2 = env
|
||||
store.create_task(
|
||||
task_id="TASK-GPU-001",
|
||||
goal="GPU task",
|
||||
instructions="Run GPU workload",
|
||||
success_criteria=[],
|
||||
required_capabilities=["gpu"],
|
||||
idempotency_key="create:gpu",
|
||||
)
|
||||
assert store.poll_tasks(agent_id=a1["agent_id"]) == []
|
||||
|
||||
|
||||
def test_create_task_idempotency_conflict(env):
|
||||
store, _clock, _a1, _a2 = env
|
||||
first = create_task(store)
|
||||
replay = create_task(store)
|
||||
assert first["task_id"] == replay["task_id"]
|
||||
assert replay["idempotent"] is True
|
||||
|
||||
with pytest.raises(TaskProtocolError, match="IDEMPOTENCY_CONFLICT"):
|
||||
store.create_task(
|
||||
task_id="TASK-ONLINE-002",
|
||||
goal="different",
|
||||
instructions="different",
|
||||
success_criteria=[],
|
||||
idempotency_key="create:TASK-ONLINE-001",
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_success_retains_executing_agent_for_final_readback(env):
|
||||
store, _clock, a1, _a2 = env
|
||||
create_task(store, "TASK-TERMINAL-READBACK")
|
||||
claim = store.claim_task(
|
||||
task_id="TASK-TERMINAL-READBACK",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_seconds=120,
|
||||
)
|
||||
store.submit_result(
|
||||
task_id="TASK-TERMINAL-READBACK",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token=claim["lease_token"],
|
||||
idempotency_key="terminal-result",
|
||||
result={"status": "completed"},
|
||||
)
|
||||
final = store.verify_task(
|
||||
task_id="TASK-TERMINAL-READBACK",
|
||||
decision="SUCCEEDED",
|
||||
verification={"criteria": "PASS"},
|
||||
)
|
||||
assert final["status"] == "SUCCEEDED"
|
||||
assert final["claimed_by"] == a1["agent_id"]
|
||||
|
||||
|
||||
def test_needs_rework_releases_agent_ownership_for_reclaim(env):
|
||||
store, _clock, a1, a2 = env
|
||||
create_task(store, "TASK-REWORK-OWNERSHIP")
|
||||
claim = store.claim_task(
|
||||
task_id="TASK-REWORK-OWNERSHIP",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_seconds=120,
|
||||
)
|
||||
store.submit_result(
|
||||
task_id="TASK-REWORK-OWNERSHIP",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token=claim["lease_token"],
|
||||
idempotency_key="rework-owner-result",
|
||||
result={"status": "completed"},
|
||||
)
|
||||
requeued = store.verify_task(
|
||||
task_id="TASK-REWORK-OWNERSHIP",
|
||||
decision="NEEDS_REWORK",
|
||||
verification={"criteria": "FAIL"},
|
||||
)
|
||||
assert requeued["status"] == "QUEUED"
|
||||
assert requeued["claimed_by"] is None
|
||||
reclaimed = store.claim_task(
|
||||
task_id="TASK-REWORK-OWNERSHIP",
|
||||
agent_id=a2["agent_id"],
|
||||
lease_seconds=120,
|
||||
)
|
||||
assert reclaimed["claimed_by"] == a2["agent_id"]
|
||||
@@ -0,0 +1,215 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP = ROOT / "active-runtime" / "app"
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"aios_durable_owner_authorization",
|
||||
APP / "aios_durable_owner_authorization.py",
|
||||
)
|
||||
BROKER_MODULE = importlib.util.module_from_spec(SPEC)
|
||||
import sys
|
||||
sys.modules[SPEC.name] = BROKER_MODULE
|
||||
assert SPEC.loader is not None
|
||||
SPEC.loader.exec_module(BROKER_MODULE)
|
||||
|
||||
AuthorizationError = BROKER_MODULE.AuthorizationError
|
||||
Broker = BROKER_MODULE.Broker
|
||||
Constraints = BROKER_MODULE.Constraints
|
||||
DurablePolicy = BROKER_MODULE.DurablePolicy
|
||||
ReceiptStore = BROKER_MODULE.ReceiptStore
|
||||
Upstream = BROKER_MODULE.Upstream
|
||||
|
||||
CALLER = "chatgpt-governed-implementer-singlehost"
|
||||
SCOPE = "release:candidate"
|
||||
GOVERNED_TASK_SCOPE = "governed-task:project"
|
||||
SOURCE = "257c7a8df433f75b364bcaf69cbbc32e8c2e5d38"
|
||||
|
||||
|
||||
def policy(*, expires_at=None, capabilities=None):
|
||||
return DurablePolicy(
|
||||
grant_id="grant-caller-projection-v1",
|
||||
owner_identity="owner-durable-policy",
|
||||
account=CALLER,
|
||||
capabilities=frozenset(
|
||||
capabilities or {GOVERNED_TASK_SCOPE, SCOPE}
|
||||
),
|
||||
issued_at=datetime.now(timezone.utc) - timedelta(minutes=1),
|
||||
not_before=datetime.now(timezone.utc) - timedelta(minutes=1),
|
||||
persistent_until_revoked=True,
|
||||
constraints=Constraints(
|
||||
repository="aios/aios",
|
||||
account=CALLER,
|
||||
allowed_target_prefix="agent/",
|
||||
release_version_prefix="V2R2C",
|
||||
max_task_risk="engineering",
|
||||
),
|
||||
policy_sha256="a" * 64,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
|
||||
def make_broker(root):
|
||||
return Broker(
|
||||
policy_path=root / "policy.json",
|
||||
revocation_path=root / "revocations.json",
|
||||
store=ReceiptStore(root / "receipts.sqlite3"),
|
||||
upstream=Upstream("http://127.0.0.1:1", "not-used"),
|
||||
expected_account=CALLER,
|
||||
expected_peer_user="test-peer",
|
||||
allowed_peer_users=frozenset({"test-peer"}),
|
||||
)
|
||||
|
||||
|
||||
class CallerProjectionTests(unittest.TestCase):
|
||||
def project(self, broker, request=None):
|
||||
return broker.project_caller(
|
||||
CALLER,
|
||||
SCOPE,
|
||||
request or {"source_commit": SOURCE, "version": "V2R2C2"},
|
||||
idempotency_key="caller-projection-test-v1",
|
||||
)
|
||||
|
||||
def test_positive_projection_ack_and_effective_scope(self):
|
||||
broker = make_broker(Path(tempfile.mkdtemp()))
|
||||
with patch.object(broker, "policy", return_value=policy()):
|
||||
value = self.project(broker)
|
||||
credential = value.pop("_credential")
|
||||
self.assertGreaterEqual(len(credential), 32)
|
||||
self.assertEqual("ACKED", value["projection_state"])
|
||||
self.assertEqual("REHYDRATED", value["credential_state"])
|
||||
self.assertEqual(
|
||||
[GOVERNED_TASK_SCOPE, SCOPE],
|
||||
value["effective_scopes"],
|
||||
)
|
||||
self.assertFalse(value["secret_exposed"])
|
||||
|
||||
def test_governed_task_projection_uses_durable_capability(self):
|
||||
broker = make_broker(Path(tempfile.mkdtemp()))
|
||||
with patch.object(broker, "policy", return_value=policy()):
|
||||
value = broker.project_caller(
|
||||
CALLER,
|
||||
GOVERNED_TASK_SCOPE,
|
||||
{},
|
||||
idempotency_key="caller-projection-governed-task-v1",
|
||||
)
|
||||
value.pop("_credential")
|
||||
self.assertEqual(GOVERNED_TASK_SCOPE, value["scope"])
|
||||
self.assertEqual(
|
||||
[GOVERNED_TASK_SCOPE, SCOPE],
|
||||
value["effective_scopes"],
|
||||
)
|
||||
self.assertFalse(value["secret_exposed"])
|
||||
|
||||
def test_secret_material_is_not_durable(self):
|
||||
root = Path(tempfile.mkdtemp())
|
||||
broker = make_broker(root)
|
||||
with patch.object(broker, "policy", return_value=policy()):
|
||||
value = self.project(broker)
|
||||
credential = value.pop("_credential")
|
||||
durable = (root / "receipts.sqlite3").read_bytes()
|
||||
self.assertNotIn(credential.encode(), durable)
|
||||
self.assertNotIn("credential_material", durable.decode("utf-8", "ignore"))
|
||||
self.assertFalse(value["secret_exposed"])
|
||||
|
||||
def test_caller_mismatch_is_denied(self):
|
||||
broker = make_broker(Path(tempfile.mkdtemp()))
|
||||
with self.assertRaises(AuthorizationError) as caught:
|
||||
broker.project_caller(
|
||||
"other-caller",
|
||||
SCOPE,
|
||||
{},
|
||||
idempotency_key="caller-projection-test-v1",
|
||||
)
|
||||
self.assertEqual("CALLER_MISMATCH", caught.exception.code)
|
||||
|
||||
def test_scope_mismatch_is_denied(self):
|
||||
broker = make_broker(Path(tempfile.mkdtemp()))
|
||||
with self.assertRaises(AuthorizationError) as caught:
|
||||
broker.project_caller(
|
||||
CALLER,
|
||||
"release:deploy",
|
||||
{},
|
||||
idempotency_key="caller-projection-test-v1",
|
||||
)
|
||||
self.assertEqual("SCOPE_MISMATCH", caught.exception.code)
|
||||
|
||||
def test_policy_missing_revoked_and_expired_fail_closed(self):
|
||||
broker = make_broker(Path(tempfile.mkdtemp()))
|
||||
for code in ("POLICY_FILE_INVALID", "OWNER_GRANT_REVOKED", "POLICY_EXPIRED"):
|
||||
with self.subTest(code=code):
|
||||
with patch.object(
|
||||
broker,
|
||||
"policy",
|
||||
side_effect=AuthorizationError(code, 403),
|
||||
):
|
||||
with self.assertRaises(AuthorizationError) as caught:
|
||||
self.project(broker)
|
||||
self.assertEqual(code, caught.exception.code)
|
||||
|
||||
def test_invalid_projection_state_fails_closed(self):
|
||||
with self.assertRaises(AuthorizationError) as caught:
|
||||
Broker.validate_caller_projection(
|
||||
{
|
||||
"caller": CALLER,
|
||||
"scope": SCOPE,
|
||||
"projection_state": "CORRUPT",
|
||||
"credential_state": "REHYDRATED",
|
||||
"rehydration_state": "RECOVERED",
|
||||
"effective_scopes": [SCOPE],
|
||||
"secret_exposed": False,
|
||||
}
|
||||
)
|
||||
self.assertEqual("PROJECTION_STATE_INVALID", caught.exception.code)
|
||||
|
||||
def test_restart_rehydrates_from_durable_metadata(self):
|
||||
root = Path(tempfile.mkdtemp())
|
||||
first = make_broker(root)
|
||||
with patch.object(first, "policy", return_value=policy()):
|
||||
initial = self.project(first)
|
||||
first_credential = initial.pop("_credential")
|
||||
second = make_broker(root)
|
||||
with patch.object(second, "policy", return_value=policy()):
|
||||
recovered = self.project(second)
|
||||
second_credential = recovered.pop("_credential")
|
||||
self.assertNotEqual(first_credential, second_credential)
|
||||
self.assertEqual("RECOVERED", recovered["rehydration_state"])
|
||||
self.assertEqual("ACKED", recovered["projection_state"])
|
||||
|
||||
def test_expired_policy_loader_rejects(self):
|
||||
root = Path(tempfile.mkdtemp())
|
||||
policy_path = root / "policy.json"
|
||||
payload = {
|
||||
"schema": "aios.owner-durable-authorization.v1",
|
||||
"grant_id": "grant-caller-projection-v1",
|
||||
"owner_identity": "owner-durable-policy",
|
||||
"account": CALLER,
|
||||
"capabilities": [SCOPE],
|
||||
"issued_at": "2026-08-22T00:00:00Z",
|
||||
"not_before": "2026-08-22T00:00:00Z",
|
||||
"expires_at": "2026-08-22T01:00:00Z",
|
||||
"persistent_until_revoked": True,
|
||||
"constraints": {
|
||||
"repository": "aios/aios",
|
||||
"allowed_target_prefix": "agent/",
|
||||
"release_version_prefix": "V2R2C",
|
||||
"max_task_risk": "engineering",
|
||||
},
|
||||
}
|
||||
policy_path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
with patch.object(BROKER_MODULE, "validate_root_file", lambda path, **kwargs: path):
|
||||
with self.assertRaises(AuthorizationError) as caught:
|
||||
DurablePolicy.load(policy_path)
|
||||
self.assertEqual("POLICY_EXPIRED", caught.exception.code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,193 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP = ROOT / "active-runtime" / "app"
|
||||
if str(APP) not in sys.path:
|
||||
sys.path.insert(0, str(APP))
|
||||
|
||||
try:
|
||||
import orchestrator.action_monitor
|
||||
except ModuleNotFoundError:
|
||||
_orchestrator = types.ModuleType("orchestrator")
|
||||
_action_monitor = types.ModuleType("orchestrator.action_monitor")
|
||||
_action_monitor.ActionMonitor = type("ActionMonitor", (), {})
|
||||
_orchestrator.__path__ = []
|
||||
sys.modules["orchestrator"] = _orchestrator
|
||||
sys.modules["orchestrator.action_monitor"] = _action_monitor
|
||||
|
||||
import aios_durable_owner_authorization as broker_api
|
||||
import aios_model_api_staging as model_api
|
||||
|
||||
|
||||
CALLER = "chatgpt-governed-implementer-singlehost"
|
||||
SCOPE = "release:candidate"
|
||||
GOVERNED_TASK_SCOPE = "governed-task:project"
|
||||
|
||||
|
||||
def _invoke_capabilities(
|
||||
headers,
|
||||
*,
|
||||
auth_result=None,
|
||||
auth_side_effect=None,
|
||||
projection_client=None,
|
||||
):
|
||||
handler = object.__new__(model_api.H)
|
||||
handler.headers = headers
|
||||
responses = []
|
||||
handler.sendj = lambda value, code=200: responses.append((value, code))
|
||||
if auth_side_effect is not None:
|
||||
auth_patch = patch.object(
|
||||
model_api, "authenticate", side_effect=auth_side_effect
|
||||
)
|
||||
else:
|
||||
auth_patch = patch.object(
|
||||
model_api, "authenticate", return_value=auth_result
|
||||
)
|
||||
client = (
|
||||
projection_client
|
||||
if projection_client is not None
|
||||
else model_api.CALLER_PROJECTION_CLIENT
|
||||
)
|
||||
with auth_patch, patch.object(model_api, "CALLER_PROJECTION_CLIENT", client):
|
||||
model_api.H.caller_capabilities(handler)
|
||||
assert len(responses) == 1
|
||||
return responses[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"headers",
|
||||
[
|
||||
{},
|
||||
{"Authorization": "Bearer invalid"},
|
||||
{"Authorization": "Bearer unknown"},
|
||||
{"X-AIOS-Token": "rec-none"},
|
||||
],
|
||||
)
|
||||
def test_anonymous_invalid_unknown_and_rec_none_are_structured_401(headers):
|
||||
payload, status = _invoke_capabilities(headers, auth_result=None)
|
||||
assert status == 401
|
||||
assert payload == {"error": "unauthorized", "hint": "Bearer token required"}
|
||||
assert "Traceback" not in json.dumps(payload)
|
||||
|
||||
|
||||
def test_authenticate_exception_is_fail_closed_without_socket_abort():
|
||||
payload, status = _invoke_capabilities(
|
||||
{"Authorization": "Bearer malformed"},
|
||||
auth_side_effect=RuntimeError("malformed"),
|
||||
)
|
||||
assert status == 401
|
||||
assert payload["error"] == "unauthorized"
|
||||
assert "Traceback" not in json.dumps(payload)
|
||||
|
||||
|
||||
def test_authorized_capability_introspection_remains_secret_free():
|
||||
row = {
|
||||
"account": CALLER,
|
||||
"role": "user",
|
||||
"scopes": ["agent-task:control", "branch:create", "file:write"],
|
||||
}
|
||||
result = SimpleNamespace(
|
||||
public={
|
||||
"projection_state": "ACKED",
|
||||
"credential_state": "ACTIVE",
|
||||
"rehydration_state": "REHYDRATED",
|
||||
"effective_scopes": [GOVERNED_TASK_SCOPE, SCOPE],
|
||||
},
|
||||
credential_loaded=True,
|
||||
)
|
||||
|
||||
class _ProjectionClient:
|
||||
def rehydrate(self, caller, scope, request):
|
||||
assert caller == CALLER
|
||||
assert scope == GOVERNED_TASK_SCOPE
|
||||
assert request == {}
|
||||
return result
|
||||
|
||||
payload, status = _invoke_capabilities(
|
||||
{"Authorization": "Bearer valid"},
|
||||
auth_result=row,
|
||||
projection_client=_ProjectionClient(),
|
||||
)
|
||||
assert status == 200
|
||||
assert payload["caller_identity"] == CALLER
|
||||
assert payload["admitted"] is True
|
||||
assert SCOPE in payload["effective_scopes"]
|
||||
assert GOVERNED_TASK_SCOPE in payload["effective_scopes"]
|
||||
assert payload["projection_state"] == "ACKED"
|
||||
assert payload["credential_state"] == "ACTIVE"
|
||||
assert payload["rehydration_state"] == "REHYDRATED"
|
||||
assert payload["secret_exposed"] is False
|
||||
assert "_credential" not in json.dumps(payload)
|
||||
|
||||
|
||||
class _FakeBroker:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def project_caller(self, caller, scope, request, *, idempotency_key):
|
||||
self.calls.append((caller, scope, request, idempotency_key))
|
||||
return {
|
||||
"caller": caller,
|
||||
"scope": scope,
|
||||
"projection_state": "ACKED",
|
||||
"credential_state": "ACTIVE",
|
||||
"rehydration_state": "REHYDRATED",
|
||||
"effective_scopes": [SCOPE],
|
||||
"_credential": "x" * 40,
|
||||
}
|
||||
|
||||
|
||||
def _dispatch_broker(path="/v1/project-caller", *, peer=True, account=True):
|
||||
broker = _FakeBroker()
|
||||
handler = object.__new__(broker_api.Handler)
|
||||
handler.path = path
|
||||
handler.broker = broker
|
||||
handler.peer_authorized = lambda: peer
|
||||
handler.account_authorized = lambda: account
|
||||
handler.body = lambda: {
|
||||
"caller": CALLER,
|
||||
"scope": SCOPE,
|
||||
"idempotency_key": "runtime-regression-test-20260823",
|
||||
"request": {
|
||||
"repository": "aios/aios",
|
||||
"target_branch": "agent/runtime-regression-test",
|
||||
"release_version": "V2R2C2",
|
||||
},
|
||||
}
|
||||
responses = []
|
||||
handler.send_json = lambda value, status, internal_credential=None: responses.append(
|
||||
(value, status, internal_credential)
|
||||
)
|
||||
broker_api.Handler.do_POST(handler)
|
||||
return responses, broker
|
||||
|
||||
|
||||
def test_existing_project_caller_route_reaches_handler_without_public_secret():
|
||||
responses, broker = _dispatch_broker()
|
||||
assert len(responses) == 1
|
||||
value, status, internal_credential = responses[0]
|
||||
assert status == 200
|
||||
assert broker.calls
|
||||
assert broker.calls[0][0:2] == (CALLER, SCOPE)
|
||||
assert internal_credential == "x" * 40
|
||||
assert "_credential" not in value
|
||||
assert "x" * 40 not in json.dumps(value)
|
||||
|
||||
|
||||
def test_unauthorized_peer_is_denied_and_unregistered_route_is_not_added():
|
||||
responses, broker = _dispatch_broker(peer=False)
|
||||
assert responses[0][1] == 403
|
||||
assert broker.calls == []
|
||||
|
||||
responses, broker = _dispatch_broker(path="/v1/not-registered")
|
||||
assert responses[0][1] == 404
|
||||
assert broker.calls == []
|
||||
Reference in New Issue
Block a user