Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69a51d50c7 | ||
|
|
7f12094e58 |
@@ -0,0 +1,456 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Disabled-by-default TLS edge for the read-only GPT Online Task ingress.
|
||||
|
||||
The internal AIOS ingress remains loopback-only. This service is a separate edge
|
||||
that may bind non-loopback only when the owner explicitly approves it. It
|
||||
forwards only the tiny read-only ingress API and never proxies the canonical
|
||||
Bridge or Task API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict, deque
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import hmac
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import ssl
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Mapping
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
UPSTREAM = "http://127.0.0.1:18945"
|
||||
MAX_BODY = 64 * 1024
|
||||
MAX_RESPONSE = 256 * 1024
|
||||
MAX_CONCURRENT_REQUESTS = 32
|
||||
REQUEST_TIMEOUT_SECONDS = 20
|
||||
RATE_WINDOW_SECONDS = 60
|
||||
RATE_LIMIT_REQUESTS = 60
|
||||
RATE_BURST_REQUESTS = 15
|
||||
RATE_MAX_CLIENTS = 4096
|
||||
_AUDIT_SALT = secrets.token_bytes(32)
|
||||
PUBLIC_APPROVAL_ENV = "AIOS_GPT_EDGE_PUBLIC_BIND_APPROVED"
|
||||
PUBLIC_DOMAIN_ENV = "AIOS_GPT_EDGE_PUBLIC_DOMAIN"
|
||||
TASK_PATH_RE = re.compile(r"^/v1/read-only-tasks(?:/[A-Za-z0-9][A-Za-z0-9._:-]{7,127})?$")
|
||||
|
||||
|
||||
def _client_pseudonym(value: str) -> str:
|
||||
return hashlib.sha256(_AUDIT_SALT + value.encode("utf-8", "replace")).hexdigest()[:16]
|
||||
|
||||
|
||||
def audit(event: str, **fields: object) -> None:
|
||||
safe = {
|
||||
"event": event,
|
||||
"ts_unix_ms": int(time.time() * 1000),
|
||||
}
|
||||
for key, value in fields.items():
|
||||
if key in {"authorization", "body", "token", "cookie", "client_ip"}:
|
||||
continue
|
||||
safe[key] = value
|
||||
print(
|
||||
json.dumps(safe, sort_keys=True, separators=(",", ":")),
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
class EdgeError(RuntimeError):
|
||||
def __init__(self, code: str, status: int = 400) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
|
||||
def canonical(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def public_bind_allowed(host: str, approval: str) -> str:
|
||||
value = host.strip()
|
||||
if value in {"127.0.0.1", "::1", "localhost"}:
|
||||
return value
|
||||
if approval != "1":
|
||||
raise RuntimeError("GPT_EDGE_PUBLIC_BIND_OWNER_APPROVAL_REQUIRED")
|
||||
if value not in {"0.0.0.0", "::"}:
|
||||
raise RuntimeError("GPT_EDGE_BIND_ADDRESS_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def validated_public_domain(value: str) -> str:
|
||||
domain = value.strip().lower()
|
||||
if not domain:
|
||||
return ""
|
||||
if len(domain) > 253 or re.fullmatch(
|
||||
r"(?=.{1,253}\Z)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?",
|
||||
domain,
|
||||
) is None:
|
||||
raise RuntimeError("GPT_EDGE_PUBLIC_DOMAIN_INVALID")
|
||||
return domain
|
||||
|
||||
|
||||
def validated_edge_identity(host: str, approval: str, domain: str) -> tuple[str, str]:
|
||||
bind_host = public_bind_allowed(host, approval)
|
||||
public_domain = validated_public_domain(domain)
|
||||
if bind_host not in {"127.0.0.1", "::1", "localhost"} and not public_domain:
|
||||
raise RuntimeError("GPT_EDGE_PUBLIC_DOMAIN_REQUIRED")
|
||||
return bind_host, public_domain
|
||||
|
||||
|
||||
def validate_tls_file(path: Path, *, key: bool) -> Path:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise RuntimeError("GPT_EDGE_TLS_FILE_INVALID")
|
||||
size = path.stat().st_size
|
||||
if size < 16 or size > 256 * 1024:
|
||||
raise RuntimeError("GPT_EDGE_TLS_FILE_INVALID")
|
||||
if key and (path.stat().st_mode & 0o077):
|
||||
raise RuntimeError("GPT_EDGE_TLS_KEY_MODE_INVALID")
|
||||
return path
|
||||
|
||||
|
||||
def build_tls_context(cert_path: Path, key_path: Path) -> ssl.SSLContext:
|
||||
cert = validate_tls_file(cert_path, key=False)
|
||||
key = validate_tls_file(key_path, key=True)
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
context.options |= ssl.OP_NO_COMPRESSION
|
||||
context.load_cert_chain(certfile=str(cert), keyfile=str(key))
|
||||
return context
|
||||
|
||||
|
||||
def allowed_route(method: str, raw_path: str) -> str:
|
||||
parsed = urlsplit(raw_path)
|
||||
if parsed.query or parsed.fragment:
|
||||
raise EdgeError("QUERY_OR_FRAGMENT_DENY", 400)
|
||||
path = parsed.path
|
||||
if method == "GET" and path in {"/health", "/openapi.json"}:
|
||||
return path
|
||||
if method == "POST" and path == "/v1/read-only-tasks":
|
||||
return path
|
||||
if method == "GET" and TASK_PATH_RE.fullmatch(path):
|
||||
return path
|
||||
raise EdgeError("ROUTE_DENY", 404)
|
||||
|
||||
|
||||
def route_label(method: str, path: str) -> str:
|
||||
if method == "GET" and path == "/health":
|
||||
return "health"
|
||||
if method == "GET" and path == "/openapi.json":
|
||||
return "openapi"
|
||||
if method == "POST" and path == "/v1/read-only-tasks":
|
||||
return "task_create"
|
||||
if method == "GET" and path.startswith("/v1/read-only-tasks/"):
|
||||
return "task_status"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def forwarded_headers(headers: Mapping[str, str], *, body: bool) -> dict[str, str]:
|
||||
result = {"Accept": "application/json"}
|
||||
authorization = headers.get("Authorization")
|
||||
if not isinstance(authorization, str) or not authorization.startswith("Bearer "):
|
||||
raise EdgeError("AUTHORIZATION_REQUIRED", 401)
|
||||
token = authorization[7:]
|
||||
if len(token) < 32 or len(token) > 4096 or any(ch.isspace() for ch in token):
|
||||
raise EdgeError("AUTHORIZATION_INVALID", 401)
|
||||
result["Authorization"] = authorization
|
||||
if body:
|
||||
content_type = headers.get("Content-Type", "")
|
||||
if content_type.split(";", 1)[0].strip().lower() != "application/json":
|
||||
raise EdgeError("CONTENT_TYPE_INVALID", 415)
|
||||
result["Content-Type"] = "application/json"
|
||||
return result
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
window_seconds: int = RATE_WINDOW_SECONDS,
|
||||
limit: int = RATE_LIMIT_REQUESTS,
|
||||
burst: int = RATE_BURST_REQUESTS,
|
||||
max_clients: int = RATE_MAX_CLIENTS,
|
||||
now_fn=time.monotonic,
|
||||
) -> None:
|
||||
self.window_seconds = window_seconds
|
||||
self.limit = limit
|
||||
self.burst = burst
|
||||
self.max_clients = max_clients
|
||||
self.now_fn = now_fn
|
||||
self._lock = threading.Lock()
|
||||
self._events: dict[str, deque[float]] = defaultdict(deque)
|
||||
|
||||
def _prune_stale(self, cutoff: float) -> None:
|
||||
stale = [
|
||||
key
|
||||
for key, events in self._events.items()
|
||||
if not events or events[-1] <= cutoff
|
||||
]
|
||||
for key in stale:
|
||||
self._events.pop(key, None)
|
||||
|
||||
def allowed(self, key: str) -> bool:
|
||||
now = self.now_fn()
|
||||
cutoff = now - self.window_seconds
|
||||
with self._lock:
|
||||
if key not in self._events and len(self._events) >= self.max_clients:
|
||||
self._prune_stale(cutoff)
|
||||
if len(self._events) >= self.max_clients:
|
||||
return False
|
||||
events = self._events[key]
|
||||
while events and events[0] <= cutoff:
|
||||
events.popleft()
|
||||
if len(events) >= self.limit + self.burst:
|
||||
return False
|
||||
events.append(now)
|
||||
return True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Proxy:
|
||||
upstream: str = UPSTREAM
|
||||
timeout: int = REQUEST_TIMEOUT_SECONDS
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
headers: Mapping[str, str],
|
||||
body: bytes | None,
|
||||
) -> tuple[int, bytes, str]:
|
||||
if self.upstream != UPSTREAM:
|
||||
raise EdgeError("UPSTREAM_DRIFT_DENY", 503)
|
||||
req = urllib.request.Request(
|
||||
self.upstream + path,
|
||||
method=method,
|
||||
data=body,
|
||||
headers=dict(headers),
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
||||
raw = response.read(MAX_RESPONSE + 1)
|
||||
status = response.status
|
||||
content_type = response.headers.get("Content-Type", "application/json")
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read(MAX_RESPONSE + 1)
|
||||
status = exc.code
|
||||
content_type = exc.headers.get("Content-Type", "application/json")
|
||||
except Exception as exc:
|
||||
raise EdgeError("UPSTREAM_UNAVAILABLE", 502) from exc
|
||||
if len(raw) > MAX_RESPONSE:
|
||||
raise EdgeError("UPSTREAM_RESPONSE_TOO_LARGE", 502)
|
||||
if content_type.split(";", 1)[0].strip().lower() != "application/json":
|
||||
raise EdgeError("UPSTREAM_CONTENT_TYPE_INVALID", 502)
|
||||
return status, raw, "application/json"
|
||||
|
||||
|
||||
class EdgeServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
request_queue_size = 64
|
||||
allow_reuse_address = True
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
proxy = Proxy()
|
||||
limiter = RateLimiter()
|
||||
concurrency = threading.BoundedSemaphore(MAX_CONCURRENT_REQUESTS)
|
||||
expected_domain = ""
|
||||
|
||||
server_version = "AIOSGPTIngressEdge/1.0"
|
||||
sys_version = ""
|
||||
|
||||
def log_message(self, *_args: Any) -> None:
|
||||
return
|
||||
|
||||
def respond_json(self, value: Mapping[str, Any], status: int) -> None:
|
||||
raw = canonical(value)
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header("Content-Security-Policy", "default-src 'none'")
|
||||
self.send_header("Strict-Transport-Security", "max-age=31536000")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
def send_proxy_response(self, status: int, raw: bytes, content_type: str) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header("Content-Security-Policy", "default-src 'none'")
|
||||
self.send_header("Strict-Transport-Security", "max-age=31536000")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
def client_key(self) -> str:
|
||||
return str(self.client_address[0])
|
||||
|
||||
def validate_host(self) -> None:
|
||||
if not self.expected_domain:
|
||||
return
|
||||
host = self.headers.get("Host", "").split(":", 1)[0].lower()
|
||||
if not hmac.compare_digest(host, self.expected_domain):
|
||||
raise EdgeError("HOST_HEADER_DENY", 421)
|
||||
|
||||
def body(self) -> bytes:
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "-1"))
|
||||
except ValueError as exc:
|
||||
raise EdgeError("CONTENT_LENGTH_INVALID") from exc
|
||||
if length < 1 or length > MAX_BODY:
|
||||
raise EdgeError("REQUEST_SIZE_INVALID", 413)
|
||||
raw = self.rfile.read(length)
|
||||
if len(raw) != length:
|
||||
raise EdgeError("REQUEST_BODY_TRUNCATED", 400)
|
||||
# Validate JSON at the edge so malformed bodies do not consume upstream work.
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise EdgeError("REQUEST_JSON_INVALID", 400) from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise EdgeError("REQUEST_JSON_OBJECT_REQUIRED", 400)
|
||||
return canonical(value)
|
||||
|
||||
def handle_proxy(self, method: str) -> None:
|
||||
client_id = _client_pseudonym(self.client_key())
|
||||
started = time.monotonic()
|
||||
acquired = self.concurrency.acquire(blocking=False)
|
||||
if not acquired:
|
||||
audit("EDGE_BUSY", client_id=client_id, method=method)
|
||||
self.respond_json({"error": "EDGE_BUSY"}, 503)
|
||||
return
|
||||
label = "unknown"
|
||||
try:
|
||||
self.validate_host()
|
||||
path = allowed_route(method, self.path)
|
||||
label = route_label(method, path)
|
||||
if path == "/health" and method == "GET":
|
||||
self.respond_json(
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "aios-gpt-https-edge",
|
||||
"upstream": "loopback-readonly-ingress",
|
||||
},
|
||||
200,
|
||||
)
|
||||
audit(
|
||||
"EDGE_REQUEST",
|
||||
client_id=client_id,
|
||||
method=method,
|
||||
route=label,
|
||||
status=200,
|
||||
duration_ms=int((time.monotonic() - started) * 1000),
|
||||
)
|
||||
return
|
||||
if not self.limiter.allowed(self.client_key()):
|
||||
audit(
|
||||
"EDGE_RATE_LIMIT",
|
||||
client_id=client_id,
|
||||
method=method,
|
||||
route=label,
|
||||
)
|
||||
self.respond_json({"error": "RATE_LIMITED"}, 429)
|
||||
return
|
||||
has_body = method == "POST"
|
||||
headers = forwarded_headers(self.headers, body=has_body)
|
||||
body = self.body() if has_body else None
|
||||
status, raw, content_type = self.proxy.request(
|
||||
method,
|
||||
path,
|
||||
headers,
|
||||
body,
|
||||
)
|
||||
self.send_proxy_response(status, raw, content_type)
|
||||
audit(
|
||||
"EDGE_REQUEST",
|
||||
client_id=client_id,
|
||||
method=method,
|
||||
route=label,
|
||||
status=status,
|
||||
duration_ms=int((time.monotonic() - started) * 1000),
|
||||
)
|
||||
except EdgeError as exc:
|
||||
audit(
|
||||
"EDGE_REJECT",
|
||||
client_id=client_id,
|
||||
method=method,
|
||||
route=label,
|
||||
status=exc.status,
|
||||
error=exc.code,
|
||||
)
|
||||
self.respond_json({"error": exc.code}, exc.status)
|
||||
finally:
|
||||
self.concurrency.release()
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self.handle_proxy("GET")
|
||||
|
||||
def do_POST(self) -> None:
|
||||
self.handle_proxy("POST")
|
||||
|
||||
def do_PUT(self) -> None:
|
||||
self.respond_json({"error": "METHOD_DENY"}, 405)
|
||||
|
||||
def do_PATCH(self) -> None:
|
||||
self.respond_json({"error": "METHOD_DENY"}, 405)
|
||||
|
||||
def do_DELETE(self) -> None:
|
||||
self.respond_json({"error": "METHOD_DENY"}, 405)
|
||||
|
||||
def do_CONNECT(self) -> None:
|
||||
self.respond_json({"error": "METHOD_DENY"}, 405)
|
||||
|
||||
def do_OPTIONS(self) -> None:
|
||||
self.respond_json({"error": "METHOD_DENY"}, 405)
|
||||
|
||||
|
||||
def serve() -> None:
|
||||
host, public_domain = validated_edge_identity(
|
||||
os.environ.get("AIOS_GPT_EDGE_HOST", "127.0.0.1"),
|
||||
os.environ.get(PUBLIC_APPROVAL_ENV, "0"),
|
||||
os.environ.get(PUBLIC_DOMAIN_ENV, ""),
|
||||
)
|
||||
port = int(os.environ.get("AIOS_GPT_EDGE_PORT", "18443"))
|
||||
if not 1024 <= port <= 65535:
|
||||
raise RuntimeError("GPT_EDGE_PORT_INVALID")
|
||||
Handler.expected_domain = public_domain
|
||||
|
||||
credentials = Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials"))
|
||||
cert_path = Path(
|
||||
os.environ.get(
|
||||
"AIOS_GPT_EDGE_TLS_CERT_FILE",
|
||||
str(credentials / "tls_cert"),
|
||||
)
|
||||
)
|
||||
key_path = Path(
|
||||
os.environ.get(
|
||||
"AIOS_GPT_EDGE_TLS_KEY_FILE",
|
||||
str(credentials / "tls_key"),
|
||||
)
|
||||
)
|
||||
context = build_tls_context(cert_path, key_path)
|
||||
|
||||
server = EdgeServer((host, port), Handler)
|
||||
server.socket = context.wrap_socket(server.socket, server_side=True)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve()
|
||||
@@ -0,0 +1,816 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Credential-isolated host-side ingress for externally scheduled read-only Online Tasks.
|
||||
|
||||
This service is intentionally not a public AIOS control plane. It accepts a tiny,
|
||||
authenticated external intent and translates it to the canonical loopback Bridge.
|
||||
Callers cannot select repository coordinates, capabilities, execution action,
|
||||
worker, branch policy, credentials, verification decisions, or write permissions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Mapping
|
||||
|
||||
SCHEMA = "aios.gpt-online-task-ingress.request.v1"
|
||||
RESPONSE_SCHEMA = "aios.gpt-online-task-ingress.response.v1"
|
||||
OPENAPI_VERSION = "3.1.0"
|
||||
|
||||
REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$")
|
||||
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
||||
JOB_ID_RE = re.compile(r"^job-[0-9a-f]{32}$")
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
MAX_BODY = 64 * 1024
|
||||
MAX_GOAL = 12_000
|
||||
MAX_INSTRUCTIONS = 24_000
|
||||
MAX_CRITERIA = 16
|
||||
MAX_CRITERION_TEXT = 1_000
|
||||
|
||||
READONLY_CAPABILITIES = ("git.read", "git.test")
|
||||
REPOSITORY = "aios/aios"
|
||||
|
||||
|
||||
class IngressError(RuntimeError):
|
||||
def __init__(self, code: str, status: int = 400) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
|
||||
def canonical(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def digest(value: object) -> str:
|
||||
return hashlib.sha256(canonical(value)).hexdigest()
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def load_secret(path: Path, *, code: str) -> str:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise RuntimeError(code + "_UNAVAILABLE")
|
||||
value = path.read_text(encoding="utf-8").strip()
|
||||
if len(value) < 32 or "\n" in value or "\r" in value:
|
||||
raise RuntimeError(code + "_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def normalized_request(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
allowed = {
|
||||
"schema",
|
||||
"request_id",
|
||||
"goal",
|
||||
"instructions",
|
||||
"success_criteria",
|
||||
"constraints",
|
||||
}
|
||||
if set(value) - allowed:
|
||||
raise IngressError("UNKNOWN_FIELDS")
|
||||
if value.get("schema") != SCHEMA:
|
||||
raise IngressError("SCHEMA_INVALID")
|
||||
|
||||
request_id = value.get("request_id")
|
||||
if not isinstance(request_id, str) or REQUEST_ID_RE.fullmatch(request_id) is None:
|
||||
raise IngressError("REQUEST_ID_INVALID")
|
||||
|
||||
goal = value.get("goal")
|
||||
if not isinstance(goal, str) or not goal.strip() or len(goal) > MAX_GOAL:
|
||||
raise IngressError("GOAL_INVALID")
|
||||
instructions = value.get("instructions", "")
|
||||
if not isinstance(instructions, str) or len(instructions) > MAX_INSTRUCTIONS:
|
||||
raise IngressError("INSTRUCTIONS_INVALID")
|
||||
|
||||
criteria = value.get("success_criteria") or []
|
||||
if not isinstance(criteria, list) or len(criteria) > MAX_CRITERIA:
|
||||
raise IngressError("SUCCESS_CRITERIA_INVALID")
|
||||
normalized_criteria = []
|
||||
for item in criteria:
|
||||
if not isinstance(item, Mapping) or set(item) != {"id", "description"}:
|
||||
raise IngressError("SUCCESS_CRITERIA_INVALID")
|
||||
criterion_id = item.get("id")
|
||||
description = item.get("description")
|
||||
if (
|
||||
not isinstance(criterion_id, str)
|
||||
or not criterion_id.strip()
|
||||
or len(criterion_id) > 120
|
||||
or not isinstance(description, str)
|
||||
or not description.strip()
|
||||
or len(description) > MAX_CRITERION_TEXT
|
||||
):
|
||||
raise IngressError("SUCCESS_CRITERIA_INVALID")
|
||||
normalized_criteria.append(
|
||||
{"id": criterion_id.strip(), "description": description.strip()}
|
||||
)
|
||||
|
||||
constraints = value.get("constraints") or {}
|
||||
if not isinstance(constraints, Mapping):
|
||||
raise IngressError("CONSTRAINTS_INVALID")
|
||||
allowed_constraints = {
|
||||
"max_files",
|
||||
"focus_paths",
|
||||
"notes",
|
||||
}
|
||||
if set(constraints) - allowed_constraints:
|
||||
raise IngressError("CONSTRAINTS_FIELDS_INVALID")
|
||||
normalized_constraints: dict[str, Any] = {}
|
||||
if "max_files" in constraints:
|
||||
max_files = constraints["max_files"]
|
||||
if (
|
||||
not isinstance(max_files, int)
|
||||
or isinstance(max_files, bool)
|
||||
or not 1 <= max_files <= 500
|
||||
):
|
||||
raise IngressError("MAX_FILES_INVALID")
|
||||
normalized_constraints["max_files"] = max_files
|
||||
if "focus_paths" in constraints:
|
||||
paths = constraints["focus_paths"]
|
||||
if (
|
||||
not isinstance(paths, list)
|
||||
or len(paths) > 32
|
||||
or not all(
|
||||
isinstance(path, str)
|
||||
and path
|
||||
and len(path) <= 512
|
||||
and not path.startswith("/")
|
||||
and ".." not in Path(path).parts
|
||||
for path in paths
|
||||
)
|
||||
):
|
||||
raise IngressError("FOCUS_PATHS_INVALID")
|
||||
normalized_constraints["focus_paths"] = paths
|
||||
if "notes" in constraints:
|
||||
notes = constraints["notes"]
|
||||
if not isinstance(notes, str) or len(notes) > 4_000:
|
||||
raise IngressError("CONSTRAINT_NOTES_INVALID")
|
||||
normalized_constraints["notes"] = notes
|
||||
|
||||
return {
|
||||
"schema": SCHEMA,
|
||||
"request_id": request_id,
|
||||
"goal": goal.strip(),
|
||||
"instructions": instructions,
|
||||
"success_criteria": normalized_criteria,
|
||||
"constraints": normalized_constraints,
|
||||
}
|
||||
|
||||
|
||||
def validated_bind_host(value: str) -> str:
|
||||
host = value.strip()
|
||||
if host not in {"127.0.0.1", "::1", "localhost"}:
|
||||
raise RuntimeError("GPT_INGRESS_NON_LOOPBACK_BIND_DENY")
|
||||
return host
|
||||
|
||||
|
||||
def task_id_for(request_id: str) -> str:
|
||||
suffix = hashlib.sha256(request_id.encode("utf-8")).hexdigest()[:24]
|
||||
return "gpt-readonly-" + suffix
|
||||
|
||||
|
||||
def branch_for(task_id: str) -> str:
|
||||
return "agent/external-gpt-readonly/" + task_id[-24:]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Upstream:
|
||||
bridge_base: str
|
||||
task_base: str
|
||||
control_token: str
|
||||
timeout: int = 20
|
||||
|
||||
def _request(
|
||||
self,
|
||||
base: str,
|
||||
method: str,
|
||||
path: str,
|
||||
body: Mapping[str, Any] | None = None,
|
||||
) -> tuple[int, dict[str, Any]]:
|
||||
raw_body = None if body is None else canonical(body)
|
||||
headers = {
|
||||
"Authorization": "Bearer " + self.control_token,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if raw_body is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(
|
||||
base.rstrip("/") + path,
|
||||
method=method,
|
||||
data=raw_body,
|
||||
headers=headers,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
||||
raw = response.read()
|
||||
status = response.status
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read()
|
||||
status = exc.code
|
||||
except Exception as exc:
|
||||
raise IngressError("UPSTREAM_TRANSPORT_FAILED", 503) from exc
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise IngressError("UPSTREAM_RESPONSE_INVALID", 502) from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise IngressError("UPSTREAM_RESPONSE_INVALID", 502)
|
||||
return status, dict(value)
|
||||
|
||||
def control_snapshot(self) -> dict[str, Any]:
|
||||
status, value = self._request(
|
||||
self.bridge_base,
|
||||
"GET",
|
||||
"/v2/control-plane-snapshot",
|
||||
)
|
||||
if status != 200:
|
||||
raise IngressError("CONTROL_SNAPSHOT_UNAVAILABLE", 503)
|
||||
return value
|
||||
|
||||
def create_governed_task(
|
||||
self,
|
||||
body: Mapping[str, Any],
|
||||
) -> tuple[int, dict[str, Any]]:
|
||||
return self._request(
|
||||
self.bridge_base,
|
||||
"POST",
|
||||
"/v2/governed-task",
|
||||
body,
|
||||
)
|
||||
|
||||
def execution_envelope(
|
||||
self,
|
||||
body: Mapping[str, Any],
|
||||
) -> tuple[int, dict[str, Any]]:
|
||||
return self._request(
|
||||
self.bridge_base,
|
||||
"POST",
|
||||
"/v2/execution-envelope",
|
||||
body,
|
||||
)
|
||||
|
||||
def get_task(self, task_id: str) -> tuple[int, dict[str, Any]]:
|
||||
return self._request(
|
||||
self.task_base,
|
||||
"GET",
|
||||
"/v1/tasks/" + task_id,
|
||||
)
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
self._lock = threading.RLock()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._connect() as con:
|
||||
con.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS external_task_requests(
|
||||
request_id TEXT PRIMARY KEY,
|
||||
request_sha256 TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL UNIQUE,
|
||||
job_id TEXT,
|
||||
base_oid TEXT,
|
||||
state TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
response_json TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
con.commit()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
con = sqlite3.connect(self.path, timeout=30)
|
||||
con.row_factory = sqlite3.Row
|
||||
con.execute("PRAGMA busy_timeout=30000")
|
||||
return con
|
||||
|
||||
def get(self, request_id: str) -> sqlite3.Row | None:
|
||||
with self._connect() as con:
|
||||
return con.execute(
|
||||
"SELECT * FROM external_task_requests WHERE request_id=?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
|
||||
def reserve(self, request: Mapping[str, Any], task_id: str) -> sqlite3.Row:
|
||||
request_sha = digest(request)
|
||||
timestamp = now()
|
||||
with self._lock, self._connect() as con:
|
||||
con.execute("BEGIN IMMEDIATE")
|
||||
row = con.execute(
|
||||
"SELECT * FROM external_task_requests WHERE request_id=?",
|
||||
(request["request_id"],),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
if row["request_sha256"] != request_sha:
|
||||
raise IngressError("IDEMPOTENCY_CONFLICT", 409)
|
||||
con.commit()
|
||||
return row
|
||||
con.execute(
|
||||
"""
|
||||
INSERT INTO external_task_requests(
|
||||
request_id,request_sha256,task_id,state,created_at,updated_at
|
||||
) VALUES(?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
request["request_id"],
|
||||
request_sha,
|
||||
task_id,
|
||||
"PENDING",
|
||||
timestamp,
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
con.commit()
|
||||
row = self.get(request["request_id"])
|
||||
assert row is not None
|
||||
return row
|
||||
|
||||
def bound(
|
||||
self,
|
||||
request_id: str,
|
||||
*,
|
||||
job_id: str,
|
||||
base_oid: str,
|
||||
response: Mapping[str, Any],
|
||||
) -> None:
|
||||
with self._lock, self._connect() as con:
|
||||
con.execute(
|
||||
"""
|
||||
UPDATE external_task_requests
|
||||
SET job_id=?,base_oid=?,state='BOUND',response_json=?,updated_at=?
|
||||
WHERE request_id=?
|
||||
""",
|
||||
(
|
||||
job_id,
|
||||
base_oid,
|
||||
json.dumps(response, sort_keys=True, separators=(",", ":")),
|
||||
now(),
|
||||
request_id,
|
||||
),
|
||||
)
|
||||
con.commit()
|
||||
|
||||
|
||||
class IngressService:
|
||||
def __init__(self, upstream: Upstream, store: Store) -> None:
|
||||
self.upstream = upstream
|
||||
self.store = store
|
||||
|
||||
@staticmethod
|
||||
def _main_oid(snapshot: Mapping[str, Any]) -> str:
|
||||
candidates = [
|
||||
snapshot.get("authoritative_head_oid"),
|
||||
snapshot.get("head"),
|
||||
(snapshot.get("aggregate") or {}).get("authoritative_head_oid")
|
||||
if isinstance(snapshot.get("aggregate"), Mapping)
|
||||
else None,
|
||||
]
|
||||
for candidate in candidates:
|
||||
if isinstance(candidate, str) and OID_RE.fullmatch(candidate):
|
||||
return candidate
|
||||
raise IngressError("AUTHORITATIVE_HEAD_UNAVAILABLE", 503)
|
||||
|
||||
@staticmethod
|
||||
def _public_response(
|
||||
request_id: str,
|
||||
task_id: str,
|
||||
job_id: str,
|
||||
state: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": RESPONSE_SCHEMA,
|
||||
"request_id": request_id,
|
||||
"task_id": task_id,
|
||||
"job_id": job_id,
|
||||
"state": state,
|
||||
"execution_mode": "source_capture",
|
||||
"capabilities": list(READONLY_CAPABILITIES),
|
||||
"write_access": False,
|
||||
"official_verification_required": True,
|
||||
}
|
||||
|
||||
def _recover_existing(
|
||||
self,
|
||||
request: Mapping[str, Any],
|
||||
row: sqlite3.Row,
|
||||
) -> dict[str, Any] | None:
|
||||
task_id = str(row["task_id"])
|
||||
status, task = self.upstream.get_task(task_id)
|
||||
if status == 404:
|
||||
return None
|
||||
if status != 200:
|
||||
raise IngressError("TASK_RECOVERY_FAILED", 503)
|
||||
metadata = task.get("metadata")
|
||||
execution = (
|
||||
metadata.get("execution")
|
||||
if isinstance(metadata, Mapping)
|
||||
and isinstance(metadata.get("execution"), Mapping)
|
||||
else {}
|
||||
)
|
||||
job_id = str(execution.get("job_id") or row["job_id"] or "")
|
||||
base_oid = str(execution.get("base_oid") or row["base_oid"] or "")
|
||||
if (
|
||||
task.get("task_id") != task_id
|
||||
or not JOB_ID_RE.fullmatch(job_id)
|
||||
or not OID_RE.fullmatch(base_oid)
|
||||
or execution.get("action") != "source_capture"
|
||||
or sorted(execution.get("capability_scope") or [])
|
||||
!= sorted(READONLY_CAPABILITIES)
|
||||
or (execution.get("governance") or {}).get("writer_lease") is not False
|
||||
):
|
||||
raise IngressError("TASK_RECOVERY_BINDING_INVALID", 409)
|
||||
response = self._public_response(
|
||||
request["request_id"],
|
||||
task_id,
|
||||
job_id,
|
||||
str(task.get("status") or "BOUND"),
|
||||
)
|
||||
self.store.bound(
|
||||
request["request_id"],
|
||||
job_id=job_id,
|
||||
base_oid=base_oid,
|
||||
response=response,
|
||||
)
|
||||
return response
|
||||
|
||||
def create(self, raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
request = normalized_request(raw)
|
||||
task_id = task_id_for(request["request_id"])
|
||||
row = self.store.reserve(request, task_id)
|
||||
if row["state"] == "BOUND" and row["response_json"]:
|
||||
return json.loads(row["response_json"])
|
||||
|
||||
recovered = self._recover_existing(request, row)
|
||||
if recovered is not None:
|
||||
return recovered
|
||||
|
||||
snapshot = self.upstream.control_snapshot()
|
||||
base_oid = self._main_oid(snapshot)
|
||||
target_branch = branch_for(task_id)
|
||||
binding_key = "gpt-readonly-binding-" + digest(
|
||||
{"request_id": request["request_id"], "base_oid": base_oid}
|
||||
)[:24]
|
||||
constraints = {
|
||||
**request["constraints"],
|
||||
"repository_mutation": "forbidden",
|
||||
"control_plane_side_effects": "forbidden",
|
||||
"credential_access": "forbidden",
|
||||
}
|
||||
body = {
|
||||
"task_id": task_id,
|
||||
"repository": REPOSITORY,
|
||||
"base_oid": base_oid,
|
||||
"expected_branch_oid": base_oid,
|
||||
"target_branch": target_branch,
|
||||
"branch_mode": "CREATE_NEW",
|
||||
"generation": 1,
|
||||
"risk": "low",
|
||||
"description": "Externally requested read-only governed Online Task.",
|
||||
"idempotency_key": binding_key,
|
||||
"priority": 0,
|
||||
"max_attempts": 3,
|
||||
"require_online_task_projection": True,
|
||||
"online_task": {
|
||||
"goal": request["goal"],
|
||||
"instructions": request["instructions"],
|
||||
"success_criteria": request["success_criteria"],
|
||||
"constraints": constraints,
|
||||
"required_capabilities": list(READONLY_CAPABILITIES),
|
||||
"priority": 0,
|
||||
"max_attempts": 3,
|
||||
},
|
||||
"execution": {
|
||||
"action": "source_capture",
|
||||
"adapter": "source_capture",
|
||||
"capability_scope": list(READONLY_CAPABILITIES),
|
||||
"source_signals": [
|
||||
"external read-only source analysis request",
|
||||
"no repository mutation no credential access no control-plane side effects",
|
||||
],
|
||||
},
|
||||
}
|
||||
status, value = self.upstream.create_governed_task(body)
|
||||
if status not in {200, 201, 202}:
|
||||
if status == 409:
|
||||
recovered = self._recover_existing(request, row)
|
||||
if recovered is not None:
|
||||
return recovered
|
||||
raise IngressError(
|
||||
"GOVERNED_TASK_CREATE_FAILED",
|
||||
409 if status == 409 else 503,
|
||||
)
|
||||
job_id = str(value.get("job_id") or "")
|
||||
if value.get("task_id") != task_id or JOB_ID_RE.fullmatch(job_id) is None:
|
||||
raise IngressError("GOVERNED_TASK_RESPONSE_INVALID", 502)
|
||||
|
||||
envelope_status, envelope = self.upstream.execution_envelope(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"job_id": job_id,
|
||||
"required_capabilities": list(READONLY_CAPABILITIES),
|
||||
"execution": body["execution"],
|
||||
}
|
||||
)
|
||||
if envelope_status != 200:
|
||||
raise IngressError("EXECUTION_PROJECTION_FAILED", 503)
|
||||
if (
|
||||
envelope.get("task_id") != task_id
|
||||
or envelope.get("job_id") != job_id
|
||||
or envelope.get("base_oid") != base_oid
|
||||
or envelope.get("action") != "source_capture"
|
||||
or sorted(envelope.get("capability_scope") or [])
|
||||
!= sorted(READONLY_CAPABILITIES)
|
||||
or (envelope.get("governance") or {}).get("writer_lease") is not False
|
||||
):
|
||||
raise IngressError("EXECUTION_PROJECTION_INVALID", 502)
|
||||
|
||||
response = self._public_response(
|
||||
request["request_id"],
|
||||
task_id,
|
||||
job_id,
|
||||
"BOUND",
|
||||
)
|
||||
self.store.bound(
|
||||
request["request_id"],
|
||||
job_id=job_id,
|
||||
base_oid=base_oid,
|
||||
response=response,
|
||||
)
|
||||
return response
|
||||
|
||||
def status(self, request_id: str) -> dict[str, Any]:
|
||||
if REQUEST_ID_RE.fullmatch(request_id) is None:
|
||||
raise IngressError("REQUEST_ID_INVALID")
|
||||
row = self.store.get(request_id)
|
||||
if row is None:
|
||||
raise IngressError("REQUEST_NOT_FOUND", 404)
|
||||
task_id = str(row["task_id"])
|
||||
status, task = self.upstream.get_task(task_id)
|
||||
if status == 200 and not row["job_id"]:
|
||||
metadata = task.get("metadata")
|
||||
execution = (
|
||||
metadata.get("execution")
|
||||
if isinstance(metadata, Mapping)
|
||||
and isinstance(metadata.get("execution"), Mapping)
|
||||
else {}
|
||||
)
|
||||
job_id = str(execution.get("job_id") or "")
|
||||
base_oid = str(execution.get("base_oid") or "")
|
||||
if (
|
||||
JOB_ID_RE.fullmatch(job_id)
|
||||
and OID_RE.fullmatch(base_oid)
|
||||
and execution.get("action") == "source_capture"
|
||||
and sorted(execution.get("capability_scope") or [])
|
||||
== sorted(READONLY_CAPABILITIES)
|
||||
and (execution.get("governance") or {}).get("writer_lease") is False
|
||||
):
|
||||
recovered = self._public_response(
|
||||
request_id,
|
||||
task_id,
|
||||
job_id,
|
||||
str(task.get("status") or "BOUND"),
|
||||
)
|
||||
self.store.bound(
|
||||
request_id,
|
||||
job_id=job_id,
|
||||
base_oid=base_oid,
|
||||
response=recovered,
|
||||
)
|
||||
row = self.store.get(request_id) or row
|
||||
if status == 404:
|
||||
return {
|
||||
"schema": RESPONSE_SCHEMA,
|
||||
"request_id": request_id,
|
||||
"task_id": task_id,
|
||||
"state": "PENDING",
|
||||
"official_verification_required": True,
|
||||
}
|
||||
if status != 200:
|
||||
raise IngressError("TASK_STATUS_UNAVAILABLE", 503)
|
||||
|
||||
result = task.get("result") if isinstance(task.get("result"), Mapping) else {}
|
||||
verification = (
|
||||
task.get("verification")
|
||||
if isinstance(task.get("verification"), Mapping)
|
||||
else {}
|
||||
)
|
||||
safe = {
|
||||
"schema": RESPONSE_SCHEMA,
|
||||
"request_id": request_id,
|
||||
"task_id": task_id,
|
||||
"job_id": str(row["job_id"] or ""),
|
||||
"state": str(task.get("status") or "UNKNOWN"),
|
||||
"attempt": int(task.get("attempt") or 0),
|
||||
"claimed": bool(task.get("claimed_by")),
|
||||
"result_status": result.get("status"),
|
||||
"result_classification": result.get("classification"),
|
||||
"official_verification": verification.get("decision"),
|
||||
"official_classification": verification.get("classification"),
|
||||
"write_access": False,
|
||||
}
|
||||
if result.get("execution_mode") == "source_capture":
|
||||
snapshot = result.get("source_snapshot")
|
||||
if isinstance(snapshot, Mapping):
|
||||
safe["source_snapshot_sha256"] = snapshot.get("payload_sha256")
|
||||
safe["source_snapshot_file_count"] = snapshot.get("file_count")
|
||||
return safe
|
||||
|
||||
|
||||
def openapi_document() -> dict[str, Any]:
|
||||
return {
|
||||
"openapi": OPENAPI_VERSION,
|
||||
"info": {
|
||||
"title": "AIOS Read-Only Online Task Ingress",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
"paths": {
|
||||
"/v1/read-only-tasks": {
|
||||
"post": {
|
||||
"operationId": "createReadOnlyOnlineTask",
|
||||
"security": [{"bearerAuth": []}],
|
||||
"requestBody": {"required": True},
|
||||
"responses": {"200": {"description": "Task accepted"}},
|
||||
}
|
||||
},
|
||||
"/v1/read-only-tasks/{request_id}": {
|
||||
"get": {
|
||||
"operationId": "getReadOnlyOnlineTaskStatus",
|
||||
"security": [{"bearerAuth": []}],
|
||||
"responses": {"200": {"description": "Sanitized task status"}},
|
||||
}
|
||||
},
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
service: IngressService | None = None
|
||||
ingress_token: str = ""
|
||||
|
||||
def log_message(self, *_args: Any) -> None:
|
||||
return
|
||||
|
||||
def respond(self, value: Mapping[str, Any], status: int) -> None:
|
||||
raw = canonical(value)
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
def authorized(self) -> bool:
|
||||
header = self.headers.get("Authorization", "")
|
||||
return (
|
||||
header.startswith("Bearer ")
|
||||
and bool(self.ingress_token)
|
||||
and hmac.compare_digest(header[7:], self.ingress_token)
|
||||
)
|
||||
|
||||
def body(self) -> dict[str, Any]:
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "-1"))
|
||||
except ValueError as exc:
|
||||
raise IngressError("CONTENT_LENGTH_INVALID") from exc
|
||||
if length < 1 or length > MAX_BODY:
|
||||
raise IngressError("REQUEST_SIZE_INVALID", 413)
|
||||
try:
|
||||
value = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise IngressError("REQUEST_JSON_INVALID") from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise IngressError("REQUEST_OBJECT_REQUIRED")
|
||||
return dict(value)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
if self.path == "/health":
|
||||
self.respond(
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "aios-gpt-online-task-ingress",
|
||||
"mode": "read-only",
|
||||
},
|
||||
200,
|
||||
)
|
||||
return
|
||||
if self.path == "/openapi.json":
|
||||
self.respond(openapi_document(), 200)
|
||||
return
|
||||
if not self.authorized():
|
||||
self.respond({"error": "UNAUTHORIZED"}, 401)
|
||||
return
|
||||
match = re.fullmatch(r"/v1/read-only-tasks/([^/?#]+)", self.path)
|
||||
if match is None:
|
||||
self.respond({"error": "NOT_FOUND"}, 404)
|
||||
return
|
||||
try:
|
||||
if self.service is None:
|
||||
raise IngressError("RUNTIME_UNAVAILABLE", 503)
|
||||
self.respond(self.service.status(match.group(1)), 200)
|
||||
except IngressError as exc:
|
||||
self.respond({"error": exc.code}, exc.status)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if self.path != "/v1/read-only-tasks":
|
||||
self.respond({"error": "NOT_FOUND"}, 404)
|
||||
return
|
||||
if not self.authorized():
|
||||
self.respond({"error": "UNAUTHORIZED"}, 401)
|
||||
return
|
||||
try:
|
||||
if self.service is None:
|
||||
raise IngressError("RUNTIME_UNAVAILABLE", 503)
|
||||
value = self.service.create(self.body())
|
||||
self.respond(value, 200)
|
||||
except IngressError as exc:
|
||||
self.respond({"error": exc.code}, exc.status)
|
||||
|
||||
|
||||
def serve() -> None:
|
||||
ingress_token_path = Path(
|
||||
os.environ.get(
|
||||
"AIOS_GPT_INGRESS_TOKEN_FILE",
|
||||
str(
|
||||
Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials"))
|
||||
/ "gpt_ingress_token"
|
||||
),
|
||||
)
|
||||
)
|
||||
control_token_path = Path(
|
||||
os.environ.get(
|
||||
"AIOS_AGENT_TASK_CONTROL_TOKEN_FILE",
|
||||
str(
|
||||
Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials"))
|
||||
/ "agent_task_control_token"
|
||||
),
|
||||
)
|
||||
)
|
||||
ingress_token = load_secret(
|
||||
ingress_token_path,
|
||||
code="GPT_INGRESS_CREDENTIAL",
|
||||
)
|
||||
control_token = load_secret(
|
||||
control_token_path,
|
||||
code="CONTROL_PLANE_CREDENTIAL",
|
||||
)
|
||||
|
||||
state_root = Path(
|
||||
os.environ.get(
|
||||
"AIOS_GPT_INGRESS_STATE_ROOT",
|
||||
"/var/lib/aios-gpt-online-task-ingress",
|
||||
)
|
||||
)
|
||||
host = validated_bind_host(
|
||||
os.environ.get("AIOS_GPT_INGRESS_HOST", "127.0.0.1")
|
||||
)
|
||||
port = int(os.environ.get("AIOS_GPT_INGRESS_PORT", "18945"))
|
||||
if not 1024 <= port <= 65535:
|
||||
raise RuntimeError("GPT_INGRESS_PORT_INVALID")
|
||||
|
||||
Handler.service = IngressService(
|
||||
Upstream(
|
||||
bridge_base=os.environ.get(
|
||||
"AIOS_GOVERNED_WRITE_API_URL",
|
||||
"http://127.0.0.1:18892",
|
||||
),
|
||||
task_base=os.environ.get(
|
||||
"AIOS_AGENT_TASK_API_URL",
|
||||
"http://127.0.0.1:18940",
|
||||
),
|
||||
control_token=control_token,
|
||||
),
|
||||
Store(state_root / "state.sqlite3"),
|
||||
)
|
||||
Handler.ingress_token = ingress_token
|
||||
ThreadingHTTPServer((host, port), Handler).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve()
|
||||
@@ -0,0 +1,41 @@
|
||||
[Unit]
|
||||
Description=AIOS disabled-by-default HTTPS edge for GPT read-only Online Task ingress
|
||||
After=network-online.target aios-gpt-online-task-ingress.service
|
||||
Wants=network-online.target aios-gpt-online-task-ingress.service
|
||||
ConditionPathExists=/etc/aios-gitops/gpt-https-edge.approved
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
DynamicUser=yes
|
||||
LoadCredential=tls_cert:/etc/aios-gitops/gpt-https-edge-tls.crt
|
||||
LoadCredential=tls_key:/etc/aios-gitops/gpt-https-edge-tls.key
|
||||
Environment=AIOS_GPT_EDGE_TLS_CERT_FILE=%d/tls_cert
|
||||
Environment=AIOS_GPT_EDGE_TLS_KEY_FILE=%d/tls_key
|
||||
Environment=AIOS_GPT_EDGE_HOST=127.0.0.1
|
||||
Environment=AIOS_GPT_EDGE_PORT=18443
|
||||
Environment=AIOS_GPT_EDGE_PUBLIC_BIND_APPROVED=0
|
||||
Environment=AIOS_GPT_EDGE_PUBLIC_DOMAIN=
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_gpt_https_edge.py
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectClock=yes
|
||||
LockPersonality=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6
|
||||
InaccessiblePaths=/data/aios-gitops /etc/aios-gitops /home /var/lib/aios-agent-task
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,45 @@
|
||||
[Unit]
|
||||
Description=AIOS external GPT read-only Online Task ingress
|
||||
After=network.target aios-chatgpt-bridge-v2-staging.service aios-agent-task-api.service
|
||||
Wants=aios-chatgpt-bridge-v2-staging.service aios-agent-task-api.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
DynamicUser=yes
|
||||
StateDirectory=aios-gpt-online-task-ingress
|
||||
StateDirectoryMode=0750
|
||||
LoadCredential=gpt_ingress_token:/etc/aios-gitops/gpt-online-task-ingress.token
|
||||
LoadCredential=agent_task_control_token:/etc/aios-gitops/agent-task-control.token
|
||||
Environment=AIOS_GPT_INGRESS_TOKEN_FILE=%d/gpt_ingress_token
|
||||
Environment=AIOS_AGENT_TASK_CONTROL_TOKEN_FILE=%d/agent_task_control_token
|
||||
Environment=AIOS_GPT_INGRESS_STATE_ROOT=%S/aios-gpt-online-task-ingress
|
||||
Environment=AIOS_GPT_INGRESS_HOST=127.0.0.1
|
||||
Environment=AIOS_GPT_INGRESS_PORT=18945
|
||||
Environment=AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892
|
||||
Environment=AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_gpt_online_task_ingress.py
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectClock=yes
|
||||
LockPersonality=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=127.0.0.0/8
|
||||
InaccessiblePaths=/data/aios-gitops /etc/aios-gitops /home
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,120 @@
|
||||
# Disabled-by-default HTTPS edge for external GPT Online Task access
|
||||
|
||||
## Purpose
|
||||
|
||||
The internal ingress (`127.0.0.1:18945`) remains loopback-only. This edge is the only component
|
||||
allowed to become Internet-facing, and only after a separate owner approval.
|
||||
|
||||
It forwards only:
|
||||
|
||||
```text
|
||||
POST /v1/read-only-tasks
|
||||
GET /v1/read-only-tasks/{request_id}
|
||||
GET /openapi.json
|
||||
GET /health
|
||||
```
|
||||
|
||||
It never proxies:
|
||||
|
||||
```text
|
||||
18892 canonical Bridge
|
||||
18940 Task API
|
||||
WriterLease
|
||||
commit
|
||||
verification
|
||||
release
|
||||
tag
|
||||
baseline
|
||||
arbitrary URLs
|
||||
```
|
||||
|
||||
## Default state
|
||||
|
||||
The systemd unit is disabled by policy unless the owner creates:
|
||||
|
||||
```text
|
||||
/etc/aios-gitops/gpt-https-edge.approved
|
||||
```
|
||||
|
||||
The base unit still binds only to:
|
||||
|
||||
```text
|
||||
127.0.0.1:18443
|
||||
AIOS_GPT_EDGE_PUBLIC_BIND_APPROVED=0
|
||||
```
|
||||
|
||||
Non-loopback bind is rejected by code unless an owner-controlled drop-in sets:
|
||||
|
||||
```text
|
||||
AIOS_GPT_EDGE_PUBLIC_BIND_APPROVED=1
|
||||
AIOS_GPT_EDGE_HOST=0.0.0.0
|
||||
AIOS_GPT_EDGE_PUBLIC_DOMAIN=<approved hostname>
|
||||
```
|
||||
|
||||
This repository candidate does not create that drop-in or approval marker.
|
||||
|
||||
## TLS
|
||||
|
||||
TLS certificate/key are owner-managed systemd credentials:
|
||||
|
||||
```text
|
||||
/etc/aios-gitops/gpt-https-edge-tls.crt
|
||||
/etc/aios-gitops/gpt-https-edge-tls.key
|
||||
```
|
||||
|
||||
The service requires TLS 1.2 or newer, disables TLS compression, and never logs or returns key
|
||||
material.
|
||||
|
||||
Certificate/key issuance and renewal are external owner responsibilities until a separately
|
||||
governed ACME workflow is implemented.
|
||||
|
||||
## Request protections
|
||||
|
||||
The edge provides:
|
||||
|
||||
- 64 KiB request cap;
|
||||
- 256 KiB upstream response cap;
|
||||
- JSON validation before proxying;
|
||||
- fixed upstream `http://127.0.0.1:18945`;
|
||||
- Authorization-only header forwarding;
|
||||
- no Cookie/X-Forwarded-* trust;
|
||||
- route/method allowlist;
|
||||
- per-source-IP in-memory rate limiting;
|
||||
- bounded concurrent request handling;
|
||||
- optional exact Host-header enforcement;
|
||||
- HSTS / no-store / nosniff / no-referrer response headers.
|
||||
|
||||
The rate limiter is intentionally simple and local. Multi-node/global abuse protection is outside
|
||||
the current one-host architecture.
|
||||
|
||||
## Authentication
|
||||
|
||||
The HTTPS edge does not create a new authentication scheme. It forwards the external Bearer
|
||||
credential to the loopback GPT ingress, which verifies `gpt_ingress_token`.
|
||||
|
||||
The edge never receives the AIOS control-plane token.
|
||||
|
||||
## Deployment boundary
|
||||
|
||||
Public activation is a high-risk network operation and requires explicit owner approval. Before
|
||||
activation verify:
|
||||
|
||||
```text
|
||||
host-side GPT ingress E2E=VERIFIED
|
||||
owner ingress token provisioned
|
||||
TLS certificate/key provisioned
|
||||
public hostname owned and DNS configured
|
||||
firewall/network exposure reviewed
|
||||
explicit public-bind approval recorded
|
||||
rollback plan available
|
||||
```
|
||||
|
||||
Only after those gates may an owner-authorized runtime drop-in bind this service publicly.
|
||||
|
||||
Until then:
|
||||
|
||||
```text
|
||||
GPT_HTTPS_EDGE_IMPLEMENTATION=candidate
|
||||
PUBLIC_NETWORK_EXPOSURE=NONE
|
||||
PUBLIC_CUSTOM_GPT_DIRECT_AI0S_ACCESS=NOT_VERIFIED
|
||||
```
|
||||
@@ -0,0 +1,138 @@
|
||||
# External GPT → Online Task ingress
|
||||
|
||||
## Scope
|
||||
|
||||
This component closes the current architectural gap between an external Custom GPT and AIOS
|
||||
without exposing the internal Task API (`18940`) or canonical Bridge (`18892`) directly.
|
||||
|
||||
The first supported public-facing capability is intentionally read-only:
|
||||
|
||||
```text
|
||||
authenticated external intent
|
||||
→ host-side ingress
|
||||
→ canonical /v2/governed-task
|
||||
→ canonical /v2/execution-envelope
|
||||
→ persistent generic worker
|
||||
→ source_capture result
|
||||
→ official control-plane verification
|
||||
```
|
||||
|
||||
The ingress itself never claims, heartbeats, verifies, commits, acquires WriterLease, or writes
|
||||
protected refs.
|
||||
|
||||
## Security boundary
|
||||
|
||||
The service binds only to loopback (`127.0.0.1:18945`). A future owner-approved TLS reverse proxy
|
||||
or tunnel may expose this service externally. Direct non-loopback binding is denied by code.
|
||||
|
||||
Two independent credentials are projected by systemd:
|
||||
|
||||
- `gpt_ingress_token`: authenticates the external caller to this ingress.
|
||||
- `agent_task_control_token`: lets the ingress call the loopback AIOS control plane.
|
||||
|
||||
Neither credential is stored in Git, SQLite, task payloads, results, logs, or API responses.
|
||||
|
||||
The ingress has no repository filesystem access.
|
||||
|
||||
## Caller-controlled fields
|
||||
|
||||
Allowed:
|
||||
|
||||
```text
|
||||
request_id
|
||||
goal
|
||||
instructions
|
||||
success_criteria
|
||||
constraints.max_files
|
||||
constraints.focus_paths
|
||||
constraints.notes
|
||||
```
|
||||
|
||||
Not caller-controlled:
|
||||
|
||||
```text
|
||||
repository
|
||||
base_oid
|
||||
expected_branch_oid
|
||||
target_branch
|
||||
branch_mode
|
||||
generation
|
||||
risk
|
||||
priority
|
||||
max_attempts
|
||||
capabilities
|
||||
action/adapter
|
||||
WriterLease
|
||||
worker identity
|
||||
credentials
|
||||
verification decision
|
||||
main/production/tag/baseline permissions
|
||||
```
|
||||
|
||||
All external tasks are hard-bound to:
|
||||
|
||||
```text
|
||||
repository=aios/aios
|
||||
capabilities=[git.read, git.test]
|
||||
action=source_capture
|
||||
writer_lease=false
|
||||
repository_mutation=forbidden
|
||||
control_plane_side_effects=forbidden
|
||||
credential_access=forbidden
|
||||
```
|
||||
|
||||
## Idempotency
|
||||
|
||||
`request_id` is the durable external idempotency boundary.
|
||||
|
||||
The ingress stores only:
|
||||
|
||||
```text
|
||||
request hash
|
||||
derived AIOS task id
|
||||
canonical job id
|
||||
base oid
|
||||
sanitized receipt
|
||||
state
|
||||
```
|
||||
|
||||
An exact retry returns the same receipt. Reusing a `request_id` with different content returns
|
||||
`IDEMPOTENCY_CONFLICT`.
|
||||
|
||||
A crash after canonical task creation can recover the same binding from the Task API rather than
|
||||
creating a replacement Task.
|
||||
|
||||
## Status
|
||||
|
||||
`GET /v1/read-only-tasks/{request_id}` returns only sanitized state:
|
||||
|
||||
```text
|
||||
task/job ids
|
||||
status
|
||||
attempt
|
||||
whether claimed
|
||||
result classification
|
||||
official verification decision/classification
|
||||
source snapshot hash/count when available
|
||||
```
|
||||
|
||||
It does not proxy raw Task metadata, execution envelopes, result text, credentials, or source files.
|
||||
|
||||
## Public exposure
|
||||
|
||||
This candidate does **not** make AIOS publicly reachable. Public exposure requires a separately
|
||||
approved HTTPS ingress/reverse proxy with:
|
||||
|
||||
- TLS;
|
||||
- external authentication lifecycle;
|
||||
- rate limiting / abuse controls;
|
||||
- request size limits;
|
||||
- observability;
|
||||
- explicit owner approval for the network exposure.
|
||||
|
||||
Until that step is verified:
|
||||
|
||||
```text
|
||||
HOST_SIDE_EXTERNAL_GPT_ADAPTER=implemented/staging-candidate
|
||||
PUBLIC_CUSTOM_GPT_DIRECT_AI0S_ACCESS=not verified
|
||||
```
|
||||
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from email.message import Message
|
||||
import json
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
APP=ROOT/"active-runtime/app"
|
||||
sys.path.insert(0,str(APP))
|
||||
|
||||
import aios_gpt_https_edge as edge
|
||||
|
||||
|
||||
class Headers(dict):
|
||||
def get(self,key,default=None):
|
||||
return super().get(key,default)
|
||||
|
||||
|
||||
def test_default_and_public_bind_require_explicit_owner_approval():
|
||||
assert edge.public_bind_allowed("127.0.0.1","0")=="127.0.0.1"
|
||||
assert edge.public_bind_allowed("::1","0")=="::1"
|
||||
with pytest.raises(RuntimeError,match="OWNER_APPROVAL_REQUIRED"):
|
||||
edge.public_bind_allowed("0.0.0.0","0")
|
||||
with pytest.raises(RuntimeError,match="OWNER_APPROVAL_REQUIRED"):
|
||||
edge.public_bind_allowed("::","")
|
||||
assert edge.public_bind_allowed("0.0.0.0","1")=="0.0.0.0"
|
||||
|
||||
|
||||
def test_arbitrary_public_bind_address_is_denied_even_with_approval():
|
||||
with pytest.raises(RuntimeError,match="BIND_ADDRESS_INVALID"):
|
||||
edge.public_bind_allowed("192.0.2.10","1")
|
||||
|
||||
|
||||
def test_public_domain_validation():
|
||||
assert edge.validated_public_domain("tasks.example.com")=="tasks.example.com"
|
||||
assert edge.validated_public_domain("")==""
|
||||
with pytest.raises(RuntimeError,match="PUBLIC_DOMAIN_INVALID"):
|
||||
edge.validated_public_domain("https://example.com")
|
||||
with pytest.raises(RuntimeError,match="PUBLIC_DOMAIN_INVALID"):
|
||||
edge.validated_public_domain("localhost")
|
||||
|
||||
|
||||
def test_route_allowlist_accepts_only_readonly_api():
|
||||
assert edge.allowed_route("POST","/v1/read-only-tasks")=="/v1/read-only-tasks"
|
||||
assert edge.allowed_route("GET","/v1/read-only-tasks/external-request-0001")==(
|
||||
"/v1/read-only-tasks/external-request-0001"
|
||||
)
|
||||
assert edge.allowed_route("GET","/health")=="/health"
|
||||
assert edge.allowed_route("GET","/openapi.json")=="/openapi.json"
|
||||
for method,path in [
|
||||
("POST","/v2/governed-task"),
|
||||
("GET","/v1/tasks/x"),
|
||||
("POST","/v1/read-only-tasks/x"),
|
||||
("DELETE","/v1/read-only-tasks/x"),
|
||||
]:
|
||||
with pytest.raises(edge.EdgeError,match="ROUTE_DENY"):
|
||||
edge.allowed_route(method,path)
|
||||
|
||||
|
||||
def test_query_and_fragment_are_denied():
|
||||
with pytest.raises(edge.EdgeError,match="QUERY_OR_FRAGMENT_DENY"):
|
||||
edge.allowed_route("GET","/health?x=1")
|
||||
with pytest.raises(edge.EdgeError,match="QUERY_OR_FRAGMENT_DENY"):
|
||||
edge.allowed_route("GET","/openapi.json#x")
|
||||
|
||||
|
||||
def test_forwarded_headers_are_minimal_and_require_bearer():
|
||||
incoming=Headers({
|
||||
"Authorization":"Bearer "+"a"*40,
|
||||
"Content-Type":"application/json; charset=utf-8",
|
||||
"Cookie":"secret=1",
|
||||
"X-Forwarded-For":"203.0.113.10",
|
||||
"X-Api-Key":"do-not-forward",
|
||||
})
|
||||
forwarded=edge.forwarded_headers(incoming,body=True)
|
||||
assert forwarded=={
|
||||
"Accept":"application/json",
|
||||
"Authorization":"Bearer "+"a"*40,
|
||||
"Content-Type":"application/json",
|
||||
}
|
||||
with pytest.raises(edge.EdgeError,match="AUTHORIZATION_REQUIRED"):
|
||||
edge.forwarded_headers(Headers({}),body=False)
|
||||
|
||||
|
||||
def test_invalid_content_type_is_denied():
|
||||
with pytest.raises(edge.EdgeError,match="CONTENT_TYPE_INVALID"):
|
||||
edge.forwarded_headers(
|
||||
Headers({
|
||||
"Authorization":"Bearer "+"a"*40,
|
||||
"Content-Type":"text/plain",
|
||||
}),
|
||||
body=True,
|
||||
)
|
||||
|
||||
|
||||
def test_rate_limiter_caps_window_and_allows_expiry():
|
||||
now=[100.0]
|
||||
limiter=edge.RateLimiter(window_seconds=60,limit=2,burst=1,now_fn=lambda:now[0])
|
||||
assert limiter.allowed("client")
|
||||
assert limiter.allowed("client")
|
||||
assert limiter.allowed("client")
|
||||
assert not limiter.allowed("client")
|
||||
now[0]=161.0
|
||||
assert limiter.allowed("client")
|
||||
|
||||
|
||||
|
||||
def test_rate_limiter_client_table_is_bounded():
|
||||
now=[100.0]
|
||||
limiter=edge.RateLimiter(
|
||||
window_seconds=60,
|
||||
limit=10,
|
||||
burst=0,
|
||||
max_clients=2,
|
||||
now_fn=lambda:now[0],
|
||||
)
|
||||
assert limiter.allowed("a")
|
||||
assert limiter.allowed("b")
|
||||
assert not limiter.allowed("c")
|
||||
now[0]=161.0
|
||||
assert limiter.allowed("c")
|
||||
assert len(limiter._events) <= 2
|
||||
|
||||
|
||||
def test_audit_source_does_not_log_sensitive_request_material():
|
||||
source=(ROOT/"active-runtime/app/aios_gpt_https_edge.py").read_text()
|
||||
assert '"authorization", "body", "token", "cookie", "client_ip"' in source
|
||||
assert "_client_pseudonym" in source
|
||||
|
||||
def test_proxy_upstream_is_fixed():
|
||||
proxy=edge.Proxy()
|
||||
assert proxy.upstream=="http://127.0.0.1:18945"
|
||||
bad=edge.Proxy(upstream="http://127.0.0.1:18892")
|
||||
with pytest.raises(edge.EdgeError,match="UPSTREAM_DRIFT_DENY"):
|
||||
bad.request("GET","/health",{"Authorization":"Bearer "+"a"*40},None)
|
||||
|
||||
|
||||
|
||||
def test_public_bind_requires_approved_domain():
|
||||
assert edge.validated_edge_identity("127.0.0.1","0","")==("127.0.0.1","")
|
||||
with pytest.raises(RuntimeError,match="PUBLIC_DOMAIN_REQUIRED"):
|
||||
edge.validated_edge_identity("0.0.0.0","1","")
|
||||
assert edge.validated_edge_identity(
|
||||
"0.0.0.0","1","tasks.example.com"
|
||||
)==("0.0.0.0","tasks.example.com")
|
||||
|
||||
def test_tls_key_requires_no_group_or_other_permissions(tmp_path):
|
||||
key=tmp_path/"key.pem"
|
||||
key.write_text("x"*64,encoding="utf-8")
|
||||
key.chmod(0o600)
|
||||
assert edge.validate_tls_file(key,key=True)==key
|
||||
key.chmod(0o644)
|
||||
with pytest.raises(RuntimeError,match="TLS_KEY_MODE_INVALID"):
|
||||
edge.validate_tls_file(key,key=True)
|
||||
|
||||
|
||||
def test_tls_symlink_is_denied(tmp_path):
|
||||
real=tmp_path/"cert.pem"
|
||||
real.write_text("x"*64,encoding="utf-8")
|
||||
link=tmp_path/"link.pem"
|
||||
link.symlink_to(real)
|
||||
with pytest.raises(RuntimeError,match="TLS_FILE_INVALID"):
|
||||
edge.validate_tls_file(link,key=False)
|
||||
|
||||
|
||||
def test_systemd_unit_is_disabled_by_owner_approval_condition_and_loopback_default():
|
||||
unit=(ROOT/"deploy/systemd/aios-gpt-https-edge.service").read_text()
|
||||
assert "ConditionPathExists=/etc/aios-gitops/gpt-https-edge.approved" in unit
|
||||
assert "AIOS_GPT_EDGE_HOST=127.0.0.1" in unit
|
||||
assert "AIOS_GPT_EDGE_PUBLIC_BIND_APPROVED=0" in unit
|
||||
assert "AIOS_GPT_EDGE_PORT=18443" in unit
|
||||
assert "LoadCredential=tls_cert:" in unit
|
||||
assert "LoadCredential=tls_key:" in unit
|
||||
assert "InaccessiblePaths=/data/aios-gitops" in unit
|
||||
assert "InaccessiblePaths=" in unit and "/var/lib/aios-agent-task" in unit
|
||||
assert "NoNewPrivileges=yes" in unit
|
||||
assert "ProtectSystem=strict" in unit
|
||||
|
||||
|
||||
def test_systemd_unit_does_not_reference_internal_control_credentials():
|
||||
unit=(ROOT/"deploy/systemd/aios-gpt-https-edge.service").read_text()
|
||||
assert "agent_task_control" not in unit
|
||||
assert "gitea" not in unit.lower()
|
||||
assert "18892" not in unit
|
||||
assert "18940" not in unit
|
||||
|
||||
|
||||
def test_edge_source_does_not_proxy_internal_bridge_or_task_api():
|
||||
source=(ROOT/"active-runtime/app/aios_gpt_https_edge.py").read_text()
|
||||
assert 'UPSTREAM = "http://127.0.0.1:18945"' in source
|
||||
assert "18892" not in source
|
||||
assert "18940" not in source
|
||||
assert "/v2/governed-task" not in source
|
||||
assert "/v1/tasks/" not in source
|
||||
|
||||
|
||||
def test_docs_keep_public_activation_separately_approved():
|
||||
doc=(ROOT/"docs/superpowers/specs/2026-08-19-gpt-https-edge.md").read_text()
|
||||
assert "explicit owner approval" in doc.lower()
|
||||
assert "PUBLIC_NETWORK_EXPOSURE=NONE" in doc
|
||||
assert "PUBLIC_CUSTOM_GPT_DIRECT_AI0S_ACCESS=NOT_VERIFIED" in doc
|
||||
@@ -0,0 +1,342 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
APP=ROOT/"active-runtime/app"
|
||||
sys.path.insert(0,str(APP))
|
||||
|
||||
from aios_gpt_online_task_ingress import (
|
||||
IngressError,
|
||||
IngressService,
|
||||
Store,
|
||||
READONLY_CAPABILITIES,
|
||||
branch_for,
|
||||
normalized_request,
|
||||
openapi_document,
|
||||
task_id_for,
|
||||
validated_bind_host,
|
||||
)
|
||||
|
||||
|
||||
MAIN="3244f0f17f8845592ac94d585f5ebd65693c9f29"
|
||||
JOB="job-0123456789abcdef0123456789abcdef"
|
||||
|
||||
|
||||
def request(request_id="external-request-0001", **overrides):
|
||||
value={
|
||||
"schema":"aios.gpt-online-task-ingress.request.v1",
|
||||
"request_id":request_id,
|
||||
"goal":"Inspect the repository for durable worker state.",
|
||||
"instructions":"Read only. Return a concise source analysis.",
|
||||
"success_criteria":[
|
||||
{"id":"analysis","description":"Return a source-backed analysis."}
|
||||
],
|
||||
"constraints":{
|
||||
"max_files":50,
|
||||
"focus_paths":["active-runtime/app","tests"],
|
||||
"notes":"Do not mutate repository state.",
|
||||
},
|
||||
}
|
||||
value.update(overrides)
|
||||
return value
|
||||
|
||||
|
||||
class FakeUpstream:
|
||||
def __init__(self):
|
||||
self.created=[]
|
||||
self.projected=[]
|
||||
self.tasks={}
|
||||
self.snapshot={"authoritative_head_oid":MAIN}
|
||||
|
||||
def control_snapshot(self):
|
||||
return dict(self.snapshot)
|
||||
|
||||
def create_governed_task(self, body):
|
||||
self.created.append(dict(body))
|
||||
task_id=body["task_id"]
|
||||
job_id=JOB
|
||||
self.tasks[task_id]={
|
||||
"task_id":task_id,
|
||||
"status":"PROJECTED",
|
||||
"attempt":0,
|
||||
"metadata":{
|
||||
"execution":{
|
||||
"task_id":task_id,
|
||||
"job_id":job_id,
|
||||
"base_oid":body["base_oid"],
|
||||
"action":"source_capture",
|
||||
"capability_scope":list(READONLY_CAPABILITIES),
|
||||
"governance":{"writer_lease":False},
|
||||
}
|
||||
},
|
||||
}
|
||||
return 201,{"task_id":task_id,"job_id":job_id,"state":"BOUND"}
|
||||
|
||||
def execution_envelope(self, body):
|
||||
self.projected.append(dict(body))
|
||||
task_id=body["task_id"]
|
||||
return 200,{
|
||||
"schema":"aios.online-agent.execution.v2",
|
||||
"task_id":task_id,
|
||||
"job_id":body["job_id"],
|
||||
"repository":"aios/aios",
|
||||
"base_oid":MAIN,
|
||||
"action":"source_capture",
|
||||
"capability_scope":list(READONLY_CAPABILITIES),
|
||||
"governance":{"writer_lease":False},
|
||||
}
|
||||
|
||||
def get_task(self, task_id):
|
||||
value=self.tasks.get(task_id)
|
||||
return (404,{}) if value is None else (200,dict(value))
|
||||
|
||||
|
||||
def service(tmp_path):
|
||||
upstream=FakeUpstream()
|
||||
store=Store(tmp_path/"state.sqlite3")
|
||||
return IngressService(upstream,store),upstream,store
|
||||
|
||||
|
||||
def test_request_schema_is_narrow_and_rejects_capability_or_execution_control():
|
||||
value=normalized_request(request())
|
||||
assert value["goal"].startswith("Inspect")
|
||||
for field in ("capability_scope","required_capabilities","action","adapter","repository","base_oid"):
|
||||
bad=request()
|
||||
bad[field]="evil"
|
||||
with pytest.raises(IngressError,match="UNKNOWN_FIELDS"):
|
||||
normalized_request(bad)
|
||||
|
||||
|
||||
def test_constraints_are_bounded_and_path_traversal_is_denied():
|
||||
bad=request(constraints={"focus_paths":["../etc/passwd"]})
|
||||
with pytest.raises(IngressError,match="FOCUS_PATHS_INVALID"):
|
||||
normalized_request(bad)
|
||||
bad=request(constraints={"max_files":1000})
|
||||
with pytest.raises(IngressError,match="MAX_FILES_INVALID"):
|
||||
normalized_request(bad)
|
||||
|
||||
|
||||
def test_non_loopback_bind_is_denied():
|
||||
assert validated_bind_host("127.0.0.1")=="127.0.0.1"
|
||||
with pytest.raises(RuntimeError,match="GPT_INGRESS_NON_LOOPBACK_BIND_DENY"):
|
||||
validated_bind_host("0.0.0.0")
|
||||
with pytest.raises(RuntimeError,match="GPT_INGRESS_NON_LOOPBACK_BIND_DENY"):
|
||||
validated_bind_host("192.0.2.10")
|
||||
|
||||
|
||||
def test_task_and_branch_ids_are_deterministic_and_bounded():
|
||||
a=task_id_for("external-request-0001")
|
||||
b=task_id_for("external-request-0001")
|
||||
c=task_id_for("external-request-0002")
|
||||
assert a==b
|
||||
assert a!=c
|
||||
assert a.startswith("gpt-readonly-")
|
||||
branch=branch_for(a)
|
||||
assert branch.startswith("agent/external-gpt-readonly/")
|
||||
assert len(a)<180
|
||||
|
||||
|
||||
def test_create_translates_to_canonical_readonly_governed_task(tmp_path):
|
||||
svc,upstream,store=service(tmp_path)
|
||||
response=svc.create(request())
|
||||
assert response["state"]=="BOUND"
|
||||
assert response["write_access"] is False
|
||||
assert response["official_verification_required"] is True
|
||||
assert response["capabilities"]==list(READONLY_CAPABILITIES)
|
||||
|
||||
assert len(upstream.created)==1
|
||||
body=upstream.created[0]
|
||||
assert body["repository"]=="aios/aios"
|
||||
assert body["base_oid"]==MAIN
|
||||
assert body["expected_branch_oid"]==MAIN
|
||||
assert body["branch_mode"]=="CREATE_NEW"
|
||||
assert body["risk"]=="low"
|
||||
assert body["priority"]==0
|
||||
assert body["max_attempts"]==3
|
||||
assert body["require_online_task_projection"] is True
|
||||
assert body["online_task"]["required_capabilities"]==list(READONLY_CAPABILITIES)
|
||||
assert body["online_task"]["constraints"]["repository_mutation"]=="forbidden"
|
||||
assert body["online_task"]["constraints"]["credential_access"]=="forbidden"
|
||||
assert body["execution"]["action"]=="source_capture"
|
||||
assert body["execution"]["adapter"]=="source_capture"
|
||||
assert body["execution"]["capability_scope"]==list(READONLY_CAPABILITIES)
|
||||
assert "writer_lease" not in body["execution"]
|
||||
|
||||
assert len(upstream.projected)==1
|
||||
projection=upstream.projected[0]
|
||||
assert projection["task_id"]==response["task_id"]
|
||||
assert projection["job_id"]==JOB
|
||||
assert projection["required_capabilities"]==list(READONLY_CAPABILITIES)
|
||||
|
||||
row=store.get("external-request-0001")
|
||||
assert row is not None
|
||||
assert row["state"]=="BOUND"
|
||||
assert row["job_id"]==JOB
|
||||
assert row["base_oid"]==MAIN
|
||||
|
||||
|
||||
def test_exact_retry_is_idempotent_without_duplicate_bridge_call(tmp_path):
|
||||
svc,upstream,_=service(tmp_path)
|
||||
first=svc.create(request())
|
||||
second=svc.create(request())
|
||||
assert second==first
|
||||
assert len(upstream.created)==1
|
||||
assert len(upstream.projected)==1
|
||||
|
||||
|
||||
def test_same_request_id_with_different_content_is_conflict(tmp_path):
|
||||
svc,upstream,_=service(tmp_path)
|
||||
svc.create(request())
|
||||
with pytest.raises(IngressError,match="IDEMPOTENCY_CONFLICT"):
|
||||
svc.create(request(goal="Different goal."))
|
||||
assert len(upstream.created)==1
|
||||
|
||||
|
||||
def test_pending_request_recovers_existing_canonical_task_after_crash(tmp_path):
|
||||
svc,upstream,store=service(tmp_path)
|
||||
raw=normalized_request(request())
|
||||
task_id=task_id_for(raw["request_id"])
|
||||
store.reserve(raw,task_id)
|
||||
|
||||
upstream.tasks[task_id]={
|
||||
"task_id":task_id,
|
||||
"status":"PROJECTED",
|
||||
"attempt":0,
|
||||
"metadata":{
|
||||
"execution":{
|
||||
"task_id":task_id,
|
||||
"job_id":JOB,
|
||||
"base_oid":MAIN,
|
||||
"action":"source_capture",
|
||||
"capability_scope":list(READONLY_CAPABILITIES),
|
||||
"governance":{"writer_lease":False},
|
||||
}
|
||||
},
|
||||
}
|
||||
result=svc.create(request())
|
||||
assert result["job_id"]==JOB
|
||||
assert result["task_id"]==task_id
|
||||
assert len(upstream.created)==0
|
||||
row=store.get(raw["request_id"])
|
||||
assert row["state"]=="BOUND"
|
||||
|
||||
|
||||
def test_status_is_sanitized_and_never_proxies_raw_execution_or_result_text(tmp_path):
|
||||
svc,upstream,store=service(tmp_path)
|
||||
created=svc.create(request())
|
||||
task_id=created["task_id"]
|
||||
upstream.tasks[task_id]={
|
||||
"task_id":task_id,
|
||||
"status":"SUCCEEDED",
|
||||
"attempt":1,
|
||||
"claimed_by":"online-engineering-worker-cn-network-mkt-generic-20260815",
|
||||
"metadata":{
|
||||
"execution":{
|
||||
"job_id":JOB,
|
||||
"base_oid":MAIN,
|
||||
"action":"source_capture",
|
||||
"capability_scope":list(READONLY_CAPABILITIES),
|
||||
"governance":{"writer_lease":False},
|
||||
"secret_marker":"do-not-return",
|
||||
}
|
||||
},
|
||||
"result":{
|
||||
"status":"SUCCEEDED",
|
||||
"classification":"NEEDS_REWORK_SOURCE_CAPTURE",
|
||||
"execution_mode":"source_capture",
|
||||
"output_text":"raw source text that must not be proxied",
|
||||
"source_snapshot":{
|
||||
"payload_sha256":"a"*64,
|
||||
"file_count":42,
|
||||
"files":["secret/path"],
|
||||
},
|
||||
},
|
||||
"verification":{
|
||||
"decision":"SUCCEEDED",
|
||||
"classification":"GENERIC_ONLINE_TASK_AUTO_CONSUMPTION_VERIFIED",
|
||||
"private":"do-not-return",
|
||||
},
|
||||
}
|
||||
status=svc.status("external-request-0001")
|
||||
assert status["state"]=="SUCCEEDED"
|
||||
assert status["attempt"]==1
|
||||
assert status["claimed"] is True
|
||||
assert status["official_verification"]=="SUCCEEDED"
|
||||
assert status["source_snapshot_sha256"]=="a"*64
|
||||
assert status["source_snapshot_file_count"]==42
|
||||
serialized=json.dumps(status)
|
||||
assert "raw source text" not in serialized
|
||||
assert "secret_marker" not in serialized
|
||||
assert "secret/path" not in serialized
|
||||
assert "do-not-return" not in serialized
|
||||
|
||||
|
||||
def test_pending_status_can_recover_job_binding(tmp_path):
|
||||
svc,upstream,store=service(tmp_path)
|
||||
raw=normalized_request(request())
|
||||
task_id=task_id_for(raw["request_id"])
|
||||
store.reserve(raw,task_id)
|
||||
upstream.tasks[task_id]={
|
||||
"task_id":task_id,
|
||||
"status":"PROJECTED",
|
||||
"attempt":0,
|
||||
"metadata":{
|
||||
"execution":{
|
||||
"job_id":JOB,
|
||||
"base_oid":MAIN,
|
||||
"action":"source_capture",
|
||||
"capability_scope":list(READONLY_CAPABILITIES),
|
||||
"governance":{"writer_lease":False},
|
||||
}
|
||||
},
|
||||
}
|
||||
value=svc.status(raw["request_id"])
|
||||
assert value["job_id"]==JOB
|
||||
assert store.get(raw["request_id"])["state"]=="BOUND"
|
||||
|
||||
|
||||
def test_openapi_exposes_only_create_readonly_and_status_operations():
|
||||
doc=openapi_document()
|
||||
assert doc["openapi"]=="3.1.0"
|
||||
paths=doc["paths"]
|
||||
assert set(paths)=={
|
||||
"/v1/read-only-tasks",
|
||||
"/v1/read-only-tasks/{request_id}",
|
||||
}
|
||||
rendered=json.dumps(doc)
|
||||
for forbidden in ("writer-lease","commit","verify","production","tag","baseline"):
|
||||
assert forbidden not in rendered.lower()
|
||||
|
||||
|
||||
def test_systemd_unit_is_loopback_credential_isolated_and_has_no_repository_access():
|
||||
unit=(ROOT/"deploy/systemd/aios-gpt-online-task-ingress.service").read_text()
|
||||
assert "DynamicUser=yes" in unit
|
||||
assert "LoadCredential=gpt_ingress_token:" in unit
|
||||
assert "LoadCredential=agent_task_control_token:" in unit
|
||||
assert "AIOS_GPT_INGRESS_HOST=127.0.0.1" in unit
|
||||
assert "AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892" in unit
|
||||
assert "AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940" in unit
|
||||
assert "IPAddressDeny=any" in unit
|
||||
assert "IPAddressAllow=127.0.0.0/8" in unit
|
||||
assert "InaccessiblePaths=/data/aios-gitops /etc/aios-gitops /home" in unit
|
||||
assert "ProtectSystem=strict" in unit
|
||||
assert "NoNewPrivileges=yes" in unit
|
||||
assert "MemoryDenyWriteExecute=yes" in unit
|
||||
assert "0.0.0.0" not in unit
|
||||
|
||||
|
||||
def test_ingress_sqlite_contains_no_request_body_or_credentials(tmp_path):
|
||||
svc,upstream,store=service(tmp_path)
|
||||
svc.create(request(instructions="sensitive-looking but non-secret user text"))
|
||||
con=sqlite3.connect(store.path)
|
||||
columns=[row[1] for row in con.execute("pragma table_info(external_task_requests)")]
|
||||
assert "request_json" not in columns
|
||||
assert "goal" not in columns
|
||||
assert "instructions" not in columns
|
||||
assert "token" not in " ".join(columns).lower()
|
||||
Reference in New Issue
Block a user