Compare commits
17
Commits
@@ -9,6 +9,8 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
import base64
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
@@ -58,6 +60,82 @@ class AuthorityGitExecutor:
|
||||
value = self._git(["rev-parse", "--verify", ref], check=False)
|
||||
return value if value and OID_RE.fullmatch(value) else None
|
||||
|
||||
@staticmethod
|
||||
def _read_path(path: str) -> str:
|
||||
if not isinstance(path, str) or not path or len(path) > 512:
|
||||
raise AuthorityGitError("SOURCE_PATH_INVALID")
|
||||
if "\\" in path or "\x00" in path:
|
||||
raise AuthorityGitError("SOURCE_PATH_INVALID")
|
||||
parts = Path(path).parts
|
||||
if not parts or path.startswith("/") or ".." in parts or ".git" in parts:
|
||||
raise AuthorityGitError("SOURCE_PATH_INVALID")
|
||||
return "/".join(parts)
|
||||
|
||||
def _read_revision(self, ref: str) -> str:
|
||||
if not isinstance(ref, str) or not ref or len(ref) > 256 or "\x00" in ref:
|
||||
raise AuthorityGitError("SOURCE_REF_INVALID")
|
||||
if OID_RE.fullmatch(ref):
|
||||
if not self.commit_exists(ref):
|
||||
raise AuthorityGitError("SOURCE_REF_NOT_FOUND")
|
||||
return ref
|
||||
if ref == "main":
|
||||
ref = "refs/heads/main"
|
||||
elif ref.startswith("agent/"):
|
||||
ref = "refs/heads/" + ref
|
||||
elif ref.startswith("refs/heads/agent/"):
|
||||
pass
|
||||
elif ref == "V2R2C1":
|
||||
ref = "refs/tags/V2R2C1"
|
||||
elif ref.startswith("refs/tags/V"):
|
||||
pass
|
||||
else:
|
||||
raise AuthorityGitError("SOURCE_REF_SCOPE_DENIED")
|
||||
if ".." in ref or not re.fullmatch(r"refs/(heads/(?:agent/[A-Za-z0-9._/-]+|main)|tags/V[0-9]+R[0-9]+C[0-9]+)", ref):
|
||||
raise AuthorityGitError("SOURCE_REF_SCOPE_DENIED")
|
||||
value = self._git(["rev-parse", "--verify", ref + "^{commit}"], check=False)
|
||||
if not OID_RE.fullmatch(value):
|
||||
raise AuthorityGitError("SOURCE_REF_NOT_FOUND")
|
||||
return value
|
||||
|
||||
def read_file(self, ref: str, path: str, max_bytes: int) -> dict[str, object]:
|
||||
if not isinstance(max_bytes, int) or isinstance(max_bytes, bool) or not 1 <= max_bytes <= 262_144:
|
||||
raise AuthorityGitError("SOURCE_MAX_BYTES_INVALID")
|
||||
revision = self._read_revision(ref)
|
||||
relative = self._read_path(path)
|
||||
spec = f"{revision}:{relative}"
|
||||
size_result = subprocess.run(
|
||||
["git", f"--git-dir={self.git_dir}", "cat-file", "-s", spec],
|
||||
text=True, capture_output=True, check=False,
|
||||
)
|
||||
if size_result.returncode != 0:
|
||||
raise AuthorityGitError("SOURCE_FILE_NOT_FOUND")
|
||||
try:
|
||||
size = int(size_result.stdout.strip())
|
||||
except ValueError as exc:
|
||||
raise AuthorityGitError("SOURCE_FILE_READ_FAILED") from exc
|
||||
if size > max_bytes:
|
||||
raise AuthorityGitError("SOURCE_FILE_TOO_LARGE")
|
||||
result = subprocess.run(
|
||||
["git", f"--git-dir={self.git_dir}", "show", spec],
|
||||
capture_output=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise AuthorityGitError("SOURCE_FILE_NOT_FOUND")
|
||||
content = result.stdout
|
||||
if len(content) != size:
|
||||
raise AuthorityGitError("SOURCE_FILE_READ_FAILED")
|
||||
return {
|
||||
"repository": "aios/aios",
|
||||
"ref": ref,
|
||||
"resolved_commit_oid": revision,
|
||||
"path": relative,
|
||||
"size_bytes": size,
|
||||
"sha256": hashlib.sha256(content).hexdigest(),
|
||||
"content_base64": base64.b64encode(content).decode("ascii"),
|
||||
"truncated": False,
|
||||
"read_only": True,
|
||||
}
|
||||
|
||||
def commit_exists(self, oid: str) -> bool:
|
||||
_oid(oid, "commit_oid")
|
||||
result = subprocess.run(["git", f"--git-dir={self.git_dir}", "cat-file", "-e", oid + "^{commit}"], capture_output=True, check=False)
|
||||
|
||||
@@ -11,6 +11,7 @@ BRIDGE_SCHEMA_VERSION = "aios.chatgpt-gitops-bridge.v2"
|
||||
BRIDGE_OPERATIONS = (
|
||||
"getRuntimeStatus",
|
||||
"getGovernedWriteCapabilities",
|
||||
"readRepositoryFile",
|
||||
"createGovernedTask",
|
||||
"acquireWriterLease",
|
||||
"commitFileGoverned",
|
||||
@@ -44,6 +45,7 @@ OPERATION_REQUIRED_FIELDS = {
|
||||
"getReleaseConsistency": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id"),
|
||||
"runReleaseAcceptance": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id"),
|
||||
"getControlPlaneSnapshot": (),
|
||||
"readRepositoryFile": ("repository", "ref", "path", "max_bytes"),
|
||||
}
|
||||
|
||||
_OPERATION_PATHS = {
|
||||
@@ -57,6 +59,7 @@ _OPERATION_PATHS = {
|
||||
"getTagPublicationStatus": "/v2/tag/status",
|
||||
"getReleaseConsistency": "/v2/release/consistency",
|
||||
"runReleaseAcceptance": "/v2/release/acceptance",
|
||||
"readRepositoryFile": "/v2/source/file",
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +85,7 @@ def render_openapi_contract_fragment() -> str:
|
||||
" post:\n"
|
||||
" operationId: commitFilesGoverned\n"
|
||||
" summary: Submit one atomic governed commit containing multiple files\n"
|
||||
" security: [{bearerAuth: []}]\n"
|
||||
" requestBody:\n"
|
||||
" required: true\n"
|
||||
" content:\n"
|
||||
@@ -97,10 +101,11 @@ def render_openapi_contract_fragment() -> str:
|
||||
" expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}\n"
|
||||
" target_branch: {type: string, pattern: '^agent/[A-Za-z0-9._/-]+$'}\n"
|
||||
" generation: {type: integer, minimum: 1}\n"
|
||||
" idempotency_key: {type: string}\n"
|
||||
" idempotency_key: {type: string, minLength: 8, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$'}\n"
|
||||
" files:\n"
|
||||
" type: array\n"
|
||||
" minItems: 1\n"
|
||||
" maxItems: 50\n"
|
||||
" items:\n"
|
||||
" type: object\n"
|
||||
" additionalProperties: false\n"
|
||||
@@ -113,6 +118,7 @@ def render_openapi_contract_fragment() -> str:
|
||||
" '200': {description: Governed commit job and verified branch result}\n"
|
||||
" '400': {description: Contract or policy rejection}\n"
|
||||
" '409': {description: Branch CAS or idempotency conflict}\n"
|
||||
" x-openai-isConsequential: true\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -223,6 +229,7 @@ class BridgeV2Adapter:
|
||||
def acquireWriterLease(self, request: Mapping[str, Any]) -> Any: return self._call("acquireWriterLease", request)
|
||||
def commitFileGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("commitFileGoverned", request)
|
||||
def commitFilesGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("commitFilesGoverned", request)
|
||||
def readRepositoryFile(self, request: Mapping[str, Any]) -> Any: return self._call("readRepositoryFile", request)
|
||||
def getGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("getGitOpsJob", request)
|
||||
def resumeGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("resumeGitOpsJob", request)
|
||||
def reconcileGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("reconcileGitOpsJob", request)
|
||||
|
||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
@@ -17,12 +19,14 @@ from aios_chatgpt_bridge_v2 import BridgeV2Adapter, BridgeV2Error, OPERATION_REQ
|
||||
from gitea_tag_publication import validate_gitea_api_url
|
||||
from aios_continuation_executor import AuthorityGitAdapter, ContinuationCommit, ContinuationExecutor, ContinuationPermissions, ContinuationRuntimeBinding, DetachedGitWorkspace, GiteaContinuationRemote, ServerSideCredentialProjection
|
||||
from aios_continuation_lease import WriterLeaseRequestV2
|
||||
from scoped_lease_store import ScopedLeaseError, ScopedLeaseStore
|
||||
from aios_control_plane_snapshot import build_control_plane_snapshot
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobState, JobStore, _RECOVERABLE
|
||||
from aios_governed_task_bootstrap import BootstrapError, GovernedTaskBootstrap
|
||||
from governed_production_release import build_executor_config
|
||||
|
||||
MAX_BODY = 2 * 1024 * 1024
|
||||
MAX_SOURCE_BYTES = 262_144
|
||||
ROUTES = {
|
||||
"/v2/runtime": "getRuntimeStatus",
|
||||
"/v2/capabilities": "getGovernedWriteCapabilities",
|
||||
@@ -30,6 +34,7 @@ ROUTES = {
|
||||
"/v2/writer-lease": "acquireWriterLease",
|
||||
"/v2/commit-file": "commitFileGoverned",
|
||||
"/v2/commit-files": "commitFilesGoverned",
|
||||
"/v2/source/file": "readRepositoryFile",
|
||||
"/v2/job": "getGitOpsJob",
|
||||
"/v2/job/resume": "resumeGitOpsJob",
|
||||
"/v2/job/reconcile": "reconcileGitOpsJob",
|
||||
@@ -75,6 +80,7 @@ class GovernedRuntime:
|
||||
self.runtimes: dict[tuple[str, int], tuple[Any, GitOpsJobController, ContinuationExecutor]] = {}
|
||||
self.controllers: dict[str, GitOpsJobController] = {}
|
||||
self.bindings: dict[str, dict[str, Any]] = {}
|
||||
self.leases = ScopedLeaseStore(config.state_root / "scoped-leases.sqlite3")
|
||||
self.release = build_executor_config(config)
|
||||
metadata_root = config.state_root / "task-bindings"
|
||||
if metadata_root.is_dir():
|
||||
@@ -152,13 +158,99 @@ class GovernedRuntime:
|
||||
binding = ContinuationRuntimeBinding(request=lease, workspace_path=worktree_path, lease_not_before=datetime.now(timezone.utc) - timedelta(seconds=1), lease_expires_at=datetime.now(timezone.utc) + timedelta(seconds=lease.lease_seconds), original_receipt_valid=True, original_grant_consumed=True, permissions=ContinuationPermissions(), remote_branch_create_allowed=original_branch_mode == "CREATE_NEW")
|
||||
result = (binding, controller, executor); self.runtimes[key] = result; self.controllers[lease.task_id] = controller; return result
|
||||
|
||||
def _validate_task_binding(self, request: Mapping[str, Any]) -> None:
|
||||
stored = self.bindings.get(str(request.get("task_id")))
|
||||
if not stored:
|
||||
raise RuntimeError("TASK_BINDING_NOT_FOUND")
|
||||
if (
|
||||
str(request.get("repository")) != str(stored.get("repository"))
|
||||
or str(request.get("target_branch")) != str(stored.get("target_branch"))
|
||||
or str(request.get("base_oid")) != str(stored.get("base_oid"))
|
||||
):
|
||||
raise RuntimeError("TASK_LEASE_BINDING_CONFLICT")
|
||||
if str(request.get("branch_mode")) != "CONTINUE_EXISTING":
|
||||
raise RuntimeError("TASK_BRANCH_MODE_INVALID")
|
||||
try:
|
||||
generation = int(request.get("generation"))
|
||||
stored_generation = int(stored.get("generation") or 1)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RuntimeError("TASK_LEASE_BINDING_CONFLICT") from exc
|
||||
if generation < stored_generation:
|
||||
raise RuntimeError("TASK_GENERATION_STALE")
|
||||
current = self.authority.read_ref("refs/heads/" + str(request["target_branch"]))
|
||||
if current != str(request.get("expected_branch_oid")):
|
||||
raise RuntimeError("LEASE_EXPECTED_BRANCH_OID_STALE")
|
||||
if not self.authority.ancestry_check(str(stored.get("expected_branch_oid")), str(request["expected_branch_oid"])):
|
||||
raise RuntimeError("TASK_LEASE_LINEAGE_INVALID")
|
||||
|
||||
def create_task(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
result = self.bootstrap.create(request); self.bindings[result["task_id"]] = result; return result
|
||||
|
||||
def acquire_lease(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
binding, _, _ = self._runtime(request)
|
||||
if os.environ.get("AIOS_GITOPS_WRITES_ENABLED", "false").lower() not in {"1", "true", "yes", "on"}:
|
||||
raise RuntimeError("GITOPS_WRITES_DISABLED")
|
||||
self._validate_task_binding(request)
|
||||
try:
|
||||
record, created = self.leases.acquire(request)
|
||||
except ScopedLeaseError:
|
||||
raise
|
||||
if not created:
|
||||
if record["state"] == "EXPIRED":
|
||||
raise RuntimeError("LEASE_EXPIRED")
|
||||
record = {**record, "created": False}
|
||||
return self._lease_response(record["binding"], record)
|
||||
try:
|
||||
binding, _, _ = self._runtime(request)
|
||||
except Exception:
|
||||
# The lease cannot be usable if its detached runtime could not be
|
||||
# established. Leave no durable orphan behind.
|
||||
self.leases.delete(record["lease_key"])
|
||||
raise
|
||||
req = binding.request
|
||||
return {"schema": WriterLeaseRequestV2.schema, "task_id": req.task_id, "repository": req.repository, "branch_mode": req.branch_mode, "base_oid": req.base_oid, "expected_branch_oid": req.expected_branch_oid, "target_branch": req.target_branch, "generation": req.generation, "lease_seconds": req.lease_seconds, "create_target_branch": req.branch_mode == "CREATE_NEW", "worktree_mode": "detached_at_base_oid" if req.branch_mode == "CREATE_NEW" else "detached_at_expected_head", "cas_ref_update_required": True}
|
||||
return self._lease_response(
|
||||
{
|
||||
"schema": WriterLeaseRequestV2.schema,
|
||||
"task_id": req.task_id,
|
||||
"repository": req.repository,
|
||||
"branch_mode": req.branch_mode,
|
||||
"base_oid": req.base_oid,
|
||||
"expected_branch_oid": req.expected_branch_oid,
|
||||
"target_branch": req.target_branch,
|
||||
"generation": req.generation,
|
||||
"lease_seconds": req.lease_seconds,
|
||||
},
|
||||
{**record, "created": True},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _lease_response(request: Mapping[str, Any], record: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": WriterLeaseRequestV2.schema,
|
||||
"task_id": request["task_id"],
|
||||
"repository": request["repository"],
|
||||
"branch_mode": request["branch_mode"],
|
||||
"base_oid": request["base_oid"],
|
||||
"expected_branch_oid": request["expected_branch_oid"],
|
||||
"target_branch": request["target_branch"],
|
||||
"generation": int(request["generation"]),
|
||||
"lease_seconds": int(request["lease_seconds"]),
|
||||
"lease_key": record["lease_key"],
|
||||
"lease_state": record["state"],
|
||||
"lease_expires_at": record["expires_at"],
|
||||
"idempotent": not bool(record.get("created", True)),
|
||||
"create_target_branch": request["branch_mode"] == "CREATE_NEW",
|
||||
"worktree_mode": "detached_at_base_oid" if request["branch_mode"] == "CREATE_NEW" else "detached_at_expected_head",
|
||||
"cas_ref_update_required": True,
|
||||
}
|
||||
|
||||
def read_source_file(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
repository = str(request.get("repository") or "")
|
||||
if repository != self.config.repository:
|
||||
raise RuntimeError("REPOSITORY_NOT_ALLOWED")
|
||||
max_bytes = request.get("max_bytes")
|
||||
if not isinstance(max_bytes, int) or isinstance(max_bytes, bool) or not 1 <= max_bytes <= MAX_SOURCE_BYTES:
|
||||
raise RuntimeError("SOURCE_MAX_BYTES_INVALID")
|
||||
return self.authority.read_file(str(request.get("ref") or ""), str(request.get("path") or ""), max_bytes)
|
||||
|
||||
@staticmethod
|
||||
def _files(request: Mapping[str, Any]) -> dict[str, bytes]:
|
||||
@@ -167,13 +259,29 @@ class GovernedRuntime:
|
||||
return files
|
||||
|
||||
def commit_files(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if os.environ.get("AIOS_GITOPS_WRITES_ENABLED", "false").lower() not in {"1", "true", "yes", "on"}:
|
||||
raise RuntimeError("GITOPS_WRITES_DISABLED")
|
||||
self._validate_task_binding(request)
|
||||
record = self.leases.for_commit(request)
|
||||
if record is None:
|
||||
raise RuntimeError("LEASE_NOT_FOUND")
|
||||
if record["state"] == "EXPIRED":
|
||||
raise RuntimeError("LEASE_EXPIRED")
|
||||
if record["state"] == "USED":
|
||||
if record.get("idempotency_key") == str(request["idempotency_key"]):
|
||||
return dict(record["result"])
|
||||
raise RuntimeError("LEASE_ALREADY_USED")
|
||||
runtime = self.runtimes.get((str(request["task_id"]), int(request["generation"])))
|
||||
if runtime is None: raise RuntimeError("LEASE_NOT_FOUND")
|
||||
if runtime is None:
|
||||
runtime = self._runtime(record["binding"])
|
||||
binding, _, executor = runtime
|
||||
if request["target_branch"] != binding.request.target_branch or request["expected_branch_oid"] != binding.request.expected_branch_oid: raise RuntimeError("LEASE_BINDING_CONFLICT")
|
||||
if request["repository"] != binding.request.repository or request["target_branch"] != binding.request.target_branch or request["expected_branch_oid"] != binding.request.expected_branch_oid:
|
||||
raise RuntimeError("LEASE_BINDING_CONFLICT")
|
||||
commit = ContinuationCommit(idempotency_key=str(request["idempotency_key"]), files=self._files(request), message=str(request["message"]))
|
||||
executor.workspace.materialize_files(commit.files)
|
||||
return executor.execute(binding, commit)
|
||||
result = executor.execute(binding, commit)
|
||||
self.leases.mark_used(record["lease_key"], commit.idempotency_key, result)
|
||||
return result
|
||||
|
||||
def _controller_for_job(self, job_id: str) -> GitOpsJobController:
|
||||
for controller in self.controllers.values():
|
||||
@@ -372,6 +480,7 @@ class GovernedRuntime:
|
||||
"acquireWriterLease": self.acquire_lease,
|
||||
"commitFileGoverned": lambda request: self.commit_files({**request, "files": {request["path"]: request["content_base64"]}}),
|
||||
"commitFilesGoverned": self.commit_files,
|
||||
"readRepositoryFile": self.read_source_file,
|
||||
"getGitOpsJob": lambda request: self.job(request, "get"),
|
||||
"resumeGitOpsJob": lambda request: self.job(request, "resume"),
|
||||
"reconcileGitOpsJob": lambda request: self.job(request, "reconcile"),
|
||||
@@ -396,6 +505,7 @@ class _HTTPError(RuntimeError): pass
|
||||
|
||||
class BridgeHandler(BaseHTTPRequestHandler):
|
||||
adapter: BridgeV2Adapter | None = None
|
||||
action_token_path: Path | None = None
|
||||
def log_message(self, *_args: Any) -> None: return
|
||||
def _json(self, value: Any, status: int = 200) -> None:
|
||||
raw = json.dumps(value, sort_keys=True, separators=(",", ":")).encode(); self.send_response(status); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(raw))); self.end_headers(); self.wfile.write(raw)
|
||||
@@ -406,6 +516,43 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
||||
value = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
if not isinstance(value, dict): raise _HTTPError("JSON_OBJECT_REQUIRED")
|
||||
return value
|
||||
def _authorized(self, operation: str) -> bool:
|
||||
if operation not in {"readRepositoryFile", "acquireWriterLease", "commitFilesGoverned", "commitFileGoverned"}:
|
||||
return True
|
||||
header = self.headers.get("Authorization", "")
|
||||
token = header[7:].strip() if header.startswith("Bearer ") else self.headers.get("X-AIOS-Token", "")
|
||||
if not token or self.action_token_path is None or not self.action_token_path.is_file():
|
||||
self._json({"error": "UNAUTHORIZED", "hint": "Bearer token required"}, 401)
|
||||
return False
|
||||
digest = hashlib.sha256(token.encode()).hexdigest()
|
||||
try:
|
||||
records = self.action_token_path.read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
self._json({"error": "AUTHORITY_UNAVAILABLE"}, 503)
|
||||
return False
|
||||
for raw in records:
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
try:
|
||||
record = json.loads(line) if line.startswith("{") else {"token_plain": line, "scopes": ["*"]}
|
||||
except ValueError:
|
||||
continue
|
||||
if record.get("disabled"):
|
||||
continue
|
||||
valid = bool(record.get("token_sha256") and hmac.compare_digest(str(record["token_sha256"]), digest))
|
||||
valid = valid or bool(record.get("token_plain") and hmac.compare_digest(str(record["token_plain"]), token))
|
||||
scopes = set(record.get("scopes") or [])
|
||||
required = {
|
||||
"readRepositoryFile": {"repository:read", "file:write"},
|
||||
"acquireWriterLease": {"agent-task:control"},
|
||||
"commitFilesGoverned": {"file:write"},
|
||||
"commitFileGoverned": {"file:write"},
|
||||
}[operation]
|
||||
if valid and ("*" in scopes or required.intersection(scopes)):
|
||||
return True
|
||||
self._json({"error": "FORBIDDEN", "required_scope": sorted(required)}, 403)
|
||||
return False
|
||||
def _allowed(self, operation: str, body: Mapping[str, Any]) -> None:
|
||||
allowed = set(OPERATION_REQUIRED_FIELDS[operation]) | {"schema", "lease_seconds", "create_target_branch", "worktree_mode", "cas_ref_update_required", "expected_branch_oid", "generation", "branch_mode"}
|
||||
if operation == "commitFilesGoverned": allowed.add("files")
|
||||
@@ -421,14 +568,28 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
||||
operation = ROUTES.get(self.path)
|
||||
if operation is None or operation in ("getRuntimeStatus", "getGovernedWriteCapabilities") or self.adapter is None: self._json({"error": "NOT_FOUND"}, 404); return
|
||||
try:
|
||||
if not self._authorized(operation):
|
||||
return
|
||||
body = self._body(); self._allowed(operation, body); self._json(getattr(self.adapter, operation)(body))
|
||||
except _HTTPError as exc: self._json({"error": str(exc)}, 413 if str(exc) == "REQUEST_TOO_LARGE" else 400)
|
||||
except (BootstrapError, ValueError, BridgeV2Error) as exc: self._json({"error": str(exc)}, 409 if "CONFLICT" in str(exc) else 400)
|
||||
except Exception as exc: self._json({"error": str(exc) or type(exc).__name__}, 409 if any(token in str(exc) for token in ("CONFLICT", "EXPECTATION", "MONOTONIC")) else 503)
|
||||
except Exception as exc:
|
||||
code = str(exc) or type(exc).__name__
|
||||
if code.endswith("NOT_FOUND") or code in {"SOURCE_FILE_NOT_FOUND", "SOURCE_REF_NOT_FOUND", "LEASE_NOT_FOUND"}:
|
||||
status = 404
|
||||
elif code in {"SOURCE_FILE_TOO_LARGE", "SOURCE_MAX_BYTES_INVALID", "SOURCE_PATH_INVALID", "SOURCE_REF_INVALID", "SOURCE_REF_SCOPE_DENIED", "REPOSITORY_NOT_ALLOWED", "GITOPS_WRITES_DISABLED"}:
|
||||
status = 400 if code != "GITOPS_WRITES_DISABLED" else 423
|
||||
elif any(token in code for token in ("CONFLICT", "EXPECTATION", "MONOTONIC", "EXPIRED", "ALREADY_USED", "STALE", "BINDING")):
|
||||
status = 409
|
||||
else:
|
||||
status = 503
|
||||
self._json({"error": code, "retryable": status >= 500}, status)
|
||||
|
||||
|
||||
def serve(adapter: BridgeV2Adapter, host: str = "127.0.0.1", port: int = 18892) -> None:
|
||||
BridgeHandler.adapter = adapter; ThreadingHTTPServer((host, port), BridgeHandler).serve_forever()
|
||||
BridgeHandler.adapter = adapter
|
||||
BridgeHandler.action_token_path = Path(os.environ.get("AIOS_GITOPS_TOKEN_FILE", "/var/lib/aios-gitops/runtime/secrets/gitops.tokens"))
|
||||
ThreadingHTTPServer((host, port), BridgeHandler).serve_forever()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -124,12 +124,19 @@ SILENT_RELEASE_POST_PATHS = frozenset({
|
||||
'/submitOwnerApproval',
|
||||
'/startDeployment',
|
||||
})
|
||||
BRIDGE_CONTROL_POST_PATHS = frozenset({
|
||||
'/readRepositoryFile',
|
||||
'/acquireWriterLease',
|
||||
'/commitFilesGoverned',
|
||||
})
|
||||
|
||||
def reject_non_read_http(handler, method: str, path: str) -> bool:
|
||||
if method == 'GET':
|
||||
return False
|
||||
if path in SILENT_RELEASE_POST_PATHS:
|
||||
return False
|
||||
if method == 'POST' and path in BRIDGE_CONTROL_POST_PATHS:
|
||||
return False
|
||||
if (
|
||||
method == 'POST'
|
||||
and path in ('/api/file', '/api/commit-file')
|
||||
@@ -445,7 +452,10 @@ class H(BaseHTTPRequestHandler):
|
||||
request = urllib.request.Request(
|
||||
base + route,
|
||||
data=json.dumps(data, sort_keys=True, separators=(',', ':')).encode('utf-8'),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
**({k: self.headers[k] for k in ('Authorization', 'X-AIOS-Token') if self.headers.get(k)}),
|
||||
},
|
||||
method='POST',
|
||||
)
|
||||
try:
|
||||
@@ -460,6 +470,36 @@ 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 bridge_control(self, path, data):
|
||||
route = {
|
||||
'/readRepositoryFile': '/v2/source/file',
|
||||
'/acquireWriterLease': '/v2/writer-lease',
|
||||
'/commitFilesGoverned': '/v2/commit-files',
|
||||
}.get(path)
|
||||
if route is None:
|
||||
return self.sendj({'error': 'governed_control_route_invalid'}, 400)
|
||||
base = os.getenv('AIOS_BRIDGE_V2_URL', 'http://127.0.0.1:18892').rstrip('/')
|
||||
request = urllib.request.Request(
|
||||
base + route,
|
||||
data=json.dumps(data, sort_keys=True, separators=(',', ':')).encode('utf-8'),
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
**({k: self.headers[k] for k in ('Authorization', 'X-AIOS-Token') if self.headers.get(k)}),
|
||||
},
|
||||
method='POST',
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=60) 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 do_POST(self):
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
if path in {
|
||||
@@ -730,6 +770,18 @@ class H(BaseHTTPRequestHandler):
|
||||
if reject_non_read_http(self, 'POST', path): return
|
||||
data=self.body()
|
||||
try:
|
||||
if path == '/readRepositoryFile':
|
||||
a=self.need('file:write')
|
||||
if not a: return
|
||||
return self.bridge_control(path, data)
|
||||
if path == '/acquireWriterLease':
|
||||
a=self.need('agent-task:control')
|
||||
if not a: return
|
||||
return self.bridge_control(path, data)
|
||||
if path == '/commitFilesGoverned':
|
||||
a=self.need('file:write')
|
||||
if not a: return
|
||||
return self.bridge_control(path, data)
|
||||
if path == '/createReleaseCandidate':
|
||||
a=self.need('release:candidate')
|
||||
if not a: return
|
||||
@@ -1050,5 +1102,3 @@ def main():
|
||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
||||
if __name__ == '__main__': main()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small staging-only response overlay for the legacy listPatches route.
|
||||
|
||||
The base staging module remains the source of truth for routing, auth, and
|
||||
governed write behavior. This entrypoint only replaces the handler's JSON
|
||||
serializer for the patch-read aliases, projecting the legacy payload into a
|
||||
bounded, cursor-pageable summary while leaving every other route untouched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import urllib.parse
|
||||
from typing import Any
|
||||
|
||||
import aios_model_api_staging as base
|
||||
|
||||
|
||||
PATCH_PATHS = frozenset({"/api/patch", "/api/patches", "/api/branches", "/patch"})
|
||||
PATCH_DEFAULT_LIMIT = 20
|
||||
PATCH_MAX_LIMIT = 100
|
||||
PATCH_RESPONSE_BUDGET = 128 * 1024
|
||||
PATCH_FIELD_MAX_CHARS = 256
|
||||
PATCH_SUMMARY_MAX_CHARS = 1024
|
||||
PATCH_COMPAT_LIST_LIMIT = 20
|
||||
_TOKEN_RE = re.compile(r"(?i)(?:sk-|ghp_|bearer\s+)[A-Za-z0-9._:/+=-]{12,}")
|
||||
|
||||
|
||||
def _text(value: Any, limit: int = PATCH_FIELD_MAX_CHARS) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (dict, list)):
|
||||
value = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
value = _TOKEN_RE.sub("[REDACTED]", str(value))
|
||||
return value if len(value) <= limit else value[: max(1, limit - 1)] + "…"
|
||||
|
||||
|
||||
def _record_id(record: dict[str, Any], index: int) -> str:
|
||||
return _text(
|
||||
record.get("patch_id")
|
||||
or record.get("id")
|
||||
or record.get("patch_name")
|
||||
or record.get("branch")
|
||||
or f"patch-{index}",
|
||||
)
|
||||
|
||||
|
||||
def _record_key(record: dict[str, Any], index: int) -> tuple[bool, str, str, int]:
|
||||
timestamp = _text(
|
||||
record.get("updated_at")
|
||||
or record.get("updatedAt")
|
||||
or record.get("created_at")
|
||||
or record.get("createdAt")
|
||||
)
|
||||
return bool(timestamp), timestamp, _record_id(record, index), index
|
||||
|
||||
|
||||
def _summary(record: dict[str, Any], index: int, summary_limit: int) -> dict[str, Any]:
|
||||
patch_id = _record_id(record, index)
|
||||
raw_summary = next(
|
||||
(
|
||||
record.get(key)
|
||||
for key in ("summary", "description", "text", "intent")
|
||||
if record.get(key) not in (None, "")
|
||||
),
|
||||
"",
|
||||
)
|
||||
summary = _text(raw_summary, summary_limit)
|
||||
item = {
|
||||
"patch_id": patch_id,
|
||||
"name": _text(record.get("name") or record.get("title") or record.get("patch_name") or patch_id),
|
||||
"branch": _text(record.get("branch")) or None,
|
||||
"ref": _text(record.get("ref") or record.get("branch")) or None,
|
||||
"status": _text(record.get("status") or record.get("state") or "recorded"),
|
||||
"risk": _text(record.get("risk")) or None,
|
||||
"created_at": _text(record.get("created_at") or record.get("createdAt")) or None,
|
||||
"updated_at": _text(record.get("updated_at") or record.get("updatedAt")) or None,
|
||||
"commit_oid": _text(record.get("commit_oid") or record.get("commit") or record.get("oid"), 128) or None,
|
||||
"summary": summary,
|
||||
}
|
||||
if len(_text(raw_summary, PATCH_SUMMARY_MAX_CHARS + 1)) > summary_limit:
|
||||
item["summary_truncated"] = True
|
||||
return item
|
||||
|
||||
|
||||
def _branch_summary(record: Any, index: int) -> Any:
|
||||
if not isinstance(record, dict):
|
||||
return _text(record)
|
||||
return {
|
||||
"name": _text(record.get("name") or record.get("branch") or f"branch-{index}"),
|
||||
"ref": _text(record.get("ref") or record.get("name") or record.get("branch")) or None,
|
||||
"oid": _text(record.get("oid") or record.get("commit_oid") or record.get("commit"), 128) or None,
|
||||
"updated_at": _text(record.get("updated_at") or record.get("updatedAt")) or None,
|
||||
}
|
||||
|
||||
|
||||
def _encode_cursor(key: tuple[bool, str, str, int]) -> str:
|
||||
raw = json.dumps(
|
||||
{"has_timestamp": key[0], "timestamp": key[1], "patch_id": key[2], "index": key[3]},
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _decode_cursor(value: Any) -> tuple[bool, str, str, int]:
|
||||
if not isinstance(value, str) or not value or len(value) > 256 or re.fullmatch(r"[A-Za-z0-9_-]+", value) is None:
|
||||
raise ValueError("invalid_cursor")
|
||||
try:
|
||||
padded = value + "=" * (-len(value) % 4)
|
||||
decoded = json.loads(base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise ValueError("invalid_cursor") from exc
|
||||
if (
|
||||
not isinstance(decoded, dict)
|
||||
or set(decoded) != {"has_timestamp", "timestamp", "patch_id", "index"}
|
||||
or not isinstance(decoded["has_timestamp"], bool)
|
||||
or not isinstance(decoded["timestamp"], str)
|
||||
or not isinstance(decoded["patch_id"], str)
|
||||
or not isinstance(decoded["index"], int)
|
||||
or decoded["index"] < 0
|
||||
):
|
||||
raise ValueError("invalid_cursor")
|
||||
return decoded["has_timestamp"], decoded["timestamp"], decoded["patch_id"], decoded["index"]
|
||||
|
||||
|
||||
def _limit(query: dict[str, list[str]]) -> int:
|
||||
raw = (query.get("limit") or [str(PATCH_DEFAULT_LIMIT)])[0]
|
||||
try:
|
||||
result = int(raw)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("invalid_limit") from exc
|
||||
if not 1 <= result <= PATCH_MAX_LIMIT:
|
||||
raise ValueError("invalid_limit")
|
||||
return result
|
||||
|
||||
|
||||
def _page(records: list[Any], query: dict[str, list[str]], summary_limit: int = PATCH_SUMMARY_MAX_CHARS) -> dict[str, Any]:
|
||||
limit = _limit(query)
|
||||
rows = [
|
||||
(record, index, _record_key(record, index))
|
||||
for index, record in enumerate(records)
|
||||
if isinstance(record, dict)
|
||||
]
|
||||
rows.sort(key=lambda row: row[2], reverse=True)
|
||||
cursor = (query.get("cursor") or [None])[0]
|
||||
if cursor:
|
||||
cursor_key = _decode_cursor(cursor)
|
||||
if cursor_key not in {row[2] for row in rows}:
|
||||
raise ValueError("invalid_cursor")
|
||||
rows = [row for row in rows if row[2] < cursor_key]
|
||||
selected = rows[:limit]
|
||||
return {
|
||||
"items": [_summary(record, index, summary_limit) for record, index, _ in selected],
|
||||
"next_cursor": _encode_cursor(selected[-1][2]) if len(rows) > len(selected) and selected else None,
|
||||
"has_more": len(rows) > len(selected),
|
||||
"count": len(selected),
|
||||
"_cursor_keys": [key for _, _, key in selected],
|
||||
}
|
||||
|
||||
|
||||
def _bounded_payload(data: dict[str, Any], query: dict[str, list[str]]) -> dict[str, Any]:
|
||||
records = data.get("metadata_patches")
|
||||
if not isinstance(records, list):
|
||||
records = data.get("patches") if isinstance(data.get("patches"), list) else []
|
||||
page = _page(records, query)
|
||||
cursor_keys = page.pop("_cursor_keys")
|
||||
payload = {
|
||||
key: data[key]
|
||||
for key in ("repository_available", "query_error")
|
||||
if key in data
|
||||
}
|
||||
payload["metadata_patches"] = page["items"]
|
||||
payload["patches"] = page["items"]
|
||||
for key in ("orphan_metadata", "branches_without_metadata"):
|
||||
values = data.get(key)
|
||||
payload[key] = [
|
||||
_summary(value, index, PATCH_SUMMARY_MAX_CHARS)
|
||||
if isinstance(value, dict)
|
||||
else _text(value)
|
||||
for index, value in enumerate(values[:PATCH_COMPAT_LIST_LIMIT])
|
||||
] if isinstance(values, list) else []
|
||||
branches = data.get("authoritative_branches")
|
||||
if isinstance(branches, list):
|
||||
payload["authoritative_branches"] = [
|
||||
_branch_summary(value, index) for index, value in enumerate(branches[:PATCH_COMPAT_LIST_LIMIT])
|
||||
]
|
||||
if isinstance(data.get("branches"), list):
|
||||
payload["branches"] = [_text(value) for value in data["branches"][:PATCH_COMPAT_LIST_LIMIT]]
|
||||
payload.update(page)
|
||||
payload["list_patches_overlay"] = "bounded-summary-v1"
|
||||
|
||||
while len(json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")) > PATCH_RESPONSE_BUDGET and payload["items"]:
|
||||
payload["items"] = payload["items"][:-1]
|
||||
cursor_keys = cursor_keys[:-1]
|
||||
payload["metadata_patches"] = payload["items"]
|
||||
payload["patches"] = payload["items"]
|
||||
payload["count"] = len(payload["items"])
|
||||
payload["has_more"] = True
|
||||
payload["next_cursor"] = (
|
||||
_encode_cursor(cursor_keys[-1])
|
||||
if cursor_keys
|
||||
else None
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
class ListPatchesOverlayHandler(base.H):
|
||||
"""Delegate all behavior except bounded serialization of patch reads."""
|
||||
|
||||
def sendj(self, data: Any, code: int = 200):
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/")
|
||||
if path in PATCH_PATHS and code in (200, 503) and isinstance(data, dict):
|
||||
try:
|
||||
data = _bounded_payload(data, urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query))
|
||||
except ValueError as exc:
|
||||
return super().sendj(
|
||||
{"error": str(exc), "min_limit": 1, "max_limit": PATCH_MAX_LIMIT},
|
||||
400,
|
||||
)
|
||||
return super().sendj(data, code)
|
||||
|
||||
|
||||
base.H = ListPatchesOverlayHandler
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
base.main()
|
||||
@@ -519,7 +519,11 @@ class GovernedProductionReleaseExecutor:
|
||||
|
||||
def acceptance(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
result = self.consistency(request)
|
||||
result["accepted"] = result.get("status") == "PASS" and result.get("production_health", {}).get("status") == "ACTIVE_VERIFIED"
|
||||
result["accepted"] = (
|
||||
result.get("status") == "PASS"
|
||||
and result.get("production_health", {}).get("verification_status")
|
||||
== "ACTIVE_VERIFIED"
|
||||
)
|
||||
self.state.record("ACCEPTED" if result["accepted"] else "RECONCILIATION_REQUIRED", "acceptance", result)
|
||||
return result
|
||||
|
||||
@@ -541,7 +545,11 @@ def _health(url: str, binding: ReleaseBinding, artifact: Mapping[str, Any]) -> d
|
||||
body = json.loads(response.read().decode("utf-8"))
|
||||
status = response.status
|
||||
except Exception as exc:
|
||||
return {"status": "UNAVAILABLE", "error": type(exc).__name__}
|
||||
return {
|
||||
"status": "UNAVAILABLE",
|
||||
"verification_status": "UNAVAILABLE",
|
||||
"error": type(exc).__name__,
|
||||
}
|
||||
expected_artifact = artifact.get("artifact_sha256")
|
||||
exact = (
|
||||
status == 200
|
||||
@@ -551,7 +559,11 @@ def _health(url: str, binding: ReleaseBinding, artifact: Mapping[str, Any]) -> d
|
||||
and body.get("deployment_state") == "ACTIVE"
|
||||
and body.get("production_deployed") is True
|
||||
)
|
||||
return {"status": "ACTIVE_VERIFIED" if exact else "CONFLICT", "http_status": status, **body}
|
||||
return {
|
||||
**body,
|
||||
"http_status": status,
|
||||
"verification_status": "ACTIVE_VERIFIED" if exact else "CONFLICT",
|
||||
}
|
||||
|
||||
|
||||
def build_executor_config(config: Any) -> GovernedProductionReleaseExecutor:
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Durable, generation-scoped leases for the staging governed writer."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _iso(value: datetime) -> str:
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
def _expired(value: str) -> bool:
|
||||
return datetime.fromisoformat(value).astimezone(timezone.utc) <= _now()
|
||||
|
||||
|
||||
class ScopedLeaseError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ScopedLeaseStore:
|
||||
"""A small SQLite CAS boundary; no repository or filesystem writes."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
if not path.is_absolute() or path.is_symlink():
|
||||
raise ScopedLeaseError("LEASE_STORE_INVALID")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path = path
|
||||
with self._connect() as db:
|
||||
db.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS scoped_leases (
|
||||
lease_key TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
target_branch TEXT NOT NULL,
|
||||
expected_branch_oid TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL,
|
||||
binding_json TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
idempotency_key TEXT,
|
||||
result_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS scoped_leases_binding
|
||||
ON scoped_leases(task_id, repository, target_branch, generation);
|
||||
"""
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
db = sqlite3.connect(self.path, timeout=30.0)
|
||||
db.row_factory = sqlite3.Row
|
||||
db.execute("PRAGMA journal_mode=WAL")
|
||||
db.execute("PRAGMA synchronous=FULL")
|
||||
db.execute("PRAGMA busy_timeout=30000")
|
||||
return db
|
||||
|
||||
@staticmethod
|
||||
def key(request: Mapping[str, Any]) -> str:
|
||||
binding = {
|
||||
field: request[field]
|
||||
for field in (
|
||||
"task_id", "repository", "branch_mode", "base_oid",
|
||||
"expected_branch_oid", "target_branch", "generation",
|
||||
"lease_seconds",
|
||||
)
|
||||
}
|
||||
return hashlib.sha256(
|
||||
json.dumps(binding, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _row(row: sqlite3.Row) -> dict[str, Any]:
|
||||
value = dict(row)
|
||||
value["binding"] = json.loads(value.pop("binding_json"))
|
||||
if value.get("result_json"):
|
||||
value["result"] = json.loads(value.pop("result_json"))
|
||||
else:
|
||||
value.pop("result_json", None)
|
||||
return value
|
||||
|
||||
def acquire(self, request: Mapping[str, Any]) -> tuple[dict[str, Any], bool]:
|
||||
key = self.key(request)
|
||||
now = _now()
|
||||
expires = now.timestamp() + int(request["lease_seconds"])
|
||||
expires_at = _iso(datetime.fromtimestamp(expires, timezone.utc))
|
||||
binding_json = json.dumps(dict(request), sort_keys=True, separators=(",", ":"))
|
||||
try:
|
||||
with self._connect() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
exact = db.execute(
|
||||
"SELECT * FROM scoped_leases WHERE lease_key=?", (key,)
|
||||
).fetchone()
|
||||
if exact is not None:
|
||||
if exact["state"] == "ACTIVE" and _expired(exact["expires_at"]):
|
||||
db.execute(
|
||||
"UPDATE scoped_leases SET state='EXPIRED',updated_at=? WHERE lease_key=?",
|
||||
(_iso(now), key),
|
||||
)
|
||||
exact = db.execute(
|
||||
"SELECT * FROM scoped_leases WHERE lease_key=?", (key,)
|
||||
).fetchone()
|
||||
db.commit()
|
||||
return self._row(exact), False
|
||||
active = db.execute(
|
||||
"SELECT * FROM scoped_leases WHERE task_id=? AND repository=? AND target_branch=? AND state='ACTIVE'",
|
||||
(str(request["task_id"]), str(request["repository"]), str(request["target_branch"])),
|
||||
).fetchall()
|
||||
for row in active:
|
||||
if _expired(row["expires_at"]):
|
||||
db.execute(
|
||||
"UPDATE scoped_leases SET state='EXPIRED',updated_at=? WHERE lease_key=?",
|
||||
(_iso(now), row["lease_key"]),
|
||||
)
|
||||
else:
|
||||
raise ScopedLeaseError("LEASE_BINDING_CONFLICT")
|
||||
db.execute(
|
||||
"INSERT INTO scoped_leases(lease_key,task_id,repository,target_branch,expected_branch_oid,generation,binding_json,expires_at,state,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
key, request["task_id"], request["repository"],
|
||||
request["target_branch"], request["expected_branch_oid"],
|
||||
int(request["generation"]), binding_json, expires_at,
|
||||
"ACTIVE", _iso(now), _iso(now),
|
||||
),
|
||||
)
|
||||
row = db.execute(
|
||||
"SELECT * FROM scoped_leases WHERE lease_key=?", (key,)
|
||||
).fetchone()
|
||||
db.commit()
|
||||
return self._row(row), True
|
||||
except ScopedLeaseError:
|
||||
raise
|
||||
except sqlite3.Error as exc:
|
||||
raise ScopedLeaseError("LEASE_STORE_UNAVAILABLE") from exc
|
||||
|
||||
def for_commit(self, request: Mapping[str, Any]) -> dict[str, Any] | None:
|
||||
try:
|
||||
with self._connect() as db:
|
||||
row = db.execute(
|
||||
"SELECT * FROM scoped_leases WHERE task_id=? AND repository=? AND target_branch=? AND expected_branch_oid=? AND generation=? ORDER BY created_at DESC LIMIT 1",
|
||||
(
|
||||
request["task_id"], request["repository"],
|
||||
request["target_branch"], request["expected_branch_oid"],
|
||||
int(request["generation"]),
|
||||
),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
if row["state"] == "ACTIVE" and _expired(row["expires_at"]):
|
||||
db.execute(
|
||||
"UPDATE scoped_leases SET state='EXPIRED',updated_at=? WHERE lease_key=?",
|
||||
(_iso(_now()), row["lease_key"]),
|
||||
)
|
||||
row = db.execute(
|
||||
"SELECT * FROM scoped_leases WHERE lease_key=?", (row["lease_key"],)
|
||||
).fetchone()
|
||||
return self._row(row)
|
||||
except sqlite3.Error as exc:
|
||||
raise ScopedLeaseError("LEASE_STORE_UNAVAILABLE") from exc
|
||||
|
||||
def mark_used(self, lease_key: str, idempotency_key: str, result: Mapping[str, Any]) -> None:
|
||||
with self._connect() as db:
|
||||
updated = db.execute(
|
||||
"UPDATE scoped_leases SET state='USED',idempotency_key=?,result_json=?,updated_at=? WHERE lease_key=? AND state='ACTIVE'",
|
||||
(
|
||||
idempotency_key, json.dumps(dict(result), sort_keys=True),
|
||||
_iso(_now()), lease_key,
|
||||
),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
raise ScopedLeaseError("LEASE_ALREADY_USED")
|
||||
|
||||
def delete(self, lease_key: str) -> None:
|
||||
with self._connect() as db:
|
||||
db.execute("DELETE FROM scoped_leases WHERE lease_key=? AND state='ACTIVE'", (lease_key,))
|
||||
@@ -0,0 +1,20 @@
|
||||
[Service]
|
||||
User=aiman
|
||||
Group=gitops
|
||||
WorkingDirectory=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime/app/aios_chatgpt_bridge_v2_server.py
|
||||
Environment=AIOS_CONTROL_PLANE_TARGET_OID=@SOURCE_OID@
|
||||
Environment=AIOS_CONTROL_PLANE_TARGET_VERSION=V2R2C1
|
||||
Environment=AIOS_CONTROL_PLANE_RUNTIME_PATH=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime
|
||||
Environment=AIOS_CONTROL_PLANE_ARTIFACT=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime/manifest.json
|
||||
Environment=AIOS_CONTROL_PLANE_ARTIFACT_SHA256=@ARTIFACT_SHA256@
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED=true
|
||||
Environment=AIOS_GITOPS_TOKEN_FILE=%d/action_tokens
|
||||
Environment=AIOS_GITEA_API_BASE=https://g.cnnetmkt.top/git/api/v1
|
||||
Environment=AIOS_BRIDGE_V2_PORT=18892
|
||||
Environment=PYTHONPATH=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime:/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime/app:/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime/runtime-agent-os
|
||||
LoadCredential=action_tokens:/data/aios-gitops/runtime/secrets/gitops.tokens
|
||||
ReadOnlyPaths=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime /data/aios-gitops/repo.git
|
||||
ReadWritePaths=/var/lib/aios-gitops/bridge-v2-staging
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
[Service]
|
||||
User=aiman
|
||||
Group=gitops
|
||||
WorkingDirectory=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime/app/aios_model_api_staging_list_patches_overlay.py
|
||||
Environment=AIOS_GITOPS_WORKSPACE=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime
|
||||
Environment=AIOS_GITOPS_RUNTIME_OID=@SOURCE_OID@
|
||||
Environment=AIOS_GITOPS_ARTIFACT=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime/manifest.json
|
||||
Environment=AIOS_GITOPS_ARTIFACT_SHA256=@ARTIFACT_SHA256@
|
||||
Environment=AIOS_GITOPS_DEPLOYMENT_STATE=STAGING
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED=true
|
||||
Environment=AIOS_GITOPS_JOB_CONTROLLER_ENABLED=false
|
||||
Environment=AIOS_GITOPS_TOKEN_FILE=%d/action_tokens
|
||||
Environment=AIOS_GITOPS_GITEA_API_URL=https://g.cnnetmkt.top/git/api/v1
|
||||
Environment=AIOS_BRIDGE_V2_URL=http://127.0.0.1:18892
|
||||
Environment=AIOS_GITOPS_PORT=18893
|
||||
Environment=PYTHONPATH=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime:/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime/app:/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime/runtime-agent-os
|
||||
LoadCredential=action_tokens:/data/aios-gitops/runtime/secrets/gitops.tokens
|
||||
ReadOnlyPaths=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime /data/aios-gitops/repo.git
|
||||
ReadWritePaths=/var/lib/aios-gitops/model-api-staging
|
||||
@@ -0,0 +1,5 @@
|
||||
AIOS governed Action loop smoke
|
||||
task=fix-action-contracts-20260825-1407
|
||||
generation=5
|
||||
expected_parent=f104e1244fcbc36d1debf1aa0c6720f734ea04cf
|
||||
purpose=verify read-lease-commit-idempotency-cas
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: AIOS GitOps Controlled Access API
|
||||
version: V1R1C0-chatgpt-actions
|
||||
version: V2R2C1-chatgpt-actions-28ops
|
||||
description: Controlled ChatGPT Actions API for AIOS online GitOps baseline. Read endpoints are public. Write endpoints require Bearer token and create Git commits on non-main branches.
|
||||
servers:
|
||||
- url: https://cnnetmkt.top
|
||||
@@ -241,10 +241,62 @@ components:
|
||||
properties:
|
||||
candidate_id: {type: string}
|
||||
paths:
|
||||
/readRepositoryFile:
|
||||
post:
|
||||
operationId: readRepositoryFile
|
||||
summary: Read one bounded file from an approved repository ref
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [repository, ref, path, max_bytes]
|
||||
properties:
|
||||
repository: {type: string, enum: [aios/aios]}
|
||||
ref: {type: string, description: agent branch, main, V2R2C1, or full commit OID}
|
||||
path: {type: string, minLength: 1, maxLength: 512}
|
||||
max_bytes: {type: integer, minimum: 1, maximum: 262144}
|
||||
responses:
|
||||
'200': {description: Bounded read-only source content}
|
||||
'400': {description: Invalid repository, ref, path, or size}
|
||||
'404': {description: Ref or file not found}
|
||||
x-openai-isConsequential: false
|
||||
/acquireWriterLease:
|
||||
post:
|
||||
operationId: acquireWriterLease
|
||||
summary: Acquire a short-lived generation-scoped writer lease
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [schema, task_id, repository, branch_mode, base_oid, expected_branch_oid, target_branch, generation, lease_seconds]
|
||||
properties:
|
||||
schema: {type: string, enum: [aios.task-writer-lease.v2]}
|
||||
task_id: {type: string, minLength: 1, maxLength: 200}
|
||||
repository: {type: string, enum: [aios/aios]}
|
||||
branch_mode: {type: string, enum: [CONTINUE_EXISTING]}
|
||||
base_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9._/-]+$'}
|
||||
generation: {type: integer, minimum: 1}
|
||||
lease_seconds: {type: integer, minimum: 1, maximum: 3600}
|
||||
responses:
|
||||
'200': {description: Acquired or idempotently replayed scoped lease}
|
||||
'409': {description: Binding, stale, expired, or already-used lease}
|
||||
'423': {description: Governed writes disabled}
|
||||
x-openai-isConsequential: true
|
||||
/v2/commit-files:
|
||||
post:
|
||||
operationId: commitFilesGoverned
|
||||
summary: Submit one atomic governed commit containing multiple files
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -267,10 +319,11 @@ paths:
|
||||
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9._/-]+$'}
|
||||
generation: {type: integer, minimum: 1}
|
||||
idempotency_key: {type: string}
|
||||
idempotency_key: {type: string, minLength: 8, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$'}
|
||||
files:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 50
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
@@ -283,6 +336,7 @@ paths:
|
||||
'200': {description: Governed commit job and verified branch result}
|
||||
'400': {description: Contract or policy rejection}
|
||||
'409': {description: Branch CAS or idempotency conflict}
|
||||
x-openai-isConsequential: true
|
||||
/createReleaseIntent:
|
||||
post:
|
||||
operationId: createReleaseIntent
|
||||
@@ -362,20 +416,6 @@ paths:
|
||||
'403': {description: Signature, key, expiry, or revocation failure}
|
||||
'409': {description: Conflicting release plan}
|
||||
x-openai-isConsequential: true
|
||||
/submitSignedApproval:
|
||||
post:
|
||||
operationId: submitSignedApproval
|
||||
summary: Alias for immutable signed owner approval submission
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/SignedReleasePlanApproval'}
|
||||
responses:
|
||||
'200': {description: Signature verified and approval stored}
|
||||
'403': {description: Production approval backend unavailable or signature invalid}
|
||||
x-openai-isConsequential: true
|
||||
/startDeployment:
|
||||
post:
|
||||
operationId: startDeployment
|
||||
@@ -439,17 +479,6 @@ paths:
|
||||
operationId: listPatches
|
||||
summary: List patch records
|
||||
responses: {'200': {description: OK}}
|
||||
post:
|
||||
operationId: createPatch
|
||||
summary: Create a patch branch and commit metadata
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/PatchRequest'}
|
||||
responses: {'200': {description: Patch branch committed}, '401': {description: Unauthorized}, '403': {description: Forbidden}}
|
||||
x-openai-isConsequential: true
|
||||
/api/model:
|
||||
get:
|
||||
operationId: readModelRegistry
|
||||
@@ -471,61 +500,6 @@ paths:
|
||||
operationId: getRuntimeStatus
|
||||
summary: Read runtime status
|
||||
responses: {'200': {description: OK}}
|
||||
/api/branch:
|
||||
post:
|
||||
operationId: createBranch
|
||||
summary: Create a non-main branch and commit branch metadata
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/BranchRequest'}
|
||||
responses: {'200': {description: Branch created}, '401': {description: Unauthorized}, '403': {description: Forbidden}}
|
||||
x-openai-isConsequential: true
|
||||
/api/file:
|
||||
post:
|
||||
operationId: commitFile
|
||||
summary: Write a file and create a Git commit on a non-main branch
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/FileCommitRequest'}
|
||||
responses: {'200': {description: File committed}, '400': {description: Secret rejected or invalid path}, '401': {description: Unauthorized}}
|
||||
x-openai-isConsequential: true
|
||||
/api/commit-file:
|
||||
post:
|
||||
operationId: commitFileAtomic
|
||||
summary: Atomically write one allowlisted file, commit, push, and verify using Token Policy V2
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/AtomicFileCommitRequest'}
|
||||
responses:
|
||||
'200': {description: Completed or idempotently replayed}
|
||||
'400': {description: Invalid canonical request}
|
||||
'401': {description: Missing, invalid, expired, or revoked Token Policy V2 grant}
|
||||
'403': {description: Audience, source, branch, operation, path, or policy denied}
|
||||
'409': {description: Idempotency conflict or manual review required}
|
||||
'413': {description: Request too large}
|
||||
'503': {description: Push or remote verification unavailable; recoverable}
|
||||
x-openai-isConsequential: true
|
||||
/api/commit:
|
||||
post:
|
||||
operationId: commitChanges
|
||||
summary: Commit existing workspace changes on a non-main branch
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/CommitRequest'}
|
||||
responses: {'200': {description: Commit result}, '401': {description: Unauthorized}}
|
||||
x-openai-isConsequential: true
|
||||
/api/tag-publication-status:
|
||||
get:
|
||||
operationId: getTagPublicationStatus
|
||||
|
||||
@@ -20,9 +20,34 @@ import sys
|
||||
|
||||
source, output = map(Path, sys.argv[1:])
|
||||
text = source.read_text(encoding="utf-8")
|
||||
bridge_marker = " # AIOS external Action Bridge v2 staging route"
|
||||
bridge_block = r''' # AIOS external Action Bridge v2 staging route
|
||||
# The published Action schema uses the site-root /v2 paths and must reach
|
||||
# the non-production Bridge v2 listener, never the production 18920 unit.
|
||||
location ^~ /v2/ {
|
||||
proxy_pass http://127.0.0.1:18892;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header X-AIOS-Token $http_x_aios_token;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 1100k;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
'''
|
||||
if bridge_marker not in text:
|
||||
needle = " # BEGIN AIOS CHATGPT AUTONOMOUS RELEASE TASK ROUTE"
|
||||
if text.count(needle) != 1:
|
||||
raise SystemExit("expected exactly one legacy Action route marker")
|
||||
text = text.replace(needle, bridge_block + "\n" + needle, 1)
|
||||
elif "location ^~ /v2/" not in text or "proxy_pass http://127.0.0.1:18892;" not in text:
|
||||
raise SystemExit("staging Bridge v2 route marker exists but route is incomplete")
|
||||
|
||||
marker = " # AIOS external Action governed-read ownership"
|
||||
if marker in text:
|
||||
raise SystemExit("governed-read locations already present in backup")
|
||||
block = r''' # AIOS external Action governed-read ownership
|
||||
# These reads are owned by the authenticated 18890 facade, not the 18893 model API.
|
||||
location = /api/governed-write-capabilities {
|
||||
@@ -69,7 +94,8 @@ block = r''' # AIOS external Action governed-read ownership
|
||||
needle = " location /api/ {"
|
||||
if text.count(needle) != 1:
|
||||
raise SystemExit("expected exactly one generic /api/ location")
|
||||
text = text.replace(needle, block + "\n" + needle, 1)
|
||||
if marker not in text:
|
||||
text = text.replace(needle, block + "\n" + needle, 1)
|
||||
output.write_text(text, encoding="utf-8")
|
||||
PY
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# AIOS Action Connector Schema Final Closure — 2026-08-25
|
||||
|
||||
## Disposition
|
||||
|
||||
`NEEDS_REWORK`.
|
||||
|
||||
The staging runtime and published OpenAPI contract are repaired and the public `/v2` staging route is now reachable. The final P0-C acceptance cannot be claimed because this execution environment has no callable ChatGPT Action/connector inspection or registration tool, and the deployed repository intentionally has no connector acknowledgement/write endpoint. Therefore the required proof of the actual callable tool schema, a real public two-file batch commit, and its public terminal reconcile are not available. The task must not be marked `SUCCEEDED`.
|
||||
|
||||
No main, protected tag, baseline registry, or production write was performed.
|
||||
|
||||
## Scope and live identity
|
||||
|
||||
- Repository: `aios/aios`
|
||||
- Task: `fix-action-contracts-20260825-1407`
|
||||
- Branch: `agent/fix-action-contracts-20260825-1407`
|
||||
- Source worktree: `/var/lib/aios-gitops/bridge-v2-staging/worktrees/fix-action-contracts-20260825-1407`
|
||||
- Source HEAD before this closure patch: `eb72fcd8dce242abaef15f03a21b43ba2c5197fd`
|
||||
- Deployed staging Bridge runtime commit: `2ec49005bde44f6981b14351500847d594e0276c`
|
||||
- Deployed Bridge artifact SHA-256: `f67aed6cc0cce1f08197485c3eea204d3ef4d3989f44dc98e8ea2691e5886cbf`
|
||||
- Bridge unit: `aios-chatgpt-bridge-v2-staging.service`, `aiman:gitops`, `127.0.0.1:18892`
|
||||
- Model API unit: `aios-gitops-model-api-v2r2c1-staging.service`, `127.0.0.1:18893`
|
||||
- Gitea API base projected by staging systemd: `https://g.cnnetmkt.top/git/api/v1`
|
||||
- Runtime state: `STAGING`, `drift_status=PASS`, `legacy_global_writes_enabled=false`, `main_write=false`, `tag_write=false`, `baseline_write=false`, `production_write=false`
|
||||
|
||||
## Root causes and contract chain
|
||||
|
||||
1. Runtime source was already repaired to require `files` for `commitFilesGoverned`. `active-runtime/app/aios_chatgpt_bridge_v2.py` declares the canonical required tuple and `render_openapi_contract_fragment()` emits the operation fragment.
|
||||
2. `scripts/build_v2r2c1_model_api_staging.py` copies the canonical `docs/gitops/openapi-chatgpt-actions.yaml` into the immutable staging artifact and rejects a build missing `/v2/commit-files`.
|
||||
3. The deployed public OpenAPI is served from the model API workspace at `/api/openapi-chatgpt-actions.yaml`; its SHA-256 was `a0775b0719a76e08a95f3257a3d3b2a73b70e1c55c162914250f80dd28da022d`. It contains `/v2/commit-files`, `files`, and item fields `path` and `content_base64`.
|
||||
4. The missing deployment link was Nginx: OpenAPI `servers.url` is `https://cnnetmkt.top` and its operation paths are `/v2/...`, but the live site had no `/v2/` location and the old `/api/v2/` location pointed at absent production port `18920`. The old `/api/file` route is an inactive legacy single-file router.
|
||||
5. A repository-wide and host-side inspection found no formal external ChatGPT connector registration source, schema cache, re-registration command, or acknowledgement endpoint. The existing durable-owner design explicitly states that no connector acknowledgement/write endpoint is provided and the only valid state is `PENDING_EXTERNAL_CONNECTOR`. This is the remaining external blocker, not a reason to create a parallel local schema or reuse another credential.
|
||||
|
||||
## Changes made
|
||||
|
||||
- `ops/gitops-migration/bind_external_action_surface_final_staging.sh`
|
||||
- added an idempotent exact `/v2/` route to staging Bridge `127.0.0.1:18892`;
|
||||
- forwards `Authorization` and `X-AIOS-Token`;
|
||||
- keeps the existing exact governed-read routes on `18890` and generic `/api/` on `18893`;
|
||||
- refuses an incomplete existing staging route and never adds a route to production `18920`.
|
||||
- `tests/test_action_contract_regressions.py`
|
||||
- added a parity/deployment test tying published `/v2/commit-files` to the staging route and asserting no `18920` target in the staging installer.
|
||||
- `runbooks/action-connector-schema-refresh-20260825.md`
|
||||
- added the durable connector refresh and fail-closed acceptance procedure.
|
||||
- `reports/action-connector-schema-final-closure-20260825.md`
|
||||
- this evidence report.
|
||||
|
||||
The source/runtime contract parity test remains in place and still compares runtime required fields, generated fragment, and published schema. The live connector layer cannot be parity-tested because no connector layer is deployed or callable from this environment; the gate therefore remains fail-closed rather than accepting a fixture as proof.
|
||||
|
||||
## Deployment and restart evidence
|
||||
|
||||
The existing staging installer was run after `git diff --check` and focused tests. It created the Nginx backup `/data/aios-gitops/backups/action-surface-final-20260825T105656Z`, passed `nginx -t`, and reloaded Nginx. Public readback then returned HTTP 200 for:
|
||||
|
||||
- `https://cnnetmkt.top/v2/runtime`
|
||||
- `https://cnnetmkt.top/v2/capabilities`
|
||||
- `https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml`
|
||||
|
||||
The staging Bridge and Model API were subsequently restarted once. Both `systemctl is-active` checks returned `active`. Post-restart `/v2/runtime` retained the staging identity and artifact SHA above; `/v2/capabilities` retained `commitFilesGoverned.required_fields` including `files`.
|
||||
|
||||
## Tests and live smoke
|
||||
|
||||
Passing results:
|
||||
|
||||
- `python3 -m pytest -q tests/test_action_contract_regressions.py tests/test_bridge_v2_governed_http_bootstrap.py` — `17 passed`.
|
||||
- Mandatory suite — `264 passed, 28 subtests passed in 4.81s`.
|
||||
- Full repository suite — `720 passed, 2 skipped, 2 failed, 154 subtests passed in 34.02s`. The two failures are unchanged known failures: stale `/tmp/aios-agent-events-test` fixture and deployed signer self-test return code `42`.
|
||||
|
||||
Post-restart live results:
|
||||
|
||||
- `getReleaseConsistency(V2R2C1, ...)`: HTTP 200 structured consistency response; no Gitea URL validation failure. Gitea listing and peeled target readback were visible.
|
||||
- `runReleaseAcceptance(V2R2C1, ...)`: HTTP 200 structured policy result with `accepted=false`; it reached policy evaluation rather than failing URL validation.
|
||||
- `getTagPublicationStatus("V2R2C1")`: HTTP 200 structured state (`REMOTE_PUSH_PENDING` on Bridge v2; the legacy model read surface returned structured `UNKNOWN`).
|
||||
- `getReleaseSaga(nonexistent)`: HTTP 404, normal NOT_FOUND behavior.
|
||||
- Existing terminal job `job-93138017f3afd7f709f691fd05c77923`: HTTP 200, `COMPLETED`, revision `8`; terminal `/v2/job/reconcile` returned HTTP 200 with the same `COMPLETED` receipt and Authority/API/Git remote OID `904e7ea0cd376d204bad19628c36a9dd90ca6621`.
|
||||
- Empty diff: HTTP 200, zero paths/size, `truncated=false`.
|
||||
- Non-empty diff: HTTP 200 with identities, changed paths, stats, total size, patch limit `262144`, and bounded response fields.
|
||||
- Public `POST /v2/commit-files` probes returned `BRIDGE_REQUEST_FIELDS_MISSING` for incomplete bodies; the body containing `files` was not rejected as an unknown field. No write side effect occurred.
|
||||
|
||||
## Missing final acceptance
|
||||
|
||||
The following required evidence is not available and is intentionally not inferred:
|
||||
|
||||
- actual ChatGPT callable Action tool schema showing required `files`;
|
||||
- formal connector re-register/cache invalidation acknowledgement;
|
||||
- an authorized public `commitFilesGoverned` request containing two files;
|
||||
- Authority/Gitea/API remote OID equality for that new batch commit;
|
||||
- its batch Job `COMPLETED` readback and public terminal reconcile;
|
||||
- post-batch connector callable smoke.
|
||||
|
||||
The prior single-file public commit `904e7ea0cd376d204bad19628c36a9dd90ca6621` and its terminal reconcile are preserved as baseline evidence only; they are not substituted for the required batch proof.
|
||||
|
||||
## Protected boundaries and rollback
|
||||
|
||||
No source or deployment operation wrote `main`, a protected tag, the baseline registry, or production. The production-facing `18920` route was not changed; the new `/v2/` location targets only the staging Bridge listener. No secret value was printed, rotated, or reused. Automatic rollback was not triggered because Nginx syntax, staging service health, runtime identity, Gitea readback, and protected-ref boundaries remained valid. If a future connector refresh breaks these checks, restore the Nginx backup and the preceding staging artifact through the existing governed installer, then re-run the same probes.
|
||||
|
||||
## Next required owner/platform action
|
||||
|
||||
Provide the supported staging external Action connector lifecycle (registration/re-registration/cache invalidation) and an authorized staging Action bearer, or have the legitimate external Agent claim the existing continuation task and produce its durable callable-schema and batch-commit evidence. Until then the correct disposition remains `NEEDS_REWORK`; do not mark `SUCCEEDED` based on the server-side OpenAPI alone.
|
||||
|
||||
@@ -78,3 +78,42 @@ The full-suite failures remain the known event-bus fixture failure and the real
|
||||
The final staging services were restarted after deployment and health/runtime identity were re-read. The post-restart `systemctl is-active` result was `active` for both services; model health again reported OID `2ec49005bde44f6981b14351500847d594e0276c`, artifact SHA `f67aed6cc0cce1f08197485c3eea204d3ef4d3989f44dc98e8ea2691e5886cbf`, staging mode, and `production_deployed=false`. Post-restart consistency remained `PASS`, acceptance remained structured `accepted=false` for the pre-existing protected-tag publication state, saga remained `NOT_FOUND` for the nonexistent candidate, and the large diff remained bounded/truncated. The task cannot be promoted to `SUCCEEDED` until the protected-tag publication state and the two known repository-wide failures are resolved through their respective governed owners. No automatic rollback was performed because the staging services stayed healthy, mandatory tests passed, Gitea readback passed, and protected-ref boundaries remained intact.
|
||||
|
||||
Task disposition is `NEEDS_REWORK`, not `SUCCEEDED`: the staging contract repair is materially complete, but the deployed acceptance result is not all-green and the task row has not been legitimately claimed/verified by the online task worker.
|
||||
|
||||
## External Action Surface final closure evidence — 2026-08-25
|
||||
|
||||
### Disposition
|
||||
NEEDS_REWORK. Staging is deployed and P0-A/P0-B have live closure evidence. P0-C is blocked: no formal staging external Action bearer issuance path was available, so the required real public two-file commit and terminal reconcile were not authorized or executed. Do not mark SUCCEEDED. No main, tag, baseline, or production write occurred; untracked runtime/agent-observability and runtime/task-center were preserved.
|
||||
|
||||
### Identity and before-change facts
|
||||
Authority /data/aios-gitops/repo.git; worktree /var/lib/aios-gitops/bridge-v2-staging/worktrees/fix-action-contracts-20260825-1407; branch agent/fix-action-contracts-20260825-1407; initial HEAD 35eec9afe1432e8466d60040b2fb1049bb41242a1. Stable V2R2C1 source 998d30304787e31e5ae607c0bd86b5edb2b0e5ad; evidence closure a8b906d1bc24088c19647e98de6a679268f6d0a2. Repair commit 174e64dd0218cef6d49dd13d58bbd90c78fecadd; protected refs unchanged.
|
||||
|
||||
### P0-A governed-read ownership
|
||||
Root cause: generic /api/ sent governed-write-capabilities to model API 18893 (404), while governed facade 18890 owned it. Fix: ops/gitops-migration/bind_external_action_surface_final_staging.sh installs exact locations for governed-write-capabilities, control-plane-snapshot, online-agent-task, and online-agent-task-events to 18890; generic /api/ remains 18893; auth headers forward and no business host is hard-coded. Backup /data/aios-gitops/backups/action-surface-final-20260825T101714Z; log /data/aios-gitops/logs/action-surface-final-20260825T101714Z.log. nginx -t passed, Nginx active, public GET /api/governed-write-capabilities HTTP 200. Contract reports governed writes enabled but legacy/main/tag/baseline/production writes false.
|
||||
|
||||
### P0-B release semantics
|
||||
Root cause: public model GET used local TagPublisher/PublicationStore lacking tag object binding, so consistency was FAIL/tag_object=false; Bridge v2 had authoritative durable binding. Fix: active-runtime/app/aios_model_api_staging.py constructs one canonical publication request and routes release-consistency/release-acceptance through Bridge v2 getReleaseConsistency/runReleaseAcceptance; tests/test_action_contract_regressions.py covers this. Live V2R2C1 consistency with target 998d30304787e31e5ae607c0bd86b5edb2b0e5ad, closure a8b906d1bc24088c19647e98de6a679268f6d0a2, plan sha256:c0df52008ae7f64cec5ee2d28947aa7b892066c670ab8295e4549140b1a7500e returned HTTP 200 PASS, all checks true, tag object 07fc2746b688f5f243f29b39de39f32edc3dd9d2 and peeled target correct. Acceptance entered structured policy evaluation with accepted=false, not URL failure. Tag publication V2R2C1 returned structured UNKNOWN, not invalid version.
|
||||
|
||||
### P0-C credential and commit blocker
|
||||
Hash-only Action record: account chatgpt-gitops-bot; id chatgpt-action-0a3e55b7659a4d4e8125feabce5e8bf5; kind gpt_action_api_key; consumer aios-gitops-model-api.service; issuer owner_ssh_breakglass_rotation; issued 2026-08-10T04:51:58.900488Z; expires 2026-09-09T04:51:58.900488Z; scopes file:write and agent-task:control; disabled false; owner approval required true. Plaintext was not printed/reused. agent_task_control_token is not an Action bearer. Inspected Writer grants, Gitea consumer, SSH, task broker and old token candidates provide no formal issuer; no unrelated credential substituted. Empty public POST /api/commitFilesGoverned reached staging and returned HTTP 423 gitops_writes_disabled with AIOS_GITOPS_WRITES_ENABLED=false. Writes were not enabled. No real public two-file commit or COMPLETED reconcile is claimed. Blocker: FORMAL_STAGING_ACTION_BEARER_ISSUANCE_NOT_AVAILABLE.
|
||||
|
||||
### Schema/deployment/runtime
|
||||
Source 174e64dd0218cef6d49dd13d58bbd90c78fecadd. Artifact /data/aios-gitops/artifacts/action-contract-final-174e64d/aios-v2r2c1-model-api-174e64dd0218cef6d49dd13d58bbd90c78fecadd.tar.gz; SHA256 8fc811cfb9dbb22d6eb7ca418d58f9ada1d6fdd12e566e3e8f5a02bec47c4f37. Runtime /opt/aios/gitops-model-api/174e64dd0218cef6d49dd13d58bbd90c78fecadd-action-surface-final/runtime. Drop-in /etc/systemd/system/aios-gitops-model-api-v2r2c1-staging.service.d/zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz-action-contract-zzzz-final-999.conf; backup /data/aios-gitops/backups/action-contract-final-deploy-20260825T101959Z/drop-in-directory. User/Group aiman:gitops; API base from systemd https://g.cnnetmkt.top/git/api/v1; writes false; Bridge 18892; STAGING; production_deployed=false. /api/run proved active runtime/readable authority /data/aios-gitops/repo.git and main 3244f0f17f8845592ac94d585f5ebd65693c9f29. /api/openapi.yaml required commitFilesGoverned files, each with path/content_base64.
|
||||
|
||||
### Tests
|
||||
Before mandatory: 264 passed, 28 subtests, 4.82s. After mandatory: 264 passed, 28 subtests, 4.80s. Focused action regression: 11 passed, 0.12s. Full: 719 passed, 2 skipped, 154 subtests, 2 failed, 34.03s. Same known failures: stale /tmp/aios-agent-events-test in test_unified_event_flow; signer adapter self-test return 42 in test_real_signer_help_and_self_test_are_available.
|
||||
|
||||
### Restart and smoke
|
||||
After first deployment, model and Bridge staging services were restarted and remained active. Post-restart health retained source/artifact identity, V2R2C1, STAGING, production false. Capability HTTP 200; consistency HTTP 200/PASS; tag status structured UNKNOWN; nonexistent saga HTTP 404/NOT_FOUND with execution false, shadow true, side effects false. Empty diff HTTP 200, zero paths/size, truncated false. Bounded non-empty diff returned identities, paths/stats, bounded output and patch limit 262144; local pipe-to-head caused only expected curl broken-pipe. Existing large-diff behavior remains bounded/truncated with no transport exception. Gitea readback proven by consistency PASS; protected-ref boundary remained fail-closed.
|
||||
|
||||
### Task continuity and remaining work
|
||||
Earlier execution observed TASK_NOT_FOUND and created continuation fix-action-contracts-20260825-1407-external-surface-closure with parent/source/OID and staging constraints. Later recheck returned parent HTTP 200 QUEUED; continuation also QUEUED, revision 1, claimed_by null, result null. Neither has claim, execution, terminal result, or success evidence. Required rework is formal bearer lifecycle, isolated real two-file public commit and OID/reconcile verification, disposition of the two pre-existing full-suite failures, then full smoke before SUCCEEDED.
|
||||
### Health endpoint clarification
|
||||
|
||||
The service health contract is /api/health: local model port 18893 and public https://cnnetmkt.top/api/health both returned HTTP 200 with the deployed source/artifact identity, STAGING, and production_deployed=false. Public root /health returned HTTP 502 because the existing Nginx root location proxies the separate portal upstream on 127.0.0.1:3000; it is not the Action model health route. This unrelated portal-ingress 502 is recorded as an observed remaining staging risk, and was not changed because this task scope is the Action/Bridge/release surface.
|
||||
|
||||
\n
|
||||
### Formal task-state boundary
|
||||
|
||||
The deployed Task API exposes verification decisions SUCCEEDED, NEEDS_REWORK, and FAILED only through POST /v1/tasks/{id}/verify while the task is VERIFYING. Both the parent and continuation were QUEUED with no claim, so a direct NEEDS_REWORK transition was not a legal API operation. No cancel, database edit, fake claim, or lifecycle bypass was performed. This report is the authoritative disposition for this run: NEEDS_REWORK.
|
||||
|
||||
\n
|
||||
@@ -0,0 +1,92 @@
|
||||
# Action 最小自治修复闭环证据(2026-08-25)
|
||||
|
||||
## 最终状态
|
||||
|
||||
`BLOCKED`(staging 代码/配置已部署并健康,但缺少本次可用的正式 Action bearer,未宣称真实 governed commit 或完整外部 smoke 成功)。
|
||||
|
||||
本轮没有写入 production、`main`、release tag 或 baseline。
|
||||
|
||||
## 修改前事实
|
||||
|
||||
- repository: `aios/aios`
|
||||
- branch: `agent/fix-action-contracts-20260825-1407`
|
||||
- 修改前 HEAD: `d7a0d0db09826d513cb19451a4f018c998c00191`
|
||||
- production baseline: `V2R2C1`
|
||||
- baseline source commit: `998d30304787e31e5ae607c0bd86b5edb2b0e5ad`
|
||||
- Action schema 已限制为 26 operations,但没有外部 read-source/lease 入口;Bridge lease 主要是进程内 runtime binding;Bridge 未做 operation-specific bearer 校验。
|
||||
- 真实 staging model-api source/artifact 在本轮前为 `7e5525cfcbbaa3898c2e5ed3dc9c13e321908e61` / `2163c2e0467f3bd0a97f8d2b95e3277391c47dcf3ebafb8fa372a01c3f530ac0`。
|
||||
|
||||
## 根因与修复
|
||||
|
||||
1. Autonomous Agent 没有安全 read seam:增加 `AuthorityGitExecutor.read_file` 和 `readRepositoryFile`,只读 authoritative bare Git object database,固定响应上限并返回 resolved commit/OID、size、SHA-256、base64。
|
||||
2. Lease 没有跨重启 durable binding:增加 `ScopedLeaseStore` SQLite CAS 状态,绑定 task/repository/branch/expected OID/generation/TTL,支持同 binding 幂等、冲突、过期和 USED replay;commit 先校验 lease,再走既有 detached worktree/Job Controller/Authority CAS chain。
|
||||
3. Lease 曾可由调用者声明治理身份:现在必须命中 durable task binding,公开路径只接受 `CONTINUE_EXISTING`,并要求 expected OID 等于当前 authority branch head。
|
||||
4. Auth scope 曾混合:Bridge 按 read/lease/commit operation 分别检查 scope;model-api facade 认证后透传 bearer。
|
||||
5. runtime contract 与 published schema:增加 read/lease operation,保留 `commitFilesGoverned.files[] = {path, content_base64}`,builder 将 `scoped_lease_store.py` 纳入 immutable artifact。
|
||||
|
||||
## 修改文件与配置
|
||||
|
||||
- `active-runtime/app/aios_authority_git_executor.py`:安全 ref/path/size/read helper。
|
||||
- `active-runtime/app/scoped_lease_store.py`:SQLite durable lease state。
|
||||
- `active-runtime/app/aios_chatgpt_bridge_v2.py`:read operation contract。
|
||||
- `active-runtime/app/aios_chatgpt_bridge_v2_server.py`:read route、task binding、lease store、Bearer/scope matrix、structured errors。
|
||||
- `active-runtime/app/aios_model_api_staging.py`:认证 facade 与 Bridge control proxy。
|
||||
- `docs/gitops/openapi-chatgpt-actions.yaml`:新增 `readRepositoryFile`、`acquireWriterLease`,公开 operation 数 28。
|
||||
- `scripts/build_v2r2c1_model_api_staging.py`:纳入 `scoped_lease_store.py`。
|
||||
- `tests/test_minimum_autonomous_loop.py`:read/path/size、contract、lease 幂等/冲突测试。
|
||||
- `runbooks/agent-minimum-autonomous-loop-20260825.md`:部署与验收手册。
|
||||
- `deploy/*minimal-autonomous-loop.conf`:待随最终 source OID 渲染的 staging unit 配置模板;实际 drop-in 使用 systemd `LoadCredential` 投影 `/data/aios-gitops/runtime/secrets/gitops.tokens`,不复制 token。
|
||||
|
||||
## Git 与构建结果
|
||||
|
||||
- 第一实现提交:`f3a4485be7c803c3769c518578cf4bf86db32c6e`。
|
||||
- 绑定与 scope 收紧提交:`04b69beb369365a1522268e16c3ac7cd1dd745ce`。
|
||||
- 最终实现/部署 source OID:`9f32b26eba5bdaaa0e7c89042ce336426d465dbd`。
|
||||
- build artifact:`/var/lib/aios-gitops/bridge-v2-staging/candidate-artifacts/min-autonomous-9f32b26/aios-v2r2c1-model-api-9f32b26eba5bdaaa0e7c89042ce336426d465dbd.tar.gz`
|
||||
- artifact SHA-256:`ff2031b9ef8a39811c15086c2577d33cc6f187fd060136b7be7a8d6b4152e9f1`
|
||||
- artifact source OID:`9f32b26eba5bdaaa0e7c89042ce336426d465dbd`
|
||||
|
||||
## 测试与真实 staging 结果
|
||||
|
||||
已通过:
|
||||
|
||||
```text
|
||||
pytest -q tests/test_minimum_autonomous_loop.py tests/test_action_contract_regressions.py tests/test_bridge_v2_governed_http_bootstrap.py
|
||||
21 passed
|
||||
python3 -m py_compile ... # changed runtime modules
|
||||
git diff --check
|
||||
```
|
||||
|
||||
完整 mandatory suite(使用仓库需要的 `PYTHONPATH=.:services/aios-release-controller`)结果:
|
||||
|
||||
`724 passed, 2 skipped, 2 failed, 154 subtests passed`。
|
||||
|
||||
两个失败与本轮改动无关,和修改前已知基线一致:`tests/test_agent_event_bus.py::test_unified_event_flow` 受残留 `/tmp/aios-agent-events-test` 数据污染;`tests/test_signer_contract_compatibility.py::SignerContractCompatibilityTests::test_real_signer_help_and_self_test_are_available` 的已部署 signer self-test 返回码为 `42`。
|
||||
|
||||
已验证:
|
||||
|
||||
- `aios-chatgpt-bridge-v2-staging.service`: restart 后 active,ExecStart 指向 source `9f32b26...` runtime,PID `2061371`。
|
||||
- `aios-gitops-model-api-v2r2c1-staging.service`: restart 后 active,ExecStart 指向 source `9f32b26...` runtime,PID `2061686`。
|
||||
- `/api/health` 与 `/v2/runtime`: restart 后 HTTP 200,`STAGING`、`production_deployed=false`、runtime OID `9f32b26...`、artifact SHA `ff2031...`,`drift_status=PASS`。
|
||||
- 实际 staging `/api/openapi-chatgpt-actions.yaml`: HTTP 200,版本 `V2R2C1-chatgpt-actions-28ops`,28 个 `operationId`,含 `readRepositoryFile`、`acquireWriterLease`、`commitFilesGoverned`;`commitFilesGoverned.files` required 且每项 required `path`、`content_base64`。
|
||||
- 无 Bearer 的 model-api read 请求和 loopback Bridge read 请求均返回 HTTP 401;writes runtime flag 保持 false。
|
||||
- systemd effective User/Group 为 `aiman:gitops`;Gitea API 来源为 staging service effective `AIOS_GITOPS_GITEA_API_URL=https://g.cnnetmkt.top/git/api/v1` 与 Bridge `AIOS_GITEA_API_BASE=https://g.cnnetmkt.top/git/api/v1`,不是业务代码硬编码。
|
||||
- effective silent-release root 为 `/var/lib/aios-gitops/model-api-staging/silent-release`,ownership/mode 为 `aiman:gitops 750`,父目录同为 `aiman:gitops 750`;旧报错路径 `/var/lib/aios-gitops/silent-release` 不存在,未被错误地当作当前 runtime 路径。
|
||||
- systemd 使用 `LoadCredential=action_tokens:/data/aios-gitops/runtime/secrets/gitops.tokens` 和 `%d/action_tokens` 投影;registry 文件 `root:root 600`,未打印或复制 bearer。
|
||||
- 当前 token registry 是 hash-only 记录;本次没有打印、重用 Gitea/SSH/service token,也没有启用 dev auth bypass。
|
||||
|
||||
重启后的 service restart acceptance 已完成:URL、权限、Action schema、version parser 代码产物和 writes=false 均保持有效;production service 未重启。
|
||||
|
||||
未通过/未执行:
|
||||
|
||||
- 无法执行正式 bearer 下的 `read -> lease -> commitFilesGoverned`,因此没有伪造两文件 commit OID、lease USED、Gitea Git/API readback 或 idempotency replay 证据。
|
||||
- 未执行临时 writes=true governed commit;staging 最终保持 `AIOS_GITOPS_WRITES_ENABLED=false`。
|
||||
- 未宣称完整 Action smoke、restart 后 governed commit 或 `TASK_VERIFIED`。
|
||||
|
||||
## 保护边界
|
||||
|
||||
本轮只安装 staging artifact/drop-in;production service 未重启。`main`、`V2R2C1` tag、baseline target/evidence closure 未写入。两个既有 untracked 目录 `runtime/agent-observability/`、`runtime/task-center/` 原样保留。
|
||||
|
||||
## 剩余风险与下一步
|
||||
|
||||
主要闭环阻塞是:需要 Owner/正式 Action broker 提供当前有效 bearer(不在聊天中传递)。此外 mandatory suite 仍有上述两个已知环境/部署失败。拿到 bearer 后应从当前 branch HEAD 重新读取 source OID,按 runbook 执行一次真实两文件 governed commit、Gitea 三方 OID readback、idempotency/stale-OID 负例、staging restart 后复测;在此之前 Task 必须保持 `BLOCKED/NEEDS_REWORK`,不能标记 `SUCCEEDED`。
|
||||
@@ -0,0 +1,224 @@
|
||||
# AIOS Action OpenAPI 操作数上限修复证据
|
||||
|
||||
## 结论
|
||||
|
||||
staging 上的 `https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml` 已从 32 个
|
||||
实际 OpenAPI operations 收缩到 26 个,低于 ChatGPT Action 的 30 操作限制。
|
||||
公网重新抓取返回 HTTP 200,schema version 为
|
||||
`V2R2C1-chatgpt-actions-26ops`。`commitFilesGoverned` 仍是公开的批量 governed
|
||||
writer,并且现在在 OpenAPI 中显式要求 `bearerAuth`、标记为
|
||||
`x-openai-isConsequential: true`,required `files` 契约保持一致。
|
||||
|
||||
本次只修改 branch/staging overlay;没有写入 production service、main、release
|
||||
tag 或 baseline registry。ChatGPT Action 编辑器仍需要重新导入或刷新这个 URL,
|
||||
因为客户端可能缓存旧的 32-operation schema。
|
||||
|
||||
## 修改前事实
|
||||
|
||||
- Repository:`aios/aios`
|
||||
- Branch:`agent/fix-action-contracts-20260825-1407`
|
||||
- 修复前 branch tip:`767c7ff6a975fe18a701b95cd6dacca8600eddc0`
|
||||
- 本轮最终 HEAD:`7e5525cfcbbaa3898c2e5ed3dc9c13e321908e61`
|
||||
- staging model-api unit:`aios-gitops-model-api-v2r2c1-staging.service`
|
||||
- staging model-api port:`127.0.0.1:18893`
|
||||
- staging Bridge port:`127.0.0.1:18892`
|
||||
- systemd Gitea API URL:`https://g.cnnetmkt.top/git/api/v1`
|
||||
- 当前 service User/Group:`aiman:gitops`
|
||||
- 修复前公网 schema:HTTP 200,24,721 bytes,SHA256
|
||||
`a0775b0719a76e08a95f3257a3d3b2a73b70e1c55c162914250f80dd28da022d`;30 paths、
|
||||
32 operations。两个 path 同时有多个 method,因此 path 数不是 operation 数。
|
||||
- 修复前触发错误:`OpenAPI 规范最多可以包含 30 个操作`。
|
||||
|
||||
## 根因与决策
|
||||
|
||||
根因不是 URL host 或 YAML 传输错误,而是公开 Action projection 中混入了旧式
|
||||
Git 写原语和同义 approval alias。公开 schema 同时暴露 legacy primitives 与
|
||||
`commitFilesGoverned`,最终 operation 数为 32。
|
||||
|
||||
本次投影收缩为 26 个 operation:
|
||||
|
||||
- 先前修复中移除粗粒度旧别名 `POST /api/file` (`commitFile`);
|
||||
- 先前修复中移除当前 isolated-writer policy 明确禁止的 `POST /api/commit`
|
||||
(`commitChanges`);
|
||||
- 本轮移除 `submitSignedApproval`,因为它是 `submitOwnerApproval` 的 alias;
|
||||
- 本轮从 Actions projection 隐藏 `createPatch`、`createBranch`、
|
||||
`commitFileAtomic` 三个旧式 Git 写原语。runtime 兼容处理没有删除,canonical
|
||||
对外批量写入为 `/v2/commit-files`;
|
||||
- 保留 release candidate、release consistency、release acceptance、tag
|
||||
publication status、release saga、getDiff 和 recovery 相关既有能力,满足本任务
|
||||
的既有 smoke contract。尚未实现的 `getReleaseSnapshot`/`getReleaseVerification`
|
||||
聚合 API 不在本次冒险重构范围内。
|
||||
|
||||
此外,`commitFilesGoverned` 的 runtime fragment 生成器现在生成:
|
||||
|
||||
- `security: [{bearerAuth: []}]`;
|
||||
- `x-openai-isConsequential: true`;
|
||||
- `files.maxItems: 50`;
|
||||
- `idempotency_key` 的长度和字符约束。
|
||||
|
||||
## 修改文件与提交
|
||||
|
||||
- `docs/gitops/openapi-chatgpt-actions.yaml`:canonical Action projection,26-op
|
||||
allowlist、移除 legacy/alias、补齐 batch writer security。
|
||||
- `active-runtime/app/aios_chatgpt_bridge_v2.py`:runtime contract fragment 的
|
||||
单一生成来源与公开 security/batch constraints。
|
||||
- `tests/test_action_contract_regressions.py`:26-operation gate、legacy operation
|
||||
negative assertions、runtime/schema parity、bearer/consequential assertions。
|
||||
- `runbooks/action-openapi-operation-limit-20260825.md`:操作上限、projection
|
||||
allowlist、构建和刷新手册。
|
||||
- `ops/gitops-migration/bind_external_action_surface_final_staging.sh` 及先前的
|
||||
staging binding 测试/报告:本任务分支已将 `/v2/` 公开路由绑定到 staging Bridge
|
||||
`127.0.0.1:18892`,没有使用 production `18920`。
|
||||
|
||||
提交:
|
||||
|
||||
- `4313f3e44a1e4be69d2608fb1fc18833aa0c82e8`
|
||||
`fix(action): keep public schema within operation limit`
|
||||
- `7e5525cfcbbaa3898c2e5ed3dc9c13e321908e61`
|
||||
`fix(action): project governed core within 26 operations`
|
||||
|
||||
保留的未跟踪目录 `runtime/agent-observability/`、`runtime/task-center/` 未被加入
|
||||
提交或删除。
|
||||
|
||||
## 测试结果
|
||||
|
||||
修改后 focused regression:
|
||||
|
||||
```text
|
||||
13 passed in 0.15s
|
||||
```
|
||||
|
||||
包含既有 Action/release/staging focused tests:
|
||||
|
||||
```text
|
||||
32 passed, 11 subtests passed in 0.36s
|
||||
```
|
||||
|
||||
mandatory suite:
|
||||
|
||||
```text
|
||||
264 passed, 28 subtests passed in 4.79s
|
||||
```
|
||||
|
||||
完整 suite:
|
||||
|
||||
```text
|
||||
721 passed, 2 skipped, 2 failed, 154 subtests passed in 34.02s
|
||||
```
|
||||
|
||||
两个失败与本次变更无关且在修改前已复现:
|
||||
|
||||
1. `tests/test_agent_event_bus.py::test_unified_event_flow` 使用了残留的
|
||||
`/tmp/aios-agent-events-test`,已有 1 个旧 event,断言得到 2;
|
||||
2. `tests/test_signer_contract_compatibility.py::test_real_signer_help_and_self_test_are_available`
|
||||
的已部署 signer `self-test` 返回 42。`sign --help` 仍正常,live signer integration
|
||||
另行 skip。
|
||||
|
||||
没有出现由本次 schema/runtime 改动引入的 mandatory regression。
|
||||
|
||||
## staging 构建与部署
|
||||
|
||||
使用仓库现有流程:
|
||||
|
||||
```text
|
||||
scripts/build_v2r2c1_model_api_staging.py
|
||||
source_oid: 7e5525cfcbbaa3898c2e5ed3dc9c13e321908e61
|
||||
artifact: /data/aios-gitops/artifacts/action-openapi-limit-20260825-7e5525cf/aios-v2r2c1-model-api-7e5525cfcbbaa3898c2e5ed3dc9c13e321908e61.tar.gz
|
||||
artifact_sha256: 2163c2e0467f3bd0a97f8d2b95e3277391c47dcf3ebafb8fa372a01c3f530ac0
|
||||
deployment_state: STAGING
|
||||
```
|
||||
|
||||
systemd 新 drop-in 持久化了 artifact/runtime identity、Gitea URL、`STAGING`、
|
||||
`AIOS_GITOPS_WRITES_ENABLED=false`、`aiman:gitops` 和精确的 read/write paths。
|
||||
当前生效 runtime:
|
||||
|
||||
```text
|
||||
/opt/aios/gitops-model-api/7e5525cfcbbaa3898c2e5ed3dc9c13e321908e61-action-openapi-limit/runtime
|
||||
```
|
||||
|
||||
silent-release 权限在两次重启后均为:
|
||||
|
||||
```text
|
||||
/var/lib/aios-gitops/model-api-staging/silent-release aiman:gitops 750
|
||||
ACL: user::rwx, group::r-x, other::---
|
||||
```
|
||||
|
||||
部署前的原生 final drop-in 已备份至:
|
||||
|
||||
```text
|
||||
/data/aios-gitops/backups/action-openapi-limit-deploy-20260825T112700Z/
|
||||
```
|
||||
|
||||
第一次 restart 后 MainPID 为 `1775067`;第二次按要求 restart 后 MainPID 为
|
||||
`1788253`,两次均 active/running。
|
||||
|
||||
## 公网 Action smoke
|
||||
|
||||
最终公网抓取:
|
||||
|
||||
```text
|
||||
HTTP 200
|
||||
Content-Type: application/yaml; charset=utf-8
|
||||
bytes: 21553
|
||||
SHA256: 798cac0ebd40097c63e5e01ab52c9fdbc255707c4f3820b77161c83349cd5199
|
||||
info.version: V2R2C1-chatgpt-actions-26ops
|
||||
paths: 25
|
||||
operations: 26
|
||||
```
|
||||
|
||||
关键 operation contract:
|
||||
|
||||
```text
|
||||
commitFilesGoverned required:
|
||||
task_id, repository, expected_branch_oid, target_branch, generation,
|
||||
idempotency_key, files, message
|
||||
files item required: path, content_base64
|
||||
security: bearerAuth
|
||||
x-openai-isConsequential: true
|
||||
```
|
||||
|
||||
restart 后复测:
|
||||
|
||||
- `/api/health`:200,`status=ok`,`deployment_state=STAGING`,runtime OID 为
|
||||
`7e5525cf…`,artifact SHA 与 receipt 一致,`production_deployed=false`;
|
||||
- `/v2/runtime`:200,active,Bridge schema `aios.chatgpt-gitops-bridge.v2`;
|
||||
- `/v2/capabilities`:200,governed writes enabled、legacy/main/baseline writes
|
||||
false;
|
||||
- `/api/release-consistency?version=V2R2C1...`:200,`status=PASS`,包含
|
||||
Gitea listing/tag/OID readback;
|
||||
- `/api/release-acceptance?version=V2R2C1...`:200,进入 policy evaluation,
|
||||
`accepted=false` 是既有 acceptance policy 结果,不是 URL 校验错误;
|
||||
- `/api/tag-publication-status?version=V2R2C1`:200,结构化 `UNKNOWN` 状态;
|
||||
- `/getReleaseSaga?candidate_id=nonexistent-action-limit-20260825`:404,结构化
|
||||
`NOT_FOUND`/empty-state,不是权限错误;
|
||||
- empty diff:200,`total_size_bytes=0`、`truncated=false`;
|
||||
- non-empty diff:200,31 changed paths、208,639 bytes;
|
||||
- large diff:200,732 changed paths、3,577,251 bytes、`truncated=true`、patch
|
||||
bounded at 262,144 bytes,无 transport exception。
|
||||
|
||||
保护边界复测:
|
||||
|
||||
```text
|
||||
refs/heads/main = 3244f0f17f8845592ac94d585f5ebd65693c9f29
|
||||
refs/tags/V2R2C1 = 07fc2746b688f5f243f29b39de39f32edc3dd9d2
|
||||
service writes_enabled = false
|
||||
service deployment_state = STAGING
|
||||
```
|
||||
|
||||
main/tag/baseline registry/production 未被本次部署写入。
|
||||
|
||||
## 剩余风险与后续动作
|
||||
|
||||
- ChatGPT 客户端是否已刷新不能由 staging 服务端证明;请在 Action 编辑器重新导入
|
||||
URL。若仍显示旧数量,删除旧 schema 后重新导入,确认 `V2R2C1-chatgpt-actions-26ops`。
|
||||
- 完整 suite 的两个环境失败仍待独立清理 `/tmp` fixture 和修复 signer self-test;
|
||||
它们不是本次 Action schema 修复的回归。
|
||||
- 未来若实现正式 `getReleaseSnapshot`、`getReleaseVerification` 和 governed task
|
||||
Action projection,应单独设计/测试,不应通过隐藏本任务仍要求的 release read paths
|
||||
来制造假收缩。
|
||||
|
||||
## 状态
|
||||
|
||||
`STAGING_REPAIR_PASS`:公网 staging schema、runtime、release/Gitea readback、
|
||||
restart 后 smoke 和保护边界均通过;production 未触碰。外部 ChatGPT Action UI
|
||||
刷新仍是客户端侧动作,不作为服务端部署失败。
|
||||
@@ -0,0 +1,21 @@
|
||||
# Runbook Addendum — External Action Connector Schema Refresh
|
||||
|
||||
This addendum is staging-only and applies to the `fix-action-contracts-20260825-1407` closure.
|
||||
|
||||
## Contract source
|
||||
|
||||
Use `active-runtime/app/aios_chatgpt_bridge_v2.py` as the runtime source of required fields. Use `render_openapi_contract_fragment()` and `docs/gitops/openapi-chatgpt-actions.yaml` for the generated/published projection. `commitFilesGoverned` must require `files`, and each item must require `path` and `content_base64`.
|
||||
|
||||
The public OpenAPI server is the site root and its Bridge v2 paths are `/v2/...`. Install the route with `ops/gitops-migration/bind_external_action_surface_final_staging.sh`; it must target `127.0.0.1:18892`, preserve the Authorization headers, pass `nginx -t`, reload Nginx, and never target production `18920`.
|
||||
|
||||
## Connector lifecycle gate
|
||||
|
||||
The external Action connector must be refreshed through its supported registration or re-registration lifecycle. Record the connector identity, schema source URL, fetched schema digest, cache invalidation or registration acknowledgement, and a callable-tool inspection showing required `files`. Do not make a local fixture or a second hand-written schema a substitute for this evidence.
|
||||
|
||||
If the connector registration API, cache lifecycle, callable inspection, or formal staging Action bearer is unavailable, stop at `PENDING_EXTERNAL_CONNECTOR` and record `FORMAL_STAGING_ACTION_BEARER_ISSUANCE_NOT_AVAILABLE`. Never reuse Gitea, SSH, task-control, Writer, or old token credentials.
|
||||
|
||||
## Acceptance
|
||||
|
||||
After connector refresh, use the actual callable Action once to submit two files through `commitFilesGoverned` on an isolated `agent/*` branch. Verify the task/lease lineage, Authority ref, Git remote ref, and Gitea API branch OID are equal. Read the durable job, require `COMPLETED`, then call terminal reconcile and require an idempotent successful read-back. Restart only staging Action/Bridge/model services and repeat callable schema inspection and the batch/reconcile smoke.
|
||||
|
||||
Do not mark `SUCCEEDED` until the callable schema and batch evidence exist. If refresh breaks health, routes, auth, schema, or protected-ref boundaries, restore the recorded Nginx backup and previous staging artifact through the governed deployment procedure, then diagnose.
|
||||
@@ -0,0 +1,58 @@
|
||||
# ChatGPT Action OpenAPI 操作数上限运行手册
|
||||
|
||||
## 目的
|
||||
|
||||
`https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml` 必须保持不超过 ChatGPT
|
||||
Action 导入支持的 30 个实际操作;当前投影硬门槛收紧为 26,为未来治理、诊断
|
||||
和任务协议保留余量。OpenAPI 中同一路径的多个 HTTP method 分别计数;路径数量
|
||||
不等于操作数量。
|
||||
|
||||
## 单一来源与构建
|
||||
|
||||
规范唯一来源是 `docs/gitops/openapi-chatgpt-actions.yaml`。模型 API staging
|
||||
构建脚本 `scripts/build_v2r2c1_model_api_staging.py` 将该文件复制到 immutable
|
||||
artifact,并同时校验 `/v2/commit-files` runtime contract。禁止直接编辑已部署
|
||||
artifact 或 production 文件。
|
||||
|
||||
变更后至少执行:
|
||||
|
||||
```bash
|
||||
python3 - <<'PY'
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
doc = yaml.safe_load(Path('docs/gitops/openapi-chatgpt-actions.yaml').read_text())
|
||||
methods = {'get', 'post', 'put', 'patch', 'delete', 'head', 'options'}
|
||||
operations = [(p, m) for p, item in doc['paths'].items() for m in item if m in methods]
|
||||
assert len(operations) <= 26, len(operations)
|
||||
print(f'paths={len(doc["paths"])} operations={len(operations)}')
|
||||
PY
|
||||
PYTHONPATH=active-runtime/app python3 -m pytest -q tests/test_action_contract_regressions.py
|
||||
```
|
||||
|
||||
## 公开面选择
|
||||
|
||||
公开 schema 保留受治理的 `/v2/commit-files` 以及 release、
|
||||
tag publication、consistency、acceptance、saga 和 diff 操作。粗粒度旧别名
|
||||
`/api/file`、`/api/commit`、`/api/branch`、`/api/patch` POST、`/api/commit-file`
|
||||
以及 `/submitSignedApproval` 等旧别名/旧写原语不得重新加入公开 Action schema;
|
||||
批量写入使用 `/v2/commit-files`。完整 runtime 兼容路由不因此删除。
|
||||
|
||||
`commitFilesGoverned` 必须显式声明 `security: [{bearerAuth: []}]` 和
|
||||
`x-openai-isConsequential: true`;schema 与 Bridge runtime 的 required fields、
|
||||
batch limits 必须由同一生成链维护。
|
||||
|
||||
## 部署与复验
|
||||
|
||||
只使用 `scripts/build_v2r2c1_model_api_staging.py` 生成 staging artifact,保留
|
||||
当前 systemd drop-in 备份,更新 staging model-api 的 artifact 路径后执行
|
||||
`daemon-reload` 和 staging unit restart。部署后从公网重新抓取 schema,验证:
|
||||
|
||||
- HTTP 200,Content-Type 为 YAML;
|
||||
- 实际操作数为 30 或更少;
|
||||
- `commitFilesGoverned` 仍包含 required `files`,每项包含 `path`、`content_base64`;
|
||||
- `/v2/runtime`、capabilities、release consistency/acceptance、tag status、
|
||||
saga empty-state、diff small/empty/large smoke 均正常;
|
||||
- staging unit active,production/main/tag/baseline 未写入。
|
||||
|
||||
Action 客户端可能缓存旧规范。公网 schema 复验通过后,在 ChatGPT Action 编辑器
|
||||
重新导入该 URL 或执行刷新;服务端无法替代客户端已保存的旧 schema cache。
|
||||
@@ -215,3 +215,11 @@ The runtime configuration is staging-only: `User=aiman`, `Group=gitops`, API bas
|
||||
The release executor now binds both the configured staging baseline and the immutable runtime package's `versions/V2R2C1.json` projection. The bridge passes `AIOS_GITOPS_BASELINE_AUTHORITY` into `RuntimeConfig`; this prevents release consistency and acceptance from falling back to missing publication-workspace files.
|
||||
|
||||
The final acceptance result is recorded in `reports/action-contract-repair-evidence-20260825.md`. Contract smoke is green, and release consistency is `PASS`; release acceptance is structured policy output but remains `accepted=false` because the pre-existing publication state is `REMOTE_PUSH_PENDING` for the protected `V2R2C1` tag. The task must therefore remain `NEEDS_REWORK` until an authorized owner resolves that independent protected-tag state.
|
||||
|
||||
## External Action Surface final closure attempt (2026-08-25)
|
||||
|
||||
本轮仅部署 staging/non-production;没有直接写入 main�release tag�baseline registry 或 production。
|
||||
|
||||
Public route ownership: /api/governed-write-capabilities, /api/control-plane-snapshot, /api/online-agent-task, and /api/online-agent-task-events are exact-location proxied to 127.0.0.1:18890; generic /api/ remains on 127.0.0.1:18893. The durable installer is ops/gitops-migration/bind_external_action_surface_final_staging.sh; it backs up Nginx, runs nginx -t, reloads, and forwards Authorization/X-AIOS-Token.
|
||||
|
||||
A task-control token, local Writer grant, Gitea consumer, SSH credential, or old token file is not an external Action bearer. If formal issuance is unavailable, do not print or rotate secrets, enable disabled writes, or manufacture commit evidence; record FORMAL_STAGING_ACTION_BEARER_ISSUANCE_NOT_AVAILABLE. Public release-consistency and release-acceptance use the canonical Bridge v2 evaluator. A Task API continuation remains QUEUED until a legitimate Agent claims it and gives durable result evidence.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# AIOS 最小自治修复闭环运行手册
|
||||
|
||||
## 范围
|
||||
|
||||
本手册只覆盖 staging/non-production 的最小链路:
|
||||
|
||||
```text
|
||||
readRepositoryFile -> acquireWriterLease -> commitFilesGoverned
|
||||
-> immutable staging build/reload -> runtime/read-back
|
||||
```
|
||||
|
||||
不覆盖 release/recovery/listPatches 语义,不得写入 `main`、release tag、baseline
|
||||
或 production。
|
||||
|
||||
## Action contract
|
||||
|
||||
公开 Action schema 的 canonical URL 是:
|
||||
`https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml`。
|
||||
|
||||
本轮增加两个 operation,公开面共 28 个,保留 30 个上限余量:
|
||||
|
||||
- `readRepositoryFile`:`POST /readRepositoryFile`,必填 `repository`, `ref`, `path`, `max_bytes`;只从 authoritative bare Git object database 读取,禁止绝对路径、`..`、反斜线、NUL 和 `.git`,单次最多 262144 bytes。
|
||||
- `acquireWriterLease`:`POST /acquireWriterLease`,只接受 `CONTINUE_EXISTING`,必须绑定 durable task、repository、target branch、base OID、当前 expected branch OID、generation 和短 TTL。
|
||||
- `commitFilesGoverned`:既有 `POST /v2/commit-files`,必须先有同一 task/generation/expected OID 的 lease;`files` 为数组,每项必须含 `path` 与 `content_base64`。
|
||||
|
||||
model-api facade 对上述三个入口执行 Action bearer 认证,并把 Authorization/X-AIOS-Token 透传给 loopback Bridge。Bridge 再执行 operation-specific scope:read 允许 `repository:read` 或兼容的 `file:write`,lease 需要 `agent-task:control`,commit 需要 `file:write`。
|
||||
|
||||
## 构建与部署
|
||||
|
||||
从当前隔离 agent branch 的完整 source OID 构建:
|
||||
|
||||
```bash
|
||||
python3 scripts/build_v2r2c1_model_api_staging.py \
|
||||
--source-root . \
|
||||
--output-root /var/lib/aios-gitops/bridge-v2-staging/candidate-artifacts/<id> \
|
||||
--source-oid <40-hex-source-oid>
|
||||
```
|
||||
|
||||
构建 receipt 必须声明 `STAGING`、`aios/aios`、`legacy_global_writes_enabled=false`,并记录 artifact SHA-256。使用 versioned systemd drop-in template,将 source OID、artifact SHA-256 和 immutable runtime path 渲染到 staging 两个 unit;同时使用:
|
||||
|
||||
```ini
|
||||
LoadCredential=action_tokens:/data/aios-gitops/runtime/secrets/gitops.tokens
|
||||
Environment=AIOS_GITOPS_TOKEN_FILE=%d/action_tokens
|
||||
```
|
||||
|
||||
这样由 systemd 投影已有 Action token registry,不复制或打印 token,不使用 Gitea token 代替 Action bearer。部署后只执行:
|
||||
|
||||
```bash
|
||||
systemctl daemon-reload
|
||||
systemctl restart aios-chatgpt-bridge-v2-staging.service
|
||||
systemctl restart aios-gitops-model-api-v2r2c1-staging.service
|
||||
```
|
||||
|
||||
## 验收顺序
|
||||
|
||||
1. `systemctl is-active` 两个 staging unit;读取 `/api/health`、`/v2/runtime`。
|
||||
2. 从公网 URL 重新抓取 YAML,解析 operation 数量,确认 `<=30`,并确认 `files`/`path`/`content_base64` required parity。
|
||||
3. 使用正式 Action bearer:
|
||||
|
||||
```text
|
||||
readRepositoryFile(repository, ref=agent/<branch> or exact OID, path, max_bytes)
|
||||
acquireWriterLease(task_id, repository, CONTINUE_EXISTING, base_oid,
|
||||
expected_branch_oid=<read resolved OID>, target_branch,
|
||||
generation, lease_seconds)
|
||||
commitFilesGoverned(task_id, repository, expected_branch_oid, target_branch,
|
||||
generation, idempotency_key, two files, message)
|
||||
```
|
||||
|
||||
4. 校验 authority branch OID、Gitea Git ref OID、Gitea API branch OID 和 `commit_visible` 一致;重复相同 idempotency key 不得生成第二个 commit,旧 expected OID 必须拒绝。
|
||||
5. 验证 staging runtime source OID/artifact SHA-256 与 build receipt 一致,再重复完整 smoke。
|
||||
6. 若 bearer 不可用,状态必须是 `BLOCKED`,不能用 SSH、root、Gitea、旧 token、service credential 或 dev bypass 代替。
|
||||
|
||||
完整 suite 使用:
|
||||
|
||||
```bash
|
||||
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=.:services/aios-release-controller pytest -p no:cacheprovider -q
|
||||
```
|
||||
|
||||
## 回滚边界
|
||||
|
||||
只回滚本轮创建的 staging artifact/drop-in;不得回滚 production release。若 health、Gitea readback、protected-ref boundary 或 mandatory suite 回归,恢复前一份 staging drop-in,采集 `journalctl -u` 日志,再继续诊断。
|
||||
@@ -28,12 +28,14 @@ REQUIRED_APP = (
|
||||
'aios_continuation_executor.py',
|
||||
'aios_continuation_lease.py',
|
||||
'aios_authority_git_executor.py',
|
||||
'scoped_lease_store.py',
|
||||
'aios_gitops_reconciliation_core.py',
|
||||
'authorized_task_writer_provisioning.py',
|
||||
'signer_contract_compatibility.py',
|
||||
)
|
||||
OPTIONAL_APP = (
|
||||
'aios_chatgpt_actions_api.py',
|
||||
'aios_model_api_staging_list_patches_overlay.py',
|
||||
'aios_continuation_executor.py',
|
||||
'aios_continuation_lease.py',
|
||||
'aios_control_plane_snapshot.py',
|
||||
@@ -117,7 +119,7 @@ def main() -> int:
|
||||
'repository_identity': 'aios/aios',
|
||||
'deployment_state': 'STAGING',
|
||||
'runtime_mode': 'IMMUTABLE_STAGING',
|
||||
'runtime_entrypoint': 'app/aios_model_api_staging.py',
|
||||
'runtime_entrypoint': 'app/aios_model_api_staging_list_patches_overlay.py',
|
||||
'legacy_global_writes_enabled': False,
|
||||
'governed_release_routing': 'bridge-v2',
|
||||
'artifact_sha256': 'provided-by-build-receipt-and-install-manifest',
|
||||
|
||||
@@ -6,6 +6,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
@@ -74,6 +75,57 @@ def test_governed_read_routes_are_bound_to_authenticated_facade():
|
||||
assert "Authorization $http_authorization" in route_script
|
||||
|
||||
|
||||
def test_published_action_v2_paths_are_bound_to_staging_bridge():
|
||||
route_script = (
|
||||
ROOT / "ops/gitops-migration/bind_external_action_surface_final_staging.sh"
|
||||
).read_text()
|
||||
schema = (ROOT / "docs/gitops/openapi-chatgpt-actions.yaml").read_text()
|
||||
assert "servers:" in schema
|
||||
assert "- url: https://cnnetmkt.top" in schema
|
||||
assert " /v2/commit-files:" in schema
|
||||
assert "location ^~ /v2/" in route_script
|
||||
assert "proxy_pass http://127.0.0.1:18892;" in route_script
|
||||
assert "proxy_pass http://127.0.0.1:18920;" not in route_script
|
||||
assert "Authorization $http_authorization" in route_script
|
||||
|
||||
|
||||
def test_published_action_schema_stays_within_chatgpt_operation_limit():
|
||||
schema = yaml.safe_load(
|
||||
(ROOT / "docs/gitops/openapi-chatgpt-actions.yaml").read_text()
|
||||
)
|
||||
operations = [
|
||||
(path, method, operation)
|
||||
for path, item in schema["paths"].items()
|
||||
for method, operation in item.items()
|
||||
if method.lower() in {"get", "post", "put", "patch", "delete", "head", "options"}
|
||||
]
|
||||
assert len(operations) <= 30
|
||||
assert len(operations) == 28
|
||||
assert "commitFilesGoverned" in {
|
||||
operation["operationId"] for _, _, operation in operations
|
||||
}
|
||||
assert {"readRepositoryFile", "acquireWriterLease"}.issubset(
|
||||
{operation["operationId"] for _, _, operation in operations}
|
||||
)
|
||||
assert "/api/file" not in schema["paths"]
|
||||
assert "/api/commit" not in schema["paths"]
|
||||
operation_ids = {operation["operationId"] for _, _, operation in operations}
|
||||
assert {
|
||||
"submitSignedApproval",
|
||||
"createPatch",
|
||||
"createBranch",
|
||||
"commitFileAtomic",
|
||||
"commitFile",
|
||||
"commitChanges",
|
||||
}.isdisjoint(operation_ids)
|
||||
governed = next(
|
||||
operation for _, _, operation in operations
|
||||
if operation["operationId"] == "commitFilesGoverned"
|
||||
)
|
||||
assert governed["security"] == [{"bearerAuth": []}]
|
||||
assert governed["x-openai-isConsequential"] is True
|
||||
|
||||
|
||||
def test_commit_files_runtime_and_published_schema_have_same_required_fields():
|
||||
runtime = operation_contracts()["commitFilesGoverned"]["required_fields"]
|
||||
assert runtime == list(OPERATION_REQUIRED_FIELDS["commitFilesGoverned"])
|
||||
|
||||
@@ -92,7 +92,8 @@ def test_slash_branch_uses_paginated_collection():
|
||||
def test_http_route_contract_is_complete():
|
||||
expected = {
|
||||
"/v2/runtime", "/v2/capabilities", "/v2/governed-task",
|
||||
"/v2/writer-lease", "/v2/commit-file", "/v2/commit-files",
|
||||
"/v2/writer-lease", "/v2/commit-file", "/v2/commit-files",
|
||||
"/v2/source/file",
|
||||
"/v2/job", "/v2/job/resume", "/v2/job/reconcile",
|
||||
"/v2/tag/approval-request",
|
||||
"/v2/tag/publish",
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
MODULE_PATH = (
|
||||
Path(__file__).parents[1]
|
||||
/ "active-runtime"
|
||||
/ "app"
|
||||
/ "governed_production_release.py"
|
||||
)
|
||||
|
||||
|
||||
def _load_module():
|
||||
dependency = types.ModuleType("gitea_tag_publication")
|
||||
for name in (
|
||||
"GiteaTagClient",
|
||||
"GitTagBackend",
|
||||
"PublicationManualOwnerReviewRequired",
|
||||
"PublicationPending",
|
||||
"PublicationReconciliationRequired",
|
||||
"PublicationRequest",
|
||||
"TagPublisher",
|
||||
):
|
||||
setattr(dependency, name, type(name, (), {}))
|
||||
sys.modules["gitea_tag_publication"] = dependency
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"governed_production_release_under_test", MODULE_PATH
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
release_module = _load_module()
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
status = 200
|
||||
|
||||
def __init__(self, body):
|
||||
self._payload = json.dumps(body).encode("utf-8")
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
def _health_body(binding, artifact_sha256):
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": binding.version,
|
||||
"commit_oid": binding.target_oid,
|
||||
"artifact_sha256": artifact_sha256,
|
||||
"deployment_state": "ACTIVE",
|
||||
"production_deployed": True,
|
||||
}
|
||||
|
||||
|
||||
def _run_acceptance(result):
|
||||
executor = object.__new__(release_module.GovernedProductionReleaseExecutor)
|
||||
records = []
|
||||
executor.state = SimpleNamespace(
|
||||
record=lambda *args: records.append(args),
|
||||
)
|
||||
executor.consistency = lambda request: deepcopy(result)
|
||||
return executor.acceptance({}), records
|
||||
|
||||
|
||||
def test_all_checks_true_preserves_health_status_and_accepts():
|
||||
binding = release_module.ReleaseBinding()
|
||||
artifact_sha256 = "a" * 64
|
||||
|
||||
with patch(
|
||||
"urllib.request.urlopen",
|
||||
return_value=FakeResponse(_health_body(binding, artifact_sha256)),
|
||||
):
|
||||
health = release_module._health(
|
||||
"https://example.test/health",
|
||||
binding,
|
||||
{"artifact_sha256": artifact_sha256},
|
||||
)
|
||||
|
||||
assert health["status"] == "ok"
|
||||
assert health["verification_status"] == "ACTIVE_VERIFIED"
|
||||
assert health["http_status"] == 200
|
||||
|
||||
result, records = _run_acceptance(
|
||||
{"status": "PASS", "production_health": health}
|
||||
)
|
||||
assert result["accepted"] is True
|
||||
assert records[-1][0] == "ACCEPTED"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value", "artifact_sha256"),
|
||||
[
|
||||
("commit_oid", "0" * 40, "a" * 64),
|
||||
("artifact_sha256", "b" * 64, "a" * 64),
|
||||
("deployment_state", "STAGING", "a" * 64),
|
||||
],
|
||||
ids=["commit-mismatch", "artifact-mismatch", "deployment-not-active"],
|
||||
)
|
||||
def test_health_conflicts_keep_external_status_and_reject(field, value, artifact_sha256):
|
||||
binding = release_module.ReleaseBinding()
|
||||
body = _health_body(binding, artifact_sha256)
|
||||
body[field] = value
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=FakeResponse(body)):
|
||||
health = release_module._health(
|
||||
"https://example.test/health",
|
||||
binding,
|
||||
{"artifact_sha256": artifact_sha256},
|
||||
)
|
||||
|
||||
assert health["status"] == "ok"
|
||||
assert health["verification_status"] == "CONFLICT"
|
||||
result, _ = _run_acceptance(
|
||||
{"status": "PASS", "production_health": health}
|
||||
)
|
||||
assert result["accepted"] is False
|
||||
|
||||
|
||||
def test_health_unavailable_has_internal_verdict_and_rejects():
|
||||
binding = release_module.ReleaseBinding()
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=TimeoutError("boom")):
|
||||
health = release_module._health(
|
||||
"https://example.test/health",
|
||||
binding,
|
||||
{"artifact_sha256": "a" * 64},
|
||||
)
|
||||
|
||||
assert health["status"] == "UNAVAILABLE"
|
||||
assert health["verification_status"] == "UNAVAILABLE"
|
||||
assert health["error"] == "TimeoutError"
|
||||
result, _ = _run_acceptance(
|
||||
{"status": "PASS", "production_health": health}
|
||||
)
|
||||
assert result["accepted"] is False
|
||||
|
||||
|
||||
def test_acceptance_rejects_when_overall_check_is_not_pass():
|
||||
result, records = _run_acceptance(
|
||||
{
|
||||
"status": "CONFLICT",
|
||||
"production_health": {
|
||||
"status": "ok",
|
||||
"verification_status": "ACTIVE_VERIFIED",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert result["accepted"] is False
|
||||
assert records[-1][0] == "RECONCILIATION_REQUIRED"
|
||||
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OVERLAY = ROOT / "active-runtime" / "app" / "aios_model_api_staging_list_patches_overlay.py"
|
||||
|
||||
|
||||
class StubHandler:
|
||||
def sendj(self, data, code=200):
|
||||
self.sent = (data, code)
|
||||
return self.sent
|
||||
|
||||
|
||||
def load_overlay() -> ModuleType:
|
||||
base = ModuleType("aios_model_api_staging")
|
||||
base.H = StubHandler
|
||||
base.main = lambda: None
|
||||
previous = sys.modules.get("aios_model_api_staging")
|
||||
sys.modules["aios_model_api_staging"] = base
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location("list_patches_overlay_test", OVERLAY)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
finally:
|
||||
if previous is None:
|
||||
sys.modules.pop("aios_model_api_staging", None)
|
||||
else:
|
||||
sys.modules["aios_model_api_staging"] = previous
|
||||
|
||||
|
||||
def test_overlay_keeps_legacy_keys_but_only_emits_bounded_summaries():
|
||||
overlay = load_overlay()
|
||||
records = [
|
||||
{
|
||||
"id": "oversized",
|
||||
"branch": "agent/oversized",
|
||||
"created_at": "2026-08-26T15:00:00Z",
|
||||
"text": "X" * 2_000_000,
|
||||
"body": "B" * 2_000_000,
|
||||
"diff": "D" * 2_000_000,
|
||||
"evidence": {"raw": "E" * 2_000_000},
|
||||
}
|
||||
] + [
|
||||
{"id": f"patch-{index:03d}", "created_at": f"2026-08-25T{index % 24:02d}:00:00Z", "description": "summary"}
|
||||
for index in range(1, 25)
|
||||
]
|
||||
handler = overlay.ListPatchesOverlayHandler.__new__(overlay.ListPatchesOverlayHandler)
|
||||
handler.path = "/api/patch"
|
||||
result = handler.sendj(
|
||||
{
|
||||
"metadata_patches": records,
|
||||
"patches": records,
|
||||
"authoritative_branches": [{"name": "agent/example", "oid": "a" * 40, "secret": "drop"}],
|
||||
"orphan_metadata": records,
|
||||
"branches_without_metadata": records,
|
||||
"branches": [f"agent/{index}" for index in range(40)],
|
||||
"repository_available": True,
|
||||
"query_error": None,
|
||||
}
|
||||
)
|
||||
payload, code = result
|
||||
assert code == 200
|
||||
assert payload["list_patches_overlay"] == "bounded-summary-v1"
|
||||
assert payload["count"] <= overlay.PATCH_DEFAULT_LIMIT
|
||||
assert payload["has_more"] is True
|
||||
assert payload["next_cursor"]
|
||||
encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
assert len(encoded) <= overlay.PATCH_RESPONSE_BUDGET
|
||||
assert all(set(item) <= {
|
||||
"patch_id", "name", "branch", "ref", "status", "risk", "created_at", "updated_at",
|
||||
"commit_oid", "summary", "summary_truncated",
|
||||
} for item in payload["items"])
|
||||
assert "body" not in json.dumps(payload, ensure_ascii=False)
|
||||
assert len(payload["branches"]) <= overlay.PATCH_COMPAT_LIST_LIMIT
|
||||
|
||||
|
||||
def test_overlay_cursor_pagination_and_invalid_limit_are_deterministic():
|
||||
overlay = load_overlay()
|
||||
records = [
|
||||
{"id": f"p-{index:02d}", "created_at": f"2026-08-26T{index:02d}:00:00Z"}
|
||||
for index in range(1, 26)
|
||||
]
|
||||
first = overlay._page(records, {"limit": ["1"]})
|
||||
second = overlay._page(records, {"limit": ["1"], "cursor": [first["next_cursor"]]})
|
||||
assert first["count"] == second["count"] == 1
|
||||
assert first["items"][0]["patch_id"] != second["items"][0]["patch_id"]
|
||||
assert first["has_more"] and second["has_more"]
|
||||
for value in ("0", "101", "not-an-int"):
|
||||
try:
|
||||
overlay._page(records, {"limit": [value]})
|
||||
except ValueError as exc:
|
||||
assert str(exc) == "invalid_limit"
|
||||
else:
|
||||
raise AssertionError("invalid limit accepted")
|
||||
try:
|
||||
overlay._page(records, {"limit": ["1"], "cursor": ["bad"]})
|
||||
except ValueError as exc:
|
||||
assert str(exc) == "invalid_cursor"
|
||||
else:
|
||||
raise AssertionError("invalid cursor accepted")
|
||||
|
||||
|
||||
def test_overlay_delegates_non_patch_routes_unchanged():
|
||||
overlay = load_overlay()
|
||||
handler = overlay.ListPatchesOverlayHandler.__new__(overlay.ListPatchesOverlayHandler)
|
||||
handler.path = "/api/health"
|
||||
result = handler.sendj({"status": "ok"}, 200)
|
||||
assert result == ({"status": "ok"}, 200)
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
|
||||
from aios_authority_git_executor import AuthorityGitError, AuthorityGitExecutor
|
||||
from aios_chatgpt_bridge_v2 import OPERATION_REQUIRED_FIELDS, operation_contracts
|
||||
from scoped_lease_store import ScopedLeaseStore, ScopedLeaseError
|
||||
|
||||
|
||||
def _fixture_repo(tmp_path: Path) -> tuple[Path, str]:
|
||||
bare = tmp_path / "repo.git"
|
||||
source = tmp_path / "source"
|
||||
subprocess.run(["git", "init", "--bare", str(bare)], check=True, capture_output=True)
|
||||
subprocess.run(["git", "init", str(source)], check=True, capture_output=True)
|
||||
(source / "README.md").write_text("fixture\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(source), "add", "README.md"], check=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(source), "-c", "user.name=test", "-c", "user.email=test@example.invalid", "commit", "-m", "fixture"],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
oid = subprocess.check_output(["git", "-C", str(source), "rev-parse", "HEAD"], text=True).strip()
|
||||
subprocess.run(["git", "-C", str(source), "push", str(bare), "HEAD:refs/heads/main"], check=True, capture_output=True)
|
||||
return bare, oid
|
||||
|
||||
|
||||
def test_read_repository_file_is_bounded_and_ref_scoped(tmp_path):
|
||||
bare, oid = _fixture_repo(tmp_path)
|
||||
executor = AuthorityGitExecutor(bare, tmp_path / "worktrees")
|
||||
result = executor.read_file("main", "README.md", 1024)
|
||||
assert result["resolved_commit_oid"] == oid
|
||||
assert base64.b64decode(result["content_base64"]) == b"fixture\n"
|
||||
assert result["read_only"] is True
|
||||
with pytest.raises(AuthorityGitError, match="SOURCE_PATH_INVALID"):
|
||||
executor.read_file("main", "../README.md", 1024)
|
||||
with pytest.raises(AuthorityGitError, match="SOURCE_FILE_TOO_LARGE"):
|
||||
executor.read_file("main", "README.md", 1)
|
||||
with pytest.raises(AuthorityGitError, match="SOURCE_REF_SCOPE_DENIED"):
|
||||
executor.read_file("refs/remotes/origin/main", "README.md", 1024)
|
||||
|
||||
|
||||
def test_runtime_and_published_contract_keep_read_and_commit_fields_explicit():
|
||||
contracts = operation_contracts()
|
||||
assert set(OPERATION_REQUIRED_FIELDS["readRepositoryFile"]) == {"repository", "ref", "path", "max_bytes"}
|
||||
assert contracts["commitFilesGoverned"]["required_fields"] == list(OPERATION_REQUIRED_FIELDS["commitFilesGoverned"])
|
||||
assert "files" in contracts["commitFilesGoverned"]["required_fields"]
|
||||
|
||||
|
||||
def test_scoped_lease_is_idempotent_and_rejects_cross_binding(tmp_path):
|
||||
store = ScopedLeaseStore(tmp_path / "leases.sqlite3")
|
||||
request = {
|
||||
"schema": "aios.task-writer-lease.v2",
|
||||
"task_id": "task-min-loop",
|
||||
"repository": "aios/aios",
|
||||
"branch_mode": "CONTINUE_EXISTING",
|
||||
"base_oid": "a" * 40,
|
||||
"expected_branch_oid": "a" * 40,
|
||||
"target_branch": "agent/min-loop",
|
||||
"generation": 1,
|
||||
"lease_seconds": 60,
|
||||
}
|
||||
first, created = store.acquire(request)
|
||||
assert created is True and first["state"] == "ACTIVE"
|
||||
replay, created = store.acquire(request)
|
||||
assert created is False and replay["lease_key"] == first["lease_key"]
|
||||
conflict = dict(request, expected_branch_oid="b" * 40)
|
||||
with pytest.raises(ScopedLeaseError, match="LEASE_BINDING_CONFLICT"):
|
||||
store.acquire(conflict)
|
||||
|
||||
Reference in New Issue
Block a user