Compare commits

...
23 changed files with 6291 additions and 399 deletions
+315
View File
@@ -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
+350 -24
View File
@@ -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
+154
View File
@@ -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.
+295
View File
@@ -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"
}
+2
View File
@@ -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"}}
+112
View File
@@ -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())
+193
View File
@@ -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())
+100
View File
@@ -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
+317
View File
@@ -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"]
+215
View File
@@ -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 == []