Compare commits

..
30 changed files with 744 additions and 1352 deletions
+16 -4
View File
@@ -22,6 +22,7 @@ from gitea_tag_publication import (
TagPublisher,
redact,
validate_version,
validate_gitea_api_url,
)
from orchestrator.action_monitor import ActionMonitor
from silent_release_hardening import (
@@ -308,8 +309,7 @@ def resolve_commit_oid(value: str | None, field: str = 'target_oid') -> str:
raise ValueError(f'{field} does not resolve to the exact approved commit') from exc
def existing_tag_target(tag: str) -> str | None:
if not re.fullmatch(r'V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$', str(tag or '')):
raise ValueError('invalid release version')
validate_version(tag)
try:
result = run_authoritative_git(
['rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'],
@@ -510,7 +510,15 @@ class H(BaseHTTPRequestHandler):
return self.sendj({'error':redact(exc)},413)
except (AuthoritativeGitError, RuntimeError) as exc:
return self.sendj({'error':redact(exc)},503)
return self.sendj({'from':frm,'to':to,'diff':diff})
payload = {'from': frm, 'to': to, 'diff': diff}
payload.update({
key: diff[key]
for key in (
'from_oid', 'to_oid', 'changed_paths', 'stats',
'total_size_bytes', 'truncated', 'patch_limit_bytes',
)
})
return self.sendj(payload)
if path in ('/api/patch','/api/patches','/api/branches','/patch'):
snapshot = repository_snapshot(
git_dir=AUTHORITATIVE_GIT_DIR,
@@ -773,7 +781,10 @@ class H(BaseHTTPRequestHandler):
a=self.need('tag:create');
if not a: return
ver=data.get('version') or data.get('tag')
if not re.match(r'^V\d+R\d+C\d+(-[A-Za-z0-9_.-]+)?$', ver or ''): return self.sendj({'error':'version must match VxRxCx'},400)
try:
validate_version(ver)
except ValueError:
return self.sendj({'error':'version must match VxRxCx'},400)
if not data.get('target_oid') or not data.get('evidence_closure_oid') or not data.get('approval_plan_id'):
return self.sendj({
'error':'production_tag_evidence_required',
@@ -947,6 +958,7 @@ def api_spec():
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl','POST /createReleaseCandidate','POST /submitOwnerApproval','POST /startDeployment'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True,'silent_release_execution_enabled':SILENT_RELEASE_EXECUTION_ENABLED,'silent_release_shadow_default':True}}
def main():
validate_gitea_api_url(os.getenv('AIOS_GITOPS_GITEA_API_URL', ''))
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
if __name__ == '__main__': main()
@@ -46,6 +46,75 @@ OPERATION_REQUIRED_FIELDS = {
"getControlPlaneSnapshot": (),
}
_OPERATION_PATHS = {
"createGovernedTask": "/v2/governed-task",
"acquireWriterLease": "/v2/writer-lease",
"commitFileGoverned": "/v2/commit-file",
"commitFilesGoverned": "/v2/commit-files",
"getGitOpsJob": "/v2/job",
"resumeGitOpsJob": "/v2/job/resume",
"reconcileGitOpsJob": "/v2/job/reconcile",
"getTagPublicationStatus": "/v2/tag/status",
"getReleaseConsistency": "/v2/release/consistency",
"runReleaseAcceptance": "/v2/release/acceptance",
}
def operation_contracts() -> dict[str, dict[str, object]]:
"""Return the runtime contract consumed by adapters and schema tooling."""
return {
operation: {
"method": "POST",
"path": _OPERATION_PATHS.get(operation),
"required_fields": list(fields),
}
for operation, fields in OPERATION_REQUIRED_FIELDS.items()
}
def render_openapi_contract_fragment() -> str:
required = "\n".join(
f" - {field}"
for field in OPERATION_REQUIRED_FIELDS["commitFilesGoverned"]
)
return (
" /v2/commit-files:\n"
" post:\n"
" operationId: commitFilesGoverned\n"
" summary: Submit one atomic governed commit containing multiple files\n"
" requestBody:\n"
" required: true\n"
" content:\n"
" application/json:\n"
" schema:\n"
" type: object\n"
" additionalProperties: false\n"
" required:\n"
f"{required}\n"
" properties:\n"
" task_id: {type: string}\n"
" repository: {type: string, example: aios/aios}\n"
" 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"
" files:\n"
" type: array\n"
" minItems: 1\n"
" items:\n"
" type: object\n"
" additionalProperties: false\n"
" required: [path, content_base64]\n"
" properties:\n"
" path: {type: string, minLength: 1, maxLength: 512}\n"
" content_base64: {type: string}\n"
" message: {type: string, minLength: 1, maxLength: 200}\n"
" responses:\n"
" '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"
)
class RuntimeDriftError(RuntimeError):
pass
@@ -122,6 +191,7 @@ class BridgeV2Adapter:
"legacy_writes_enabled": False,
"governed_writes_enabled": True,
"operations": list(BRIDGE_OPERATIONS),
"operation_contracts": operation_contracts(),
"force_push": False,
"main_write": False,
"tag_write": False,
@@ -14,10 +14,11 @@ from typing import Any, Mapping
from aios_authority_git_executor import AuthorityGitExecutor
from aios_chatgpt_bridge_v2 import BridgeV2Adapter, BridgeV2Error, OPERATION_REQUIRED_FIELDS
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 aios_control_plane_snapshot import build_control_plane_snapshot
from aios_gitops_job_controller import GitOpsJobController, JobStore
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
@@ -52,7 +53,7 @@ class RuntimeConfig:
ssh_key_path: Path
remote_name: str = "gitea-http"
repository: str = "aios/aios"
api_base: str = "https://g.cnnetmkt.top/git/api/v1"
api_base: str = ""
v2_task_id: str = "TASK-P0-CHATGPT-BRIDGE-V2-CONTROL-PLANE-CONVERGENCE-20260809"
v2_branch: str = "agent/p0-chatgpt-bridge-v2-control-plane-convergence-20260809"
target_version: str = "V2R2C1"
@@ -280,6 +281,8 @@ class GovernedRuntime:
return controller.store._receipt(row, duplicate=True).as_dict()
if action == "resume":
return controller.resume(job_id).as_dict()
if JobState(row["state"]) not in _RECOVERABLE:
return controller.store._receipt(row, duplicate=True).as_dict()
return self._executor_from_row(row, controller).reconcile(job_id)
def _snapshot_task(self) -> dict[str, Any]:
@@ -418,7 +421,7 @@ def main() -> None:
token_path,
ssh_key_path,
remote_name=os.environ.get("AIOS_GITEA_REMOTE", "gitea-http"),
api_base=os.environ.get("AIOS_GITEA_API_BASE", "https://g.cnnetmkt.top/git/api/v1"),
api_base=validate_gitea_api_url(os.environ.get("AIOS_GITEA_API_BASE", "")),
target_version=os.environ.get("AIOS_CONTROL_PLANE_TARGET_VERSION", "V2R2C1"),
)
runtime = GovernedRuntime(config)
@@ -1,748 +0,0 @@
#!/usr/bin/env python3
"""Server-local governed immutable Host seed executor.
This executor is deliberately not a general-purpose privileged command runner.
It polls one fixed Git ref/path, verifies a high-risk governed-task binding as
the durable authorization evidence, materializes one exact candidate from the
authoritative Git object database plus a declared patch/manifest, and atomically
publishes the immutable release tree.
It never starts/restarts services, never creates the continuation Task/Job,
never writes Git refs, and never switches Production.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
import argparse
import fcntl
import hashlib
import json
import os
import pwd
from pathlib import Path, PurePosixPath
import re
import shutil
import stat
import subprocess
import tempfile
from typing import Any, Mapping
REQUEST_SCHEMA = "aios.control-plane-host-seed-request.v1"
MANIFEST_SCHEMAS = {
"aios.control-plane-immutable-candidate.v5",
"aios.control-plane-immutable-candidate.v6",
}
OPERATION = "HOST_SEED_IMMUTABLE_CANDIDATE"
REPOSITORY = "aios/aios"
REQUEST_REF = "refs/heads/agent/host-seed-authorization-requests"
REQUEST_PATH = "control-plane/host-seed/request.json"
OID_RE = re.compile(r"^[0-9a-f]{40}$")
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
TASK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
RELEASE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{3,191}$")
CANDIDATE_RE = re.compile(r"^V[0-9]+$")
MAX_REQUEST_BYTES = 128 * 1024
MAX_MANIFEST_BYTES = 4 * 1024 * 1024
MAX_PATCH_BYTES = 2 * 1024 * 1024
MAX_MACHINE_GOAL_BYTES = 512 * 1024
MAX_BOOTSTRAP_BYTES = 512 * 1024
class HostSeedError(RuntimeError):
pass
def canonical(value: object) -> bytes:
return (json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
def sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def canonical_compact(value: object) -> bytes:
return json.dumps(
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
def _git(git_dir: Path, *args: str, input_bytes: bytes | None = None) -> bytes:
env = {"PATH": os.environ.get("PATH", "")}
proc = subprocess.run(
["git", f"--git-dir={git_dir}", *args],
input=input_bytes,
capture_output=True,
timeout=120,
env=env,
)
if proc.returncode:
raise HostSeedError("GIT_" + (args[0] if args else "COMMAND").upper() + "_FAILED")
return proc.stdout
def _git_text(git_dir: Path, *args: str) -> str:
return _git(git_dir, *args).decode("utf-8", "strict").strip()
def _safe_repo_path(value: object, *, field: str) -> str:
if not isinstance(value, str) or not value or len(value) > 512:
raise HostSeedError(field + "_INVALID")
p = PurePosixPath(value)
if p.is_absolute() or any(part in {"", ".", ".."} for part in p.parts):
raise HostSeedError(field + "_INVALID")
normalized = p.as_posix()
if normalized.startswith(".git/") or normalized == ".git":
raise HostSeedError(field + "_INVALID")
return normalized
def _read_git_blob(git_dir: Path, oid: str, path: str, *, maximum: int) -> bytes:
if not OID_RE.fullmatch(oid):
raise HostSeedError("PAYLOAD_COMMIT_INVALID")
raw = _git(git_dir, "show", f"{oid}:{path}")
if len(raw) <= 0 or len(raw) > maximum:
raise HostSeedError("GIT_BLOB_SIZE_INVALID")
return raw
def _parse_json(raw: bytes, *, code: str) -> dict[str, Any]:
try:
value = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise HostSeedError(code) from exc
if not isinstance(value, dict):
raise HostSeedError(code)
return value
def _parse_time(value: object, *, field: str) -> datetime:
if not isinstance(value, str) or not value:
raise HostSeedError(field + "_INVALID")
text = value[:-1] + "+00:00" if value.endswith("Z") else value
try:
parsed = datetime.fromisoformat(text)
except ValueError as exc:
raise HostSeedError(field + "_INVALID") from exc
if parsed.tzinfo is None:
raise HostSeedError(field + "_INVALID")
return parsed.astimezone(timezone.utc)
def _regular_file_bytes(
path: Path, *, maximum: int, expected_uid: int | None = None
) -> bytes:
if path.parent.is_symlink():
raise HostSeedError("AUTHORIZATION_BINDING_PARENT_SYMLINK_REJECTED")
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
fd = os.open(path, flags)
except OSError as exc:
raise HostSeedError("AUTHORIZATION_BINDING_UNAVAILABLE") from exc
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_size <= 0 or info.st_size > maximum:
raise HostSeedError("AUTHORIZATION_BINDING_INVALID")
if info.st_mode & 0o022:
raise HostSeedError("AUTHORIZATION_BINDING_WRITABLE_BY_GROUP_OR_OTHER")
if expected_uid is not None and info.st_uid != expected_uid:
raise HostSeedError("AUTHORIZATION_BINDING_OWNER_MISMATCH")
raw = os.read(fd, maximum + 1)
if len(raw) > maximum:
raise HostSeedError("AUTHORIZATION_BINDING_INVALID")
return raw
finally:
os.close(fd)
def _atomic_json(path: Path, value: Mapping[str, Any], *, mode: int = 0o600) -> None:
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
if path.parent.is_symlink() or path.is_symlink():
raise HostSeedError("STATE_SYMLINK_REJECTED")
fd, temporary = tempfile.mkstemp(prefix=".tmp-", dir=path.parent)
try:
os.fchmod(fd, mode)
with os.fdopen(fd, "wb", closefd=True) as stream:
stream.write(canonical(value))
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
os.chmod(path, mode)
parent_fd = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
try:
os.fsync(parent_fd)
finally:
os.close(parent_fd)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
@dataclass(frozen=True)
class Intent:
candidate: str
release_id: str
base_oid: str
payload_commit: str
patch_path: str
manifest_path: str
machine_goal_path: str
seed_bootstrap_path: str
artifact_sha256: str
manifest_sha256: str
machine_goal_sha256: str
release_tree_sha256: str
seed_bootstrap_sha256: str
target_host: str
def as_dict(self) -> dict[str, Any]:
return {
"candidate": self.candidate,
"release_id": self.release_id,
"base_oid": self.base_oid,
"payload_commit": self.payload_commit,
"patch_path": self.patch_path,
"manifest_path": self.manifest_path,
"machine_goal_path": self.machine_goal_path,
"seed_bootstrap_path": self.seed_bootstrap_path,
"artifact_sha256": self.artifact_sha256,
"manifest_sha256": self.manifest_sha256,
"machine_goal_sha256": self.machine_goal_sha256,
"release_tree_sha256": self.release_tree_sha256,
"seed_bootstrap_sha256": self.seed_bootstrap_sha256,
"target_host": self.target_host,
}
@property
def digest(self) -> str:
return sha256(canonical(self.as_dict()))
@dataclass(frozen=True)
class Request:
intent: Intent
authorization_task_id: str
authorization_binding_sha256: str
authorization_sha256: str
expires_at: datetime
@classmethod
def load(cls, raw: Mapping[str, Any], *, host_identity: str, now: datetime) -> "Request":
if set(raw) != {"schema", "operation", "repository", "intent", "authorization"}:
raise HostSeedError("REQUEST_FIELDS_INVALID")
if raw.get("schema") != REQUEST_SCHEMA or raw.get("operation") != OPERATION:
raise HostSeedError("REQUEST_CONTRACT_INVALID")
if raw.get("repository") != REPOSITORY:
raise HostSeedError("REQUEST_REPOSITORY_MISMATCH")
intent_raw = raw.get("intent")
if not isinstance(intent_raw, Mapping) or set(intent_raw) != {
"candidate", "release_id", "base_oid", "payload_commit", "patch_path",
"manifest_path", "machine_goal_path", "seed_bootstrap_path",
"artifact_sha256", "manifest_sha256", "machine_goal_sha256",
"release_tree_sha256", "seed_bootstrap_sha256", "target_host",
}:
raise HostSeedError("INTENT_FIELDS_INVALID")
candidate = str(intent_raw.get("candidate") or "")
release_id = str(intent_raw.get("release_id") or "")
base_oid = str(intent_raw.get("base_oid") or "")
payload_commit = str(intent_raw.get("payload_commit") or "")
artifact_sha = str(intent_raw.get("artifact_sha256") or "")
manifest_sha = str(intent_raw.get("manifest_sha256") or "")
machine_goal_sha = str(intent_raw.get("machine_goal_sha256") or "")
release_tree_sha = str(intent_raw.get("release_tree_sha256") or "")
seed_bootstrap_sha = str(intent_raw.get("seed_bootstrap_sha256") or "")
target_host = str(intent_raw.get("target_host") or "")
if not CANDIDATE_RE.fullmatch(candidate):
raise HostSeedError("CANDIDATE_INVALID")
if not RELEASE_RE.fullmatch(release_id):
raise HostSeedError("RELEASE_ID_INVALID")
if not OID_RE.fullmatch(base_oid) or not OID_RE.fullmatch(payload_commit):
raise HostSeedError("INTENT_OID_INVALID")
if not all(SHA_RE.fullmatch(x) for x in (
artifact_sha, manifest_sha, machine_goal_sha, release_tree_sha, seed_bootstrap_sha
)):
raise HostSeedError("INTENT_SHA_INVALID")
if target_host != host_identity:
raise HostSeedError("TARGET_HOST_MISMATCH")
intent = Intent(
candidate=candidate,
release_id=release_id,
base_oid=base_oid,
payload_commit=payload_commit,
patch_path=_safe_repo_path(intent_raw.get("patch_path"), field="PATCH_PATH"),
manifest_path=_safe_repo_path(intent_raw.get("manifest_path"), field="MANIFEST_PATH"),
machine_goal_path=_safe_repo_path(intent_raw.get("machine_goal_path"), field="MACHINE_GOAL_PATH"),
seed_bootstrap_path=_safe_repo_path(intent_raw.get("seed_bootstrap_path"), field="SEED_BOOTSTRAP_PATH"),
artifact_sha256=artifact_sha,
manifest_sha256=manifest_sha,
machine_goal_sha256=machine_goal_sha,
release_tree_sha256=release_tree_sha,
seed_bootstrap_sha256=seed_bootstrap_sha,
target_host=target_host,
)
auth = raw.get("authorization")
if not isinstance(auth, Mapping) or set(auth) != {
"task_id", "binding_sha256", "expires_at", "intent_sha256",
"authorization_sha256",
}:
raise HostSeedError("AUTHORIZATION_FIELDS_INVALID")
task_id = str(auth.get("task_id") or "")
binding_sha = str(auth.get("binding_sha256") or "")
authorization_sha = str(auth.get("authorization_sha256") or "")
if (
not TASK_RE.fullmatch(task_id)
or not SHA_RE.fullmatch(binding_sha)
or not SHA_RE.fullmatch(authorization_sha)
):
raise HostSeedError("AUTHORIZATION_IDENTITY_INVALID")
if auth.get("intent_sha256") != intent.digest:
raise HostSeedError("AUTHORIZATION_INTENT_MISMATCH")
expires_text = str(auth.get("expires_at") or "")
expires_at = _parse_time(expires_text, field="AUTHORIZATION_EXPIRES_AT")
expected_authorization_sha = sha256(canonical({
"task_id": task_id,
"intent_sha256": intent.digest,
"expires_at": expires_text,
}))
if authorization_sha != expected_authorization_sha:
raise HostSeedError("AUTHORIZATION_DIGEST_MISMATCH")
if now >= expires_at:
raise HostSeedError("AUTHORIZATION_EXPIRED")
if (expires_at - now).total_seconds() > 3600:
raise HostSeedError("AUTHORIZATION_WINDOW_TOO_LARGE")
return cls(
intent=intent,
authorization_task_id=task_id,
authorization_binding_sha256=binding_sha,
authorization_sha256=authorization_sha,
expires_at=expires_at,
)
@dataclass(frozen=True)
class Config:
git_dir: Path
task_binding_root: Path
state_root: Path
release_root: Path
host_identity: str
task_binding_owner: str | None = "aios-bridge-staging"
request_ref: str = REQUEST_REF
request_path: str = REQUEST_PATH
def _load_task_binding(config: Config, request: Request) -> dict[str, Any]:
safe = re.sub(r"[^A-Za-z0-9_.-]+", "-", request.authorization_task_id).strip("-")[:120] or "task"
path = config.task_binding_root / f"{safe}.json"
expected_uid = None
if config.task_binding_owner:
try:
expected_uid = pwd.getpwnam(config.task_binding_owner).pw_uid
except KeyError as exc:
raise HostSeedError("AUTHORIZATION_BINDING_OWNER_UNKNOWN") from exc
raw = _regular_file_bytes(path, maximum=256 * 1024, expected_uid=expected_uid)
if sha256(raw) != request.authorization_binding_sha256:
raise HostSeedError("AUTHORIZATION_BINDING_HASH_MISMATCH")
binding = _parse_json(raw, code="AUTHORIZATION_BINDING_JSON_INVALID")
required = {
"task_id", "repository", "base_oid", "target_branch", "risk",
"description", "idempotency_key", "expected_branch_oid",
"branch_mode", "generation", "job_id",
}
if set(binding) != required:
raise HostSeedError("AUTHORIZATION_BINDING_FIELDS_INVALID")
if binding.get("task_id") != request.authorization_task_id:
raise HostSeedError("AUTHORIZATION_TASK_MISMATCH")
if binding.get("repository") != REPOSITORY:
raise HostSeedError("AUTHORIZATION_REPOSITORY_MISMATCH")
if binding.get("risk") != "high":
raise HostSeedError("AUTHORIZATION_RISK_MISMATCH")
if binding.get("description") != "HOST_SEED_AUTHORIZATION:" + request.authorization_sha256:
raise HostSeedError("AUTHORIZATION_DESCRIPTION_MISMATCH")
if not str(binding.get("target_branch") or "").startswith("agent/host-seed-authorization-"):
raise HostSeedError("AUTHORIZATION_BRANCH_MISMATCH")
if not isinstance(binding.get("generation"), int) or binding["generation"] < 1:
raise HostSeedError("AUTHORIZATION_GENERATION_INVALID")
return binding
def _load_manifest(config: Config, request: Request) -> dict[str, Any]:
raw = _read_git_blob(
config.git_dir, request.intent.payload_commit, request.intent.manifest_path,
maximum=MAX_MANIFEST_BYTES,
)
if sha256(raw) != request.intent.manifest_sha256:
raise HostSeedError("MANIFEST_HASH_MISMATCH")
manifest = _parse_json(raw, code="MANIFEST_JSON_INVALID")
if manifest.get("schema") not in MANIFEST_SCHEMAS:
raise HostSeedError("MANIFEST_SCHEMA_REJECTED")
if manifest.get("candidate") != request.intent.candidate:
raise HostSeedError("MANIFEST_CANDIDATE_MISMATCH")
if manifest.get("repository") != REPOSITORY:
raise HostSeedError("MANIFEST_REPOSITORY_MISMATCH")
if manifest.get("base_oid") != request.intent.base_oid:
raise HostSeedError("MANIFEST_BASE_MISMATCH")
if manifest.get("release_id") != request.intent.release_id:
raise HostSeedError("MANIFEST_RELEASE_ID_MISMATCH")
members = manifest.get("package_members")
changes = manifest.get("changed_files")
if not isinstance(members, Mapping) or not isinstance(changes, list):
raise HostSeedError("MANIFEST_MEMBERS_INVALID")
return manifest
def _load_identity_evidence(config: Config, request: Request) -> dict[str, Any]:
goal_raw = _read_git_blob(
config.git_dir, request.intent.payload_commit, request.intent.machine_goal_path,
maximum=MAX_MACHINE_GOAL_BYTES,
)
if sha256(goal_raw) != request.intent.machine_goal_sha256:
raise HostSeedError("MACHINE_GOAL_HASH_MISMATCH")
goal = _parse_json(goal_raw, code="MACHINE_GOAL_JSON_INVALID")
required_pairs = {
"candidate": request.intent.candidate,
"repository": REPOSITORY,
"base_oid": request.intent.base_oid,
"release_id": request.intent.release_id,
"artifact_sha256": request.intent.artifact_sha256,
"manifest_sha256": request.intent.manifest_sha256,
"release_tree_sha256": request.intent.release_tree_sha256,
"seed_bootstrap_sha256": request.intent.seed_bootstrap_sha256,
}
for key, expected in required_pairs.items():
if goal.get(key) != expected:
raise HostSeedError("MACHINE_GOAL_BINDING_MISMATCH:" + key)
if goal.get("host_seed_authorized") is not False:
raise HostSeedError("MACHINE_GOAL_HOST_SEED_FLAG_INVALID")
bootstrap = _read_git_blob(
config.git_dir, request.intent.payload_commit, request.intent.seed_bootstrap_path,
maximum=MAX_BOOTSTRAP_BYTES,
)
if sha256(bootstrap) != request.intent.seed_bootstrap_sha256:
raise HostSeedError("SEED_BOOTSTRAP_HASH_MISMATCH")
return goal
def _load_patch(config: Config, request: Request) -> bytes:
patch = _read_git_blob(
config.git_dir, request.intent.payload_commit, request.intent.patch_path,
maximum=MAX_PATCH_BYTES,
)
if b"\x00" in patch:
raise HostSeedError("PATCH_BINARY_NUL_REJECTED")
return patch
def _manifest_release_map(manifest: Mapping[str, Any]) -> dict[str, str]:
members = manifest["package_members"]
result: dict[str, str] = {}
prefix = "payload/repo/after/"
for name, digest in members.items():
if not isinstance(name, str) or not name.startswith(prefix) or not isinstance(digest, str) or not SHA_RE.fullmatch(digest):
continue
rel = _safe_repo_path(name[len(prefix):], field="RELEASE_MEMBER")
if rel in result:
raise HostSeedError("DUPLICATE_RELEASE_MEMBER")
result[rel] = digest
declared_count = manifest.get("release_member_count")
if not isinstance(declared_count, int) or declared_count != len(result):
raise HostSeedError("RELEASE_MEMBER_COUNT_MISMATCH")
return result
def _expected_changes(manifest: Mapping[str, Any]) -> dict[str, tuple[str, str]]:
result: dict[str, tuple[str, str]] = {}
for entry in manifest["changed_files"]:
if not isinstance(entry, Mapping):
raise HostSeedError("CHANGED_FILE_ENTRY_INVALID")
rel = _safe_repo_path(entry.get("path"), field="CHANGED_PATH")
digest = str(entry.get("after_sha256") or "")
status = str(entry.get("status") or "")
if not SHA_RE.fullmatch(digest) or status not in {"modified", "added"} or rel in result:
raise HostSeedError("CHANGED_FILE_ENTRY_INVALID")
result[rel] = (digest, status)
if manifest.get("changed_file_count") != len(result):
raise HostSeedError("CHANGED_FILE_COUNT_MISMATCH")
return result
def _regular_member(root: Path, relative: str) -> Path:
current = root
for part in PurePosixPath(relative).parts:
current = current / part
try:
info = current.lstat()
except FileNotFoundError as exc:
raise HostSeedError("RELEASE_MEMBER_MISSING:" + relative) from exc
if stat.S_ISLNK(info.st_mode):
raise HostSeedError("RELEASE_MEMBER_SYMLINK_REJECTED:" + relative)
if not current.is_file():
raise HostSeedError("RELEASE_MEMBER_NOT_REGULAR:" + relative)
return current
def _materialize_and_verify(config: Config, request: Request, manifest: Mapping[str, Any], patch: bytes, workspace: Path) -> dict[str, str]:
if _git_text(config.git_dir, "cat-file", "-t", request.intent.base_oid) != "commit":
raise HostSeedError("BASE_COMMIT_NOT_FOUND")
if _git_text(config.git_dir, "cat-file", "-t", request.intent.payload_commit) != "commit":
raise HostSeedError("PAYLOAD_COMMIT_NOT_FOUND")
workspace.parent.mkdir(parents=True, exist_ok=True)
clone = subprocess.run(
["git", "clone", "--no-checkout", "--shared", str(config.git_dir), str(workspace)],
capture_output=True, timeout=120, env={"PATH": os.environ.get("PATH", "")},
)
if clone.returncode:
raise HostSeedError("WORKSPACE_CLONE_FAILED")
checkout = subprocess.run(
["git", "-C", str(workspace), "checkout", "--detach", request.intent.base_oid],
capture_output=True, timeout=120, env={"PATH": os.environ.get("PATH", "")},
)
if checkout.returncode:
raise HostSeedError("WORKSPACE_CHECKOUT_FAILED")
try:
proc = subprocess.run(
["git", "-C", str(workspace), "apply", "--whitespace=nowarn", "-"],
input=patch, capture_output=True, timeout=120, env={"PATH": os.environ.get("PATH", "")},
)
if proc.returncode:
raise HostSeedError("PATCH_APPLY_FAILED")
changes = _expected_changes(manifest)
status_raw = subprocess.run(
["git", "-C", str(workspace), "status", "--porcelain", "-z"],
check=True, capture_output=True, timeout=120, env={"PATH": os.environ.get("PATH", "")},
).stdout
actual: set[str] = set()
chunks = status_raw.split(b"\0")
for chunk in chunks:
if not chunk:
continue
text = chunk.decode("utf-8", "surrogateescape")
if len(text) < 4:
raise HostSeedError("WORKTREE_STATUS_INVALID")
actual.add(text[3:].replace("\\", "/"))
if actual != set(changes):
raise HostSeedError("PATCH_CHANGESET_MISMATCH")
base_paths = {
_safe_repo_path(path, field="TRACKED_PATH")
for path in _git_text(config.git_dir, "ls-tree", "-r", "--name-only", request.intent.base_oid).splitlines()
if path
}
for rel, (digest, status) in changes.items():
if status == "modified" and rel not in base_paths:
raise HostSeedError("MODIFIED_PATH_NOT_IN_BASE:" + rel)
if status == "added" and rel in base_paths:
raise HostSeedError("ADDED_PATH_ALREADY_IN_BASE:" + rel)
p = _regular_member(workspace, rel)
if sha256(p.read_bytes()) != digest:
raise HostSeedError("CHANGED_FILE_HASH_MISMATCH:" + rel)
release_map = _manifest_release_map(manifest)
expected_release_paths = base_paths | {
rel for rel, (_digest, status) in changes.items() if status == "added"
}
if set(release_map) != expected_release_paths:
raise HostSeedError("RELEASE_MEMBER_SET_MISMATCH")
for rel, digest in release_map.items():
p = _regular_member(workspace, rel)
if sha256(p.read_bytes()) != digest:
raise HostSeedError("RELEASE_MEMBER_HASH_MISMATCH:" + rel)
tree_digest = sha256(canonical_compact({f"payload/repo/after/{k}": release_map[k] for k in sorted(release_map)}))
if tree_digest != request.intent.release_tree_sha256:
raise HostSeedError("RELEASE_TREE_HASH_MISMATCH")
if manifest.get("release_tree_sha256") not in {None, request.intent.release_tree_sha256}:
raise HostSeedError("MANIFEST_RELEASE_TREE_HASH_MISMATCH")
return release_map
except Exception:
raise
def _publish_release(config: Config, request: Request, workspace: Path, release_map: Mapping[str, str]) -> Path:
if config.release_root.exists() and config.release_root.is_symlink():
raise HostSeedError("RELEASE_ROOT_SYMLINK_REJECTED")
final = config.release_root / request.intent.release_id
if final.exists():
if final.is_symlink() or not final.is_dir():
raise HostSeedError("RELEASE_DESTINATION_CONFLICT")
for rel, digest in release_map.items():
p = final / PurePosixPath(rel)
if p.is_symlink() or not p.is_file() or sha256(p.read_bytes()) != digest:
raise HostSeedError("EXISTING_RELEASE_HASH_MISMATCH")
return final
config.release_root.mkdir(parents=True, exist_ok=True)
os.chmod(config.release_root, 0o755)
staging = Path(tempfile.mkdtemp(prefix=".host-seed-", dir=config.release_root))
os.chmod(staging, 0o755)
try:
for rel, digest in release_map.items():
source = _regular_member(workspace, rel)
target = staging / PurePosixPath(rel)
target.parent.mkdir(parents=True, exist_ok=True)
current = target.parent
while current != staging:
os.chmod(current, 0o755)
current = current.parent
data = source.read_bytes()
if sha256(data) != digest:
raise HostSeedError("STAGING_SOURCE_DRIFT")
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
try:
with os.fdopen(fd, "wb", closefd=True) as out:
out.write(data)
out.flush()
os.fsync(out.fileno())
except Exception:
raise
source_mode = stat.S_IMODE(source.stat(follow_symlinks=False).st_mode)
os.chmod(target, source_mode & 0o755 or 0o644)
for rel, digest in release_map.items():
p = staging / PurePosixPath(rel)
if p.is_symlink() or sha256(p.read_bytes()) != digest:
raise HostSeedError("STAGING_VERIFY_FAILED")
os.replace(staging, final)
parent_fd = os.open(config.release_root, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
try:
os.fsync(parent_fd)
finally:
os.close(parent_fd)
return final
finally:
if staging.exists():
shutil.rmtree(staging)
def execute(config: Config, *, now: datetime | None = None, require_root: bool = True) -> dict[str, Any]:
if require_root and os.geteuid() != 0:
raise HostSeedError("ROOT_REQUIRED")
current = now or datetime.now(timezone.utc)
config.state_root.mkdir(parents=True, exist_ok=True, mode=0o700)
lock_path = config.state_root / "executor.lock"
lock_fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX)
ref_probe = subprocess.run(
["git", f"--git-dir={config.git_dir}", "rev-parse", "--verify", config.request_ref],
capture_output=True, timeout=30, env={"PATH": os.environ.get("PATH", "")},
)
if ref_probe.returncode:
return {
"schema": "aios.control-plane-host-seed-poll.v1",
"state": "NO_REQUEST",
"request_ref": config.request_ref,
"service_mutation": False,
"task_creation": False,
}
request_oid = ref_probe.stdout.decode("utf-8", "strict").strip()
if not OID_RE.fullmatch(request_oid):
raise HostSeedError("REQUEST_REF_INVALID")
raw = _read_git_blob(config.git_dir, request_oid, config.request_path, maximum=MAX_REQUEST_BYTES)
request_value = _parse_json(raw, code="REQUEST_JSON_INVALID")
request = Request.load(request_value, host_identity=config.host_identity, now=current)
binding = _load_task_binding(config, request)
manifest = _load_manifest(config, request)
_load_identity_evidence(config, request)
patch = _load_patch(config, request)
op_id = request.intent.digest
receipt_path = config.state_root / "receipts" / f"{op_id}.json"
if receipt_path.exists():
receipt = _parse_json(_regular_file_bytes(receipt_path, maximum=256 * 1024), code="RECEIPT_INVALID")
if receipt.get("state") == "EXECUTED" and receipt.get("intent_sha256") == op_id:
return receipt
raise HostSeedError("OPERATION_STATE_CONFLICT")
_atomic_json(config.state_root / "operations" / f"{op_id}.json", {
"schema": "aios.control-plane-host-seed-operation.v1",
"state": "PENDING",
"intent_sha256": op_id,
"request_commit": request_oid,
"authorization_task_id": request.authorization_task_id,
"authorization_binding_sha256": request.authorization_binding_sha256,
"started_at": current.isoformat().replace("+00:00", "Z"),
})
workspace_root = config.state_root / "workspaces"
workspace_root.mkdir(parents=True, exist_ok=True)
workspace = workspace_root / f"seed-{op_id[:20]}"
if workspace.exists():
raise HostSeedError("WORKSPACE_ALREADY_EXISTS")
release_map: dict[str, str] | None = None
try:
release_map = _materialize_and_verify(config, request, manifest, patch, workspace)
destination = _publish_release(config, request, workspace, release_map)
finally:
if workspace.exists():
shutil.rmtree(workspace, ignore_errors=True)
receipt = {
"schema": "aios.control-plane-host-seed-receipt.v1",
"state": "EXECUTED",
"operation": OPERATION,
"intent_sha256": op_id,
"request_commit": request_oid,
"repository": REPOSITORY,
"target_host": request.intent.target_host,
"candidate": request.intent.candidate,
"release_id": request.intent.release_id,
"base_oid": request.intent.base_oid,
"payload_commit": request.intent.payload_commit,
"validation_artifact_sha256": request.intent.artifact_sha256,
"artifact_delivery_used": False,
"manifest_sha256": request.intent.manifest_sha256,
"machine_goal_sha256": request.intent.machine_goal_sha256,
"release_tree_sha256": request.intent.release_tree_sha256,
"seed_bootstrap_sha256": request.intent.seed_bootstrap_sha256,
"authorization_task_id": request.authorization_task_id,
"authorization_binding_sha256": request.authorization_binding_sha256,
"release_path": str(destination),
"release_member_count": len(release_map or {}),
"service_mutation": False,
"service_restart": False,
"projector_started": False,
"task_creation": False,
"production_write": False,
"main_write": False,
"tag_write": False,
"baseline_write": False,
"executed_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
}
_atomic_json(receipt_path, receipt)
_atomic_json(config.state_root / "operations" / f"{op_id}.json", receipt)
return receipt
finally:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
os.close(lock_fd)
def config_from_environment() -> Config:
return Config(
git_dir=Path(os.environ.get("AIOS_HOST_SEED_GIT_DIR", "/data/aios-gitops/repo.git")),
task_binding_root=Path(os.environ.get(
"AIOS_HOST_SEED_TASK_BINDING_ROOT",
"/var/lib/aios-gitops/bridge-v2-staging/task-bindings",
)),
state_root=Path(os.environ.get(
"AIOS_HOST_SEED_STATE_ROOT",
"/var/lib/aios-control-plane-host-seed-executor",
)),
release_root=Path(os.environ.get("AIOS_HOST_SEED_RELEASE_ROOT", "/opt/aios/releases")),
host_identity=os.environ.get("AIOS_HOST_IDENTITY", "cn-network-mkt"),
task_binding_owner=os.environ.get(
"AIOS_HOST_SEED_TASK_BINDING_OWNER", "aios-bridge-staging"
) or None,
request_ref=os.environ.get("AIOS_HOST_SEED_REQUEST_REF", REQUEST_REF),
request_path=os.environ.get("AIOS_HOST_SEED_REQUEST_PATH", REQUEST_PATH),
)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("command", choices=("execute",))
args = parser.parse_args()
if args.command == "execute":
result = execute(config_from_environment())
print(json.dumps(result, sort_keys=True, separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -41,6 +41,8 @@ class JobState(str, Enum):
REMOTE_SYNCING = "REMOTE_SYNCING"
REMOTE_VERIFIED = "REMOTE_VERIFIED"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
CANCELLED = "CANCELLED"
RECOVERABLE_FAILURE = "RECOVERABLE_FAILURE"
RECONCILING = "RECONCILING"
RECONCILIATION_REQUIRED = "RECONCILIATION_REQUIRED"
@@ -59,7 +61,12 @@ _ORDER = {
JobState.COMPLETED: 8,
}
_RECOVERABLE = {JobState.RECOVERABLE_FAILURE, JobState.RECONCILIATION_REQUIRED}
_TERMINAL = {JobState.COMPLETED, JobState.MANUAL_OWNER_REVIEW_REQUIRED}
_TERMINAL = {
JobState.COMPLETED,
JobState.FAILED,
JobState.CANCELLED,
JobState.MANUAL_OWNER_REVIEW_REQUIRED,
}
def utc_now() -> str:
+7 -1
View File
@@ -5,6 +5,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from release_version import validate_release_version
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
WORKSPACE = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
DB_PATH = Path(os.getenv('AIOS_GITOPS_DB', str(ROOT / 'runtime' / 'gitops_model.sqlite3')))
@@ -218,7 +220,11 @@ class Handler(BaseHTTPRequestHandler):
a = self.require_write(['owner']);
if not a: return
ver=data.get('version') or data.get('tag')
if not re.match(r'^V\d+R\d+C\d+(-[a-zA-Z0-9_.-]+)?$', ver or ''): self.send_json({'error':'version must match VxRxCx'},400); return
try:
validate_release_version(ver)
except ValueError:
self.send_json({'error':'version must match VxRxCx'},400)
return
msg=data.get('message') or f'release {ver}'; out=git_cmd(['tag','-a',ver,'-m',msg]);
if data.get('push',False): git_cmd(['push','origin',ver])
level='V' if re.match(r'^V[1-9]\d*R0C0', ver) else ('R' if re.match(r'^V\d+R[1-9]\d*C0', ver) else 'C'); exec_sql('INSERT OR REPLACE INTO gitops_versions(version,tag,level,summary,status,created_by,created_at) VALUES(?,?,?,?,?,?,?)',(ver,ver,level,msg,'ready',a['username'],now())); exec_sql('UPDATE gitops_baseline SET active_version=?,active_tag=?,updated_at=? WHERE id=?',(ver,ver,now(),'current')); audit(a['username'],'git_tag',ver,{'level':level}); self.send_json({'status':'tagged','version':ver,'git':out}); return
+16 -4
View File
@@ -23,6 +23,7 @@ from gitea_tag_publication import (
TagPublisher,
redact,
validate_version,
validate_gitea_api_url,
)
from orchestrator.action_monitor import ActionMonitor
from silent_release_hardening import (
@@ -319,8 +320,7 @@ def resolve_commit_oid(value: str | None, field: str = 'target_oid') -> str:
raise ValueError(f'{field} does not resolve to the exact approved commit') from exc
def existing_tag_target(tag: str) -> str | None:
if not re.fullmatch(r'V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$', str(tag or '')):
raise ValueError('invalid release version')
validate_version(tag)
try:
result = run_authoritative_git(
['rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'],
@@ -553,7 +553,15 @@ class H(BaseHTTPRequestHandler):
return self.sendj({'error':redact(exc)},413)
except (AuthoritativeGitError, RuntimeError) as exc:
return self.sendj({'error':redact(exc)},503)
return self.sendj({'from':frm,'to':to,'diff':diff})
payload = {'from': frm, 'to': to, 'diff': diff}
payload.update({
key: diff[key]
for key in (
'from_oid', 'to_oid', 'changed_paths', 'stats',
'total_size_bytes', 'truncated', 'patch_limit_bytes',
)
})
return self.sendj(payload)
if path in ('/api/patch','/api/patches','/api/branches','/patch'):
snapshot = repository_snapshot(
git_dir=AUTHORITATIVE_GIT_DIR,
@@ -822,7 +830,10 @@ class H(BaseHTTPRequestHandler):
a=self.need('tag:create');
if not a: return
ver=data.get('version') or data.get('tag')
if not re.match(r'^V\d+R\d+C\d+(-[A-Za-z0-9_.-]+)?$', ver or ''): return self.sendj({'error':'version must match VxRxCx'},400)
try:
validate_version(ver)
except ValueError:
return self.sendj({'error':'version must match VxRxCx'},400)
if not data.get('target_oid') or not data.get('evidence_closure_oid') or not data.get('approval_plan_id'):
return self.sendj({
'error':'production_tag_evidence_required',
@@ -1016,6 +1027,7 @@ def api_spec():
def main():
validate_gitea_api_url(os.getenv('AIOS_GITOPS_GITEA_API_URL', ''))
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
if __name__ == '__main__': main()
+65 -2
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import os
import re
import subprocess
import time
import urllib.parse
from pathlib import Path
from typing import Iterable
@@ -306,14 +307,76 @@ def authoritative_diff(
from_oid = validate_git_revision(from_revision, "from", git_dir=path)
to_oid = validate_git_revision(to_revision, "to", git_dir=path)
args = ["diff", "--no-ext-diff", "--no-textconv", from_oid, to_oid]
result = run_authoritative_git(args, git_dir=path)
patch_limit = max(1, min(_MAX_OUTPUT, 256 * 1024))
process = subprocess.Popen(
["git", "--git-dir", str(path), *args],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
)
chunks: list[bytes] = []
stored_bytes = 0
total_bytes = 0
started = time.monotonic()
try:
while process.stdout is not None:
chunk = process.stdout.read(64 * 1024)
if not chunk:
break
total_bytes += len(chunk)
if stored_bytes < patch_limit:
remaining = patch_limit - stored_bytes
selected = chunk[:remaining]
chunks.append(selected)
stored_bytes += len(selected)
if time.monotonic() - started > _TIMEOUT:
process.kill()
raise AuthoritativeGitError("GIT_REF_QUERY_TIMEOUT")
stderr = (
process.stderr.read(8192).decode("utf-8", errors="replace")
if process.stderr is not None
else ""
)
returncode = process.wait(timeout=max(0.1, _TIMEOUT - (time.monotonic() - started)))
except (OSError, subprocess.TimeoutExpired) as exc:
process.kill()
process.wait()
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED") from exc
if returncode != 0:
raise AuthoritativeGitError(_redact(stderr.strip() or "GIT_REF_QUERY_FAILED")[:200])
try:
names_result = run_authoritative_git(
["diff", "--no-ext-diff", "--no-textconv", "--name-status", from_oid, to_oid],
git_dir=path,
)
changed_paths = [
line.split("\t", 1)[-1]
for line in names_result.stdout.splitlines()
if line.strip()
]
except AuthoritativeGitOutputTooLarge:
changed_paths = []
try:
stats_result = run_authoritative_git(
["diff", "--no-ext-diff", "--no-textconv", "--stat", from_oid, to_oid],
git_dir=path,
)
stats = stats_result.stdout.strip()
except AuthoritativeGitOutputTooLarge:
stats = "GIT_STAT_OUTPUT_TRUNCATED"
patch = b"".join(chunks).decode("utf-8", errors="replace")
return {
"code": 0,
"stdout": result.stdout,
"stdout": patch,
"stderr": "",
"cmd": ["git", f"--git-dir={path}", *args],
"from_oid": from_oid,
"to_oid": to_oid,
"changed_paths": changed_paths,
"stats": stats,
"total_size_bytes": total_bytes,
"truncated": total_bytes > patch_limit,
"patch_limit_bytes": patch_limit,
}
+16 -7
View File
@@ -8,6 +8,7 @@ import json
import os
import re
import subprocess
import sys
import tempfile
import time
import urllib.error
@@ -23,10 +24,15 @@ try:
from authoritative_git import AuthoritativeGitError, run_authoritative_git
except ModuleNotFoundError:
from .authoritative_git import AuthoritativeGitError, run_authoritative_git
try:
from release_version import RELEASE_VERSION_RE, validate_release_version
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from release_version import RELEASE_VERSION_RE, validate_release_version
OID_RE = re.compile(r"^[0-9a-f]{40}$")
VERSION_RE = re.compile(r"^V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$")
VERSION_RE = RELEASE_VERSION_RE
DEPLOYMENT_EVIDENCE_RE = re.compile(r"^(?:sha256:[0-9a-f]{64}|git:[0-9a-f]{40})$")
FINGERPRINT_FIELDS = (
"version",
@@ -83,9 +89,14 @@ class PublicationManualOwnerReviewRequired(
def validate_version(version: str) -> str:
value = str(version or "")
if not VERSION_RE.fullmatch(value):
raise ValueError("invalid release version")
return validate_release_version(version)
def validate_gitea_api_url(api_url: str) -> str:
value = str(api_url or "").strip().rstrip("/")
parsed = urllib.parse.urlsplit(value)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("Gitea API URL must be HTTP(S)")
return value
@@ -411,13 +422,11 @@ class GiteaTagClient:
timeout: float = 15,
token_line: int = 1,
):
self.api_url = api_url.rstrip("/")
self.api_url = validate_gitea_api_url(api_url)
self.repository = repository.strip("/")
self.token_file = token_file
self.timeout = timeout
self.token_line = token_line
if not self.api_url.startswith(("https://", "http://")):
raise ValueError("Gitea API URL must be HTTP(S)")
if self.repository.count("/") != 1:
raise ValueError("Gitea repository must be owner/name")
+6 -2
View File
@@ -34,6 +34,8 @@ from gitea_tag_publication import (
TagPublisher,
)
from release_version import validate_release_version
OID_RE = re.compile(r"^[0-9a-f]{40}$")
PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
@@ -44,8 +46,10 @@ def load_request() -> dict[str, Any]:
raise ValueError("request_object_required")
if value.get("schema") != "aios.production-executor-request.v1":
raise ValueError("request_schema_invalid")
if not re.fullmatch(r"V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?", str(value.get("version", ""))):
raise ValueError("version_invalid")
try:
validate_release_version(value.get("version", ""))
except ValueError as exc:
raise ValueError("version_invalid") from exc
for name in ("target_oid", "evidence_closure_oid"):
if not OID_RE.fullmatch(str(value.get(name, ""))):
raise ValueError(f"{name}_invalid")
@@ -27,6 +27,8 @@ import urllib.request
from pathlib import Path
from typing import Any, Mapping
from release_version import validate_release_version
OID_RE = re.compile(r"^[0-9a-f]{40}$")
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
@@ -86,8 +88,10 @@ def load_request() -> dict[str, Any]:
raise ValueError("request_schema_invalid")
if value.get("repository_identity") != "aios/aios":
raise ValueError("repository_identity_invalid")
if not re.fullmatch(r"V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?", str(value.get("version", ""))):
raise ValueError("version_invalid")
try:
validate_release_version(value.get("version", ""))
except ValueError as exc:
raise ValueError("version_invalid") from exc
for name in ("target_oid", "evidence_closure_oid"):
if not OID_RE.fullmatch(str(value.get(name, ""))):
raise ValueError(f"{name}_invalid")
@@ -22,11 +22,12 @@ from enum import Enum
from pathlib import Path
from typing import Any, Protocol
from release_version import validate_release_version
OID_RE = re.compile(r"^[0-9a-f]{40}$")
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
CANDIDATE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{2,127}$")
VERSION_RE = re.compile(r"^V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$")
SECRET_PATTERNS = (
re.compile(r"(?i)(authorization\s*[:=]\s*)(?:bearer|token)\s+\S+"),
re.compile(r"(?i)((?:access_)?token\s*[:=]\s*)\S+"),
@@ -769,8 +770,10 @@ class TagApprovalEnvelope:
def validate(self) -> None:
if self.action != "production_tag":
raise ApprovalError("approval does not authorize production tag")
if not VERSION_RE.fullmatch(self.version):
raise ApprovalError("invalid release version")
try:
validate_release_version(self.version)
except ValueError as exc:
raise ApprovalError("invalid release version") from exc
_validate_oid("tag_oid", self.tag_oid)
_validate_oid("target_oid", self.target_oid)
if _parse_utc(self.expires_at) <= datetime.now(timezone.utc):
@@ -1,52 +0,0 @@
#!/bin/sh
set -eu
if [ "$(id -u)" -ne 0 ]; then
echo "ROOT_REQUIRED" >&2
exit 20
fi
SOURCE_ROOT=${1:-}
if [ -z "$SOURCE_ROOT" ]; then
echo "SOURCE_ROOT_REQUIRED" >&2
exit 21
fi
EXECUTOR="$SOURCE_ROOT/active-runtime/app/aios_control_plane_host_seed_executor.py"
SERVICE="$SOURCE_ROOT/deploy/systemd/aios-control-plane-host-seed-executor.service"
TIMER="$SOURCE_ROOT/deploy/systemd/aios-control-plane-host-seed-executor.timer"
for file in "$EXECUTOR" "$SERVICE" "$TIMER"; do
if [ ! -f "$file" ] || [ -L "$file" ]; then
echo "SOURCE_FILE_INVALID:$file" >&2
exit 22
fi
done
AUTHORITY_GIT=/data/aios-gitops/repo.git
REQUEST_REF=refs/heads/agent/host-seed-authorization-requests
if [ ! -d "$AUTHORITY_GIT" ]; then
echo "AUTHORITATIVE_GIT_UNAVAILABLE" >&2
exit 23
fi
if ! id -u aios-bridge-staging >/dev/null 2>&1; then
echo "BRIDGE_SERVICE_ACCOUNT_UNAVAILABLE" >&2
exit 24
fi
if git --git-dir="$AUTHORITY_GIT" show-ref --verify --quiet "$REQUEST_REF"; then
echo "HOST_SEED_REQUEST_REF_ALREADY_PRESENT" >&2
exit 25
fi
install -d -m 0755 /usr/local/libexec
install -m 0755 "$EXECUTOR" /usr/local/libexec/aios-control-plane-host-seed-executor
install -m 0644 "$SERVICE" /etc/systemd/system/aios-control-plane-host-seed-executor.service
install -m 0644 "$TIMER" /etc/systemd/system/aios-control-plane-host-seed-executor.timer
install -d -m 0700 /var/lib/aios-control-plane-host-seed-executor
systemctl daemon-reload
systemctl enable --now aios-control-plane-host-seed-executor.timer
systemctl is-enabled --quiet aios-control-plane-host-seed-executor.timer
systemctl is-active --quiet aios-control-plane-host-seed-executor.timer
echo "HOST_SEED_EXECUTOR_INSTALL=PASS"
@@ -1,15 +0,0 @@
#!/bin/sh
set -eu
if [ "$(id -u)" -ne 0 ]; then
echo "ROOT_REQUIRED" >&2
exit 20
fi
systemctl disable --now aios-control-plane-host-seed-executor.timer 2>/dev/null || true
rm -f /etc/systemd/system/aios-control-plane-host-seed-executor.timer
rm -f /etc/systemd/system/aios-control-plane-host-seed-executor.service
rm -f /usr/local/libexec/aios-control-plane-host-seed-executor
systemctl daemon-reload
echo "HOST_SEED_EXECUTOR_ROLLBACK=PASS"
@@ -4,14 +4,25 @@ After=network-online.target
[Service]
Type=simple
User=aios-bridge-staging
Group=aios-bridge-staging
WorkingDirectory=/opt/aios/gitops-control-plane/current
User=aiman
Group=gitops
PermissionsStartOnly=true
WorkingDirectory=@RUNTIME_ROOT@
Environment=AIOS_BRIDGE_SCHEMA_VERSION=aios.chatgpt-gitops-bridge.v2
Environment=AIOS_GITOPS_WRITES_ENABLED=false
Environment=AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED=true
Environment=AIOS_GITOPS_WRITES_ENABLED=false
Environment=AIOS_GITEA_API_BASE=https://g.cnnetmkt.top/git/api/v1
Environment=AIOS_GITEA_TOKEN_FILE=/run/credentials/aios-chatgpt-bridge-v2-staging.service/gitea_token
Environment=AIOS_GITEA_REMOTE=gitea-http
Environment=AIOS_CONTROL_PLANE_TARGET_VERSION=V2R2C1
Environment=AIOS_CONTROL_PLANE_TARGET_OID=@SOURCE_OID@
Environment=AIOS_CONTROL_PLANE_ARTIFACT_SHA256=@ARTIFACT_SHA256@
Environment=PYTHONPATH=@RUNTIME_ROOT@:@RUNTIME_ROOT@/app:@RUNTIME_ROOT@/runtime-agent-os
Environment=AIOS_DEPLOYMENT_STATE=STAGING
ExecStart=/usr/bin/python3 /opt/aios/gitops-control-plane/current/bridge-v2-staging-server.py
LoadCredential=gitea_token:/run/credentials/aios-governance-executor.service/gitea-governance-executor-token
ExecStartPre=/usr/bin/install -d -o aiman -g gitops -m 0750 /var/lib/aios-gitops/bridge-v2-staging
ExecStart=/usr/bin/python3 -B @RUNTIME_ROOT@/app/aios_chatgpt_bridge_v2_server.py
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
@@ -1,41 +0,0 @@
[Unit]
Description=AIOS governed immutable Host seed executor
After=local-fs.target
[Service]
Type=oneshot
User=root
Group=root
UMask=0077
Environment=PYTHONDONTWRITEBYTECODE=1
Environment=AIOS_HOST_IDENTITY=cn-network-mkt
Environment=AIOS_HOST_SEED_GIT_DIR=/data/aios-gitops/repo.git
Environment=AIOS_HOST_SEED_TASK_BINDING_ROOT=/var/lib/aios-gitops/bridge-v2-staging/task-bindings
Environment=AIOS_HOST_SEED_TASK_BINDING_OWNER=aios-bridge-staging
Environment=AIOS_HOST_SEED_STATE_ROOT=/var/lib/aios-control-plane-host-seed-executor
Environment=AIOS_HOST_SEED_RELEASE_ROOT=/opt/aios/releases
Environment=AIOS_HOST_SEED_REQUEST_REF=refs/heads/agent/host-seed-authorization-requests
Environment=AIOS_HOST_SEED_REQUEST_PATH=control-plane/host-seed/request.json
ExecStart=/usr/bin/python3 -B /usr/local/libexec/aios-control-plane-host-seed-executor execute
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ProtectKernelLogs=true
LockPersonality=true
RestrictSUIDSGID=true
RestrictRealtime=true
RestrictNamespaces=true
MemoryDenyWriteExecute=true
RestrictAddressFamilies=AF_UNIX
IPAddressDeny=any
CapabilityBoundingSet=CAP_DAC_OVERRIDE CAP_FOWNER CAP_CHOWN
ReadOnlyPaths=/usr/local/libexec/aios-control-plane-host-seed-executor
ReadOnlyPaths=/data/aios-gitops/repo.git
ReadOnlyPaths=/var/lib/aios-gitops/bridge-v2-staging/task-bindings
ReadWritePaths=/var/lib/aios-control-plane-host-seed-executor
ReadWritePaths=/opt/aios/releases
@@ -1,12 +0,0 @@
[Unit]
Description=Poll governed immutable Host seed requests
[Timer]
OnBootSec=2min
OnUnitActiveSec=30s
AccuracySec=1s
Persistent=true
Unit=aios-control-plane-host-seed-executor.service
[Install]
WantedBy=timers.target
@@ -7,6 +7,9 @@ Wants=network-online.target
Type=simple
User=aiman
Group=gitops
PermissionsStartOnly=true
ExecStartPre=/usr/bin/install -d -o aiman -g gitops -m 0750 /var/lib/aios-gitops/model-api-staging
ExecStartPre=/usr/bin/install -d -o aiman -g gitops -m 0750 /var/lib/aios-gitops/model-api-staging/silent-release
WorkingDirectory=@RUNTIME_ROOT@
Environment=PYTHONUNBUFFERED=1
Environment=PYTHONDONTWRITEBYTECODE=1
@@ -27,10 +30,16 @@ Environment=AIOS_GITOPS_DEPLOYMENT_STATE=STAGING
Environment=AIOS_GITOPS_MODEL_API_SERVICE=aios-gitops-model-api-v2r2c1-staging.service
Environment=AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
Environment=AIOS_SILENT_RELEASE_STATE_ROOT=/var/lib/aios-gitops/model-api-staging/silent-release
Environment=AIOS_GITOPS_GITEA_API_URL=https://g.cnnetmkt.top/git/api/v1
Environment=AIOS_GITOPS_GITEA_REPOSITORY=aios/aios
Environment=AIOS_GITOPS_GITEA_REMOTE=gitea-http
Environment=AIOS_GITOPS_GITEA_TOKEN_FILE=/run/credentials/aios-gitops-model-api-v2r2c1-staging.service/gitea_token
Environment=AIOS_GITOPS_GITEA_TOKEN_LINE=1
LoadCredential=gitea_token:/run/credentials/aios-governance-executor.service/gitea-governance-executor-token
Environment=AIOS_BRIDGE_V2_URL=http://127.0.0.1:18892
Environment=AIOS_GITOPS_HOST=127.0.0.1
Environment=AIOS_GITOPS_PORT=18893
Environment=PYTHONPATH=@RUNTIME_ROOT@/app:@RUNTIME_ROOT@/runtime-agent-os
Environment=PYTHONPATH=@RUNTIME_ROOT@:@RUNTIME_ROOT@/app:@RUNTIME_ROOT@/runtime-agent-os
ExecStart=/usr/bin/python3 @RUNTIME_ROOT@/app/aios_model_api_staging.py
Restart=on-failure
RestartSec=2
+42
View File
@@ -241,6 +241,48 @@ components:
properties:
candidate_id: {type: string}
paths:
/v2/commit-files:
post:
operationId: commitFilesGoverned
summary: Submit one atomic governed commit containing multiple files
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
required:
- task_id
- repository
- expected_branch_oid
- target_branch
- generation
- idempotency_key
- files
- message
properties:
task_id: {type: string}
repository: {type: string, example: aios/aios}
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}
files:
type: array
minItems: 1
items:
type: object
additionalProperties: false
required: [path, content_base64]
properties:
path: {type: string, minLength: 1, maxLength: 512}
content_base64: {type: string}
message: {type: string, minLength: 1, maxLength: 200}
responses:
'200': {description: Governed commit job and verified branch result}
'400': {description: Contract or policy rejection}
'409': {description: Branch CAS or idempotency conflict}
/createReleaseIntent:
post:
operationId: createReleaseIntent
@@ -1,117 +0,0 @@
# Server-local governed immutable Host seed executor
Date: 2026-08-22
## Goal
Provide a server-local, fail-closed way to publish an already validated
immutable control-plane candidate without depending on Windows, an external
Agent worker, SSH, or an arbitrary root command channel.
The executor is deliberately narrower than deployment:
- it reads only the authoritative local Git object database;
- it consumes one fixed request ref and path;
- it accepts only `HOST_SEED_IMMUTABLE_CANDIDATE`;
- it requires a high-risk governed-task binding whose description binds the
exact candidate intent *and* authorization expiry;
- it materializes `base_oid + candidate.patch`, verifies every release-member
SHA against the candidate manifest, and verifies the candidate Machine Goal;
- it atomically publishes one immutable directory under `/opt/aios/releases`;
- it never starts or restarts services, starts the Projector, creates the fixed
continuation Task/Job, switches Production, or writes `main`, tags, or the
baseline.
## Canonical server path
```text
explicit Owner HOST_SEED authorization
-> high-risk governed authorization task binding
-> governed request commit on refs/heads/agent/host-seed-authorization-requests
-> root timer polls local authoritative Git
-> validate exact intent, expiry, binding SHA, candidate evidence
-> read payload commit from local Git
-> shared disposable clone at exact base
-> apply declared patch
-> verify exact changed set + all release-member bytes
-> atomic immutable publish
-> durable sanitized receipt
```
The Host seed request is not the fixed continuation Task. The fixed
continuation Task remains a separate later gate.
## Trust and authorization binding
The request contains an immutable `intent` with:
- candidate / release id;
- base OID and payload commit;
- fixed patch, manifest, Machine Goal and bootstrap paths;
- validation artifact SHA;
- Manifest, Machine Goal, release-tree and bootstrap SHAs;
- exact target Host identity.
`intent_sha256` is the canonical SHA of that object. Authorization adds
`task_id` and `expires_at`; those are hashed into `authorization_sha256`.
The server-side governed-task binding must contain:
```text
risk=high
description=HOST_SEED_AUTHORIZATION:<authorization_sha256>
target_branch=agent/host-seed-authorization-...
```
The request also binds the exact byte SHA of that server-side binding. This
prevents extending the authorization window or changing the candidate without
creating a new governed authorization binding.
The executor additionally requires the binding file to be a non-symlink
regular file, not group/world writable, and owned by the configured Bridge
service account in production.
## Delivery model
The live delivery payload is server-native Git data, not the external ZIP.
The payload commit carries:
```text
control-plane/candidates/<candidate>/candidate.patch
control-plane/candidates/<candidate>/implementation_manifest_*.json
control-plane/candidates/<candidate>/machine_goal_*.json
control-plane/candidates/<candidate>/aios_control_plane_immutable_seed_*.py
```
The external ZIP SHA remains bound as validation evidence through Machine
Goal, but the executor records `artifact_delivery_used=false`: it does not
claim that ZIP bytes were copied to the Host.
The release tree itself is reconstructed and verified from authoritative Git
objects plus the declared patch.
## Idempotency and recovery
Before publishing, the executor writes a durable `PENDING` operation record
keyed by the exact intent SHA. The final receipt is `EXECUTED`.
If the immutable release directory already exists, every member must match the
manifest before the operation is accepted as idempotent. Any drift fails
closed.
A missing request ref is a normal `NO_REQUEST` poll and has no side effects.
## Systemd confinement
The service:
- runs as root only because immutable release publication requires it;
- has no network access;
- reads authoritative Git and Bridge task bindings read-only;
- can write only its private state root and `/opt/aios/releases`;
- has no service-control, Projector, Task, Git-ref, Production, tag or baseline
operation in its code path.
The timer polls every 30 seconds. Installing/enabling this executor is itself a
separate governed control-plane deployment. Installing it does **not** grant a
Host seed authorization and does not seed any candidate while the fixed
request ref is absent.
+21
View File
@@ -0,0 +1,21 @@
"""Canonical AIOS release-version parser shared by runtime and release tools."""
from __future__ import annotations
import re
# The suffix preserves already-supported annotated release names while the
# canonical VnRnCn namespace remains accepted exactly.
RELEASE_VERSION_PATTERN = r"^V[0-9]+R[0-9]+C[0-9]+(?:-[A-Za-z0-9_.-]+)?$"
RELEASE_VERSION_RE = re.compile(RELEASE_VERSION_PATTERN)
def parse_release_version(version: object) -> str:
value = str(version or "").strip()
if not RELEASE_VERSION_RE.fullmatch(value):
raise ValueError("invalid release version")
return value
def validate_release_version(version: object) -> str:
return parse_release_version(version)
@@ -0,0 +1,75 @@
# Action Contract Regression Remediation — 2026-08-25
## Scope
Governed task: `fix-action-contracts-20260825-1407`
Base: `3244f0f17f8845592ac94d585f5ebd65693c9f29`
Target branch: `agent/fix-action-contracts-20260825-1407`
## Verified regressions
1. `getReleaseConsistency` and `runReleaseAcceptance` fail before evaluation with:
`Gitea API URL must be HTTP(S)`.
V2R2C1 release evidence at `a8b906d1bc24088c19647e98de6a679268f6d0a2`
proves Gitea API readback passed for target `998d30304787e31e5ae607c0bd86b5edb2b0e5ad`.
Therefore current staging/contract overlay configuration has regressed.
2. `getReleaseSaga` fails with:
`Permission denied: '/var/lib/aios-gitops/silent-release'`.
Runtime service identity must have read/traverse access to the durable release state root.
Fix ownership/group/ACL at deployment time; do not broaden to world-writable permissions.
3. `getTagPublicationStatus` rejects the authoritative approved version `V2R2C1`
as `invalid release version`, while `getGovernedWriteCapabilities` returns the same
version as the approved release scope. The two Action contracts are internally inconsistent.
Release-version validation must accept the canonical AIOS `^V[0-9]+R[0-9]+C[0-9]+$`
namespace (or, preferably, use one shared validator for all release Actions).
4. `commitFilesGoverned` is unusable from the published Action schema.
Runtime capability metadata declares required fields:
`task_id, repository, expected_branch_oid, target_branch, generation, idempotency_key, files, message`,
but the exposed tool contract has no `files` input. Regenerate/publish the Action schema from
the same operation contract source and add a contract-parity test.
5. `reconcileGitOpsJob` returned `DURABLE_BINDING_INVALID` for a completed governed
single-file job that was otherwise read back as `COMPLETED`.
Reconciliation of a valid terminal job should be idempotent/read-only and must not require
a stale or absent active lease. Add a regression case for terminal completed jobs.
6. `getDiff` returns `ResponseTooLargeError` / `GIT_OUTPUT_LIMIT_EXCEEDED` for large diffs.
Add server-side bounded output: path/stat summary plus truncation metadata, or pagination.
The Action must never fail merely because a valid repository diff is large.
## Required implementation changes
- Centralize release-version parsing and reuse it in tag status, candidate, consistency, and acceptance.
- Validate/normalize Gitea API URL at service startup and fail health checks with a configuration-specific diagnostic.
- Ensure the deployment unit projects a valid HTTP(S) Gitea API base URL into the staging overlay.
- Correct durable release-state directory ownership/ACL for the runtime service account.
- Generate the external Action schema from the runtime operation contract; parity-test required fields.
- Make terminal reconciliation idempotent and independent of expired active writer leases.
- Bound diff output and return `truncated=true` with deterministic metadata rather than transport failure.
## Acceptance gates
All must pass on the same deployed overlay:
- `getReleaseConsistency(V2R2C1, 998d303..., a8b906d..., approved_plan)` returns a structured result, not URL error.
- `runReleaseAcceptance(...)` reaches policy evaluation, not URL error.
- `getTagPublicationStatus("V2R2C1")` returns structured publication state.
- `getReleaseSaga(nonexistent_candidate)` returns NOT_FOUND/empty-state semantics, never filesystem permission failure.
- Published `commitFilesGoverned` schema exposes `files` and a two-file isolated branch commit succeeds.
- Reconcile of a completed governed job is idempotent.
- Large diff returns bounded/truncated data rather than transport exception.
- Existing protected-ref controls remain unchanged: no direct main/tag/baseline/production write.
## Proven stable reference
V2R2C1 evidence closure (`a8b906d1bc24088c19647e98de6a679268f6d0a2`) records:
- Gitea Git/API readback PASS
- staging runtime PASS
- restart recovery PASS
- 59 mandatory tests passed / 0 failed
This evidence should be used as the compatibility baseline when repairing the current
`contract-overlay-v2` / `scheme-a-323b615b...` staging layer.
@@ -0,0 +1,207 @@
# Agent Execution Contract — Action Contract Regression Repair
## Objective
Fully repair the current AIOS Action runtime/contract overlay regressions on the single server, without modifying protected refs directly and without introducing new infrastructure. The task is not complete until the deployed staging/action overlay passes all acceptance gates.
## Authority and scope
- Repository: `aios/aios`
- Base at task creation: `3244f0f17f8845592ac94d585f5ebd65693c9f29`
- Governed task: `fix-action-contracts-20260825-1407`
- Target branch: `agent/fix-action-contracts-20260825-1407`
- Protected refs must not be directly written: `main`, release tags, baseline registry, production runtime.
- No new SaaS, database, agent framework, or monitoring platform may be introduced.
## Non-interactive rules
The Agent must run the whole flow unnattended:
1. Collect facts.
2. Determine root causes.
3. Make the smallest correct changes.
4. Run unit/integration/contract tests.
5. Deploy to staging only.
6. Restart only the affected non-production services.
7. Run the full Action smoke suite.
8. On failure, automatically collect logs, diagnose, fix, and retry.
9. If a change causes a new regression, automatically roll back the staging change to the previous known-good configuration and record the failure.
10. Stop only if an owner decision is required for a high-risk, irreversible, or production-boundary action.
## Fact collection — required before editing
Record all of the following in the final evidence bundle:
- `id`, `hostname`, `uname -a`, `date --iso-seconds`
- `git rev-parse --show HEAD` for authority worktree and running overlay source
- `git status --porcelaine=v2`
- `git remote -v`
- current systemd service unit(s), drop-ins, Environment/EnvironmentFile sources for GitOps/Bredge/release services
- `systemctl show <affected-service>` including `User=`, `Group=`, `Environment=`, `EnvironmentFile=`, `ExecStart=`
- resolved Gitea API base URL after env/config expansion; redact secrets but not scheme/host
- `stat -c, `namei -l`, `getfacl`, als -ld on `/var/lib/aios-gitops/silent-release` and its parents
- find Action OpenAPI/schema generator and runtime operation definitions for `commitFilesGoverned`
- find the release version validator(s) and all callers of `getTagPublicationStatus`
- find `reconcileGitOpsJob` implementation and durable binding validation logic
- find `getDiff implementation and transport output limits
Do not edit any file until the actual implementation and configuration sources have been located.
## Repair 1 — Gitea API URL
Symptoms:
- `getReleaseConsistency` → `Gitea API URL must be HTTP(S)`
- `runReleaseAcceptance` → `Gitea API URL must be HTTP(ÊX
Required behavior:
- Determine which env/config key is projected into the current contract overlay.
- Compare the deployed overlay with V2R2C1 known-good configuration/deployment manifests.
- Restore a valid `HTTP://` or `HTTPS://` Gitea API base URL.
- Add startup config validation so invalid schemes fail at health/startup with a config-specific message, not during a release check.
- Never hard-code a different Gitea host if the existing deployment already has a single authoritative source.
## Repair 2 — durable release state permissions
Symptom:
- `getReleaseSaga` → `Permission denied: '/var/lib/aios-gitops/silent-release'`
Required behavior:
- Identify the actual runtime service User/Group.
- Grant only the required traverse/read/write permissions for the durable state directories the service must use.
- Prefer ownership + group mode or a specific ACL; keep the directory non-world-writable.
- Persist the permission in deployment/install logic so restart/reboot does not regress.
- Verify create/read/update of a staging-only saga state file under the service identity.
## Repair 3 — release version contract
Symptom:
- `getTagPublicationStatus("V2R2C1")` → `invalid release version`
- The same runtime exposes `V2R2C1` as `approved_release_version`.
Required behavior:
- Locate all version validators; consolidate onto one canonical parser.
- Accept the AIOS canonical namespace `^V[0-9]+R[0-9]+C[0-9]+$`.
- Preserve any additional already-supported legacy formats unless they are security-sensitive.
- Add contract tests proving that the version accepted by capabilities is accepted by status, candidate, consistency, acceptance, and tag publication.
## Repair 4 — `commitFilesGoverned` schema parity
Symptom:
- Runtime capability metadata requires `files`.
- Published Action tool schema does not expose `files`.
- Call reaches Bridge and fails `BRIDGE_REQUEST_FIELDS_MISSING`.
Required behavior:
- Find the canonical runtime operation contract.
- Generate the external tool schema from the same source of truth; do not manually copy a parallel schema.
- `files` must be required and must represent a list of file objects with at least `path` and `content_base64`.
- Add a parity test that compares runtime required fields with published Action required fields.
- Add a real governed commit test that writes two test files to an isolated `agent/` ibranch and verifies remote OID.
## Repair 5 — terminal reconciliation
Symptom:
- A governed job read back as `COMPLETED' but `reconcileGitOpsJob` failed `DURABLE_BINDING_INVALID`.
Required behavior:
- Reconciling a terminal successful job must be idempotent.
- An expired writer lease must not invalidate a durable binding for a job that is already terminal.
- Active-job reconciliation may require a live lease; terminal read-back must not.
- Add cases for COMPLETED, FAILED, CANCELLED / equivalent terminal states.
## Repair 6 — bounded `getDiff` output
Symptom:
- Large valid diffs fail with `ResponseTooLargeError` or `GIT_OUTPUT_LIMIT_EXCEEDED`.
Required behavior:
- Do not load unbounded diff text into the Action response.
- Return bounded summary data: commit identities, changed paths/stats, total bytes, and `truncated: true` when the full patch exceeds the safe limit.
- If pagination already exists in the contract use it; otherwise prefer deterministic truncation over a breaking schema change.
- Add tests for small, empty, and oversize diffs.
## Staging deployment procedure
1. Run the existing mandatory test suite before modification; record baseline.
2. Apply code and config changes only on the governed branch/worktree.
3. Run focused regression tests.
4. Run the full mandatory suite.
5. Build the staging contract overlay using the repository's existing build/deploy path.
6. Deploy to staging/non-production only.
7. If systemd units are changed: `systemctl daemon-reload`, then restart only the affected staging services.
8. Wait for the existing health endpoint/runtime status to become healthy.
9. Run the acceptance suite below.
If any step fails:
- collect `journalctl` logs and relevant app logs;
- diagnose root cause;
- automatically repair and retry when non-destructive;
- if a new change breaks staging, restore the previous staging artifact/config and restart the affected service;
- never silently continue after a failed gate.
## Acceptance suite
All of the following must pass on the same deployed staging overlay:
- Runtime status is `active` and healthy after restart.
- Gitea Git/API readback works from the runtime service identity.
- `getReleaseConsistency(V2R2C1, 998d30304787e31e5ae607c0bd86b5edb2b0e5ad, a8b906d1bc24088c19647e98de6a679268f6d0a2, approved_plan) returns a structured result and reaches evaluation.
- `runReleaseAcceptance` for the same identities reaches policy evaluation, not URL validation error.
- `getTagPublicationStatus("V2R2C1")` returns structured state, not `invalid release version`.
- `getReleaseSaga` for a nonexistent candidate returns NOT_FOUND/empty-state semantics, not filesystem permission failure.
- `commitFilesGoverned` exposes its required `files` parameter in the published Action schema.
- A real two-file governed commit to an isolated `agent/` branch succeeds and remote branch OID matches.
- Resuming a COMPLETED job returns the existing `JOB_TERMINAL` semantics or idempotent success as designed.
- Reconciling a COMPLETED governed job is successful/idempotent and does not require an active lease.
- Small/empty `getDiff still works.
- Large `getDiff` returns bounded output with truncation metadata instead of a transport exception.
- Existing protected-ref controls remain unchange: no direct main/tag/baseline/production write.
## Restart / reboot persistence
After the first full pass:
- Restart the staging Action/Bridge/release services one more time without changing files.
- Rerun the acceptance suite.
- Permissions, Gitea URL, and schema must still be correct after restart.
- If the repository already has a safe staging reboot test, run it as well.
## Evidence and completion
Create a single durable report in the repository, e.g. `reports/action-contract-repair-evidence-20260825.md`, containing:
- pre-change facts
- root cause for each regression
- files changed and why
- config/permission changes
- test commands and exact results
- staging deployment identity (commit/artifact/config digest)
- restart-persistence results
- final Action smoke results
- any remaining risks
Commit all source, test, deployment config, and runbook changes to the governed task branch. Do not mark the task SUCCEEDED until all acceptance gates pass on the actually deployed staging overlay.
## Automatic rollback conditions
Automatically roll back the staging deployment if any of the following occur:
- service fails to become healthy after restart
- protected-ref write boundary is broken
- Gitea readback that worked before the change stops working
- durable saga state cannot be read or recovered
- mandatory test suite regresses below the pre-change baseline
Do not automatically roll back an existing production release. Rollback here refers only to the staging overlay/service configuration changes made by this task.
+22
View File
@@ -5,6 +5,7 @@ import hashlib
import json
import os
from pathlib import Path
import sys
import tarfile
import tempfile
@@ -18,6 +19,18 @@ REQUIRED_APP = (
'silent_release_controller.py',
'silent_release_hardening.py',
'aios_gitops_job_controller.py',
'aios_chatgpt_bridge_v2.py',
'aios_chatgpt_bridge_v2_server.py',
'governed_production_release.py',
'v2r2c1_production_adapter.py',
'v2r1c7_production_adapter.py',
'aios_governed_task_bootstrap.py',
'aios_continuation_executor.py',
'aios_continuation_lease.py',
'aios_authority_git_executor.py',
'aios_gitops_reconciliation_core.py',
'authorized_task_writer_provisioning.py',
'signer_contract_compatibility.py',
)
OPTIONAL_APP = (
'aios_chatgpt_actions_api.py',
@@ -56,11 +69,18 @@ def main() -> int:
source = args.source_root.resolve(strict=True)
output = args.output_root.resolve()
app = source / 'active-runtime' / 'app'
sys.path.insert(0, str(app))
from aios_chatgpt_bridge_v2 import render_openapi_contract_fragment
runtime = source / 'runtime' / 'agent-os'
docs = source / 'docs' / 'gitops'
if not app.is_dir():
raise SystemExit('active-runtime/app missing')
paths = []
root_version = source / 'release_version.py'
if not root_version.is_file():
raise SystemExit('required source dependency missing: release_version.py')
paths.append((Path('release_version.py'), root_version))
for name in REQUIRED_APP:
path = app / name
if not path.is_file():
@@ -77,6 +97,8 @@ def main() -> int:
paths.append((Path('runtime-agent-os') / rel, path))
openapi = docs / 'openapi-chatgpt-actions.yaml'
if openapi.is_file():
if ' /v2/commit-files:' not in openapi.read_text(encoding='utf-8'):
raise SystemExit('published OpenAPI is missing generated commitFilesGoverned contract')
paths.append((Path('docs') / 'gitops' / 'openapi-chatgpt-actions.yaml', openapi))
for folder in ('config/model_registry', 'config/model_priority', 'patches', 'versions'):
root = source / folder
+10 -4
View File
@@ -9,10 +9,16 @@ WORKSPACE="${AIOS_GITOPS_WORKSPACE:-/data/aios-gitops/workspace}"
TAG_REMOTE="${AIOS_GITOPS_GITEA_REMOTE:-gitea}"
BRANCH_REMOTE="${AIOS_GITOPS_BRANCH_REMOTE:-gitea}"
case "$VERSION" in
V[0-9]*R[0-9]*C[0-9]*) ;;
*) echo "ERROR: version must match VxRxCx" >&2; exit 64 ;;
esac
SCRIPT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
if ! PYTHONPATH="$SCRIPT_ROOT:${PYTHONPATH:-}" python3 - "$VERSION" <<'PY'
import sys
from release_version import validate_release_version
validate_release_version(sys.argv[1])
PY
then
echo "ERROR: version must match VxRxCx" >&2
exit 64
fi
for value in "$TARGET_OID" "$EVIDENCE_CLOSURE_OID"; do
[[ "$value" =~ ^[0-9a-f]{40}$ ]] || {
@@ -1,8 +1,9 @@
from __future__ import annotations
import hashlib,json,re,tarfile
from release_version import RELEASE_VERSION_RE
from pathlib import Path
from typing import Any
OID_RE=re.compile(r"^[0-9a-f]{40}$"); SHA_RE=re.compile(r"^[0-9a-f]{64}$"); VERSION_RE=re.compile(r"^V[0-9]+R[0-9]+C[0-9]+(?:-[A-Za-z0-9_.-]+)?$")
OID_RE=re.compile(r"^[0-9a-f]{40}$"); SHA_RE=re.compile(r"^[0-9a-f]{64}$"); VERSION_RE=RELEASE_VERSION_RE
REQUIRED_SOURCE={"schema","version","repository","base_ref","base_oid","binding_mode","release_readiness","production_deployed","tag_created","baseline_changed","production_service","production_port","rollback_metadata","controller_schema","required_checks"}
REQUEST_KEYS={"schema","version","repository","base_oid","target_oid","artifact_uri","artifact_sha256","artifact_size","evidence_closure_oid","approval_plan_id","execute","production_deployed","production_service","production_port","rollback_metadata","current_tag_oid","source_manifest_sha256"}
class ManifestValidationError(ValueError):
+1
View File
@@ -0,0 +1 @@
action-contract-integration-a
+1
View File
@@ -0,0 +1 @@
action-contract-integration-b
+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from aios_chatgpt_bridge_v2 import ( # noqa: E402
OPERATION_REQUIRED_FIELDS,
operation_contracts,
render_openapi_contract_fragment,
)
from aios_chatgpt_bridge_v2_server import GovernedRuntime # noqa: E402
from aios_gitops_job_controller import JobState # noqa: E402
from gitea_tag_publication import validate_gitea_api_url # noqa: E402
from release_version import validate_release_version # noqa: E402
def test_canonical_release_version_is_shared_and_accepts_v2r2c1():
assert validate_release_version("V2R2C1") == "V2R2C1"
assert validate_release_version("V2R2C1-repair") == "V2R2C1-repair"
with pytest.raises(ValueError):
validate_release_version("v2r2c1")
def test_gitea_url_validation_is_startup_safe():
assert validate_gitea_api_url("https://g.cnnetmkt.top/git/api/v1") == (
"https://g.cnnetmkt.top/git/api/v1"
)
with pytest.raises(ValueError, match="HTTP"):
validate_gitea_api_url("g.cnnetmkt.top/git/api/v1")
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"])
fragment = render_openapi_contract_fragment()
published = (ROOT / "docs/gitops/openapi-chatgpt-actions.yaml").read_text()
assert fragment in published
for field in runtime:
assert f" - {field}" in fragment
assert "required: [path, content_base64]" in fragment
class _Store:
def __init__(self, row):
self.row = row
self.receipt_calls = 0
def read(self, _job_id):
return self.row
def _receipt(self, row, duplicate=False):
self.receipt_calls += 1
return type("Receipt", (), {"as_dict": lambda _self: {
"job_id": row["job_id"],
"state": row["state"],
"duplicate": duplicate,
}})()
class _Controller:
def __init__(self, row):
self.store = _Store(row)
def _runtime_for(row):
runtime = object.__new__(GovernedRuntime)
controller = _Controller(row)
runtime._controller_for_job = lambda _job_id: controller
runtime._executor_from_row = lambda *_args: (_ for _ in ()).throw(
AssertionError("terminal reconcile must not reconstruct an executor")
)
return runtime, controller
@pytest.mark.parametrize(
"state",
[
JobState.COMPLETED.value,
JobState.FAILED.value,
JobState.CANCELLED.value,
JobState.MANUAL_OWNER_REVIEW_REQUIRED.value,
],
)
def test_terminal_reconcile_is_idempotent_without_durable_binding(state):
row = {
"job_id": "job-terminal",
"state": state,
"request_json": json.dumps({}),
}
runtime, controller = _runtime_for(row)
result = GovernedRuntime.job(runtime, {"job_id": row["job_id"]}, "reconcile")
assert result["duplicate"] is True
assert controller.store.receipt_calls == 1
def test_active_reconcile_still_uses_executor():
row = {
"job_id": "job-active",
"state": JobState.RECONCILIATION_REQUIRED.value,
"request_json": json.dumps({}),
}
runtime, controller = _runtime_for(row)
class Executor:
def reconcile(self, job_id):
return {"job_id": job_id, "state": "COMPLETED"}
runtime._executor_from_row = lambda *_args: Executor()
assert GovernedRuntime.job(runtime, {"job_id": row["job_id"]}, "reconcile") == {
"job_id": "job-active",
"state": "COMPLETED",
}
@@ -1,328 +0,0 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import hashlib
import importlib.util
import json
from pathlib import Path
import shutil
import subprocess
import pytest
MODULE_PATH = Path(__file__).parents[1] / "active-runtime" / "app" / "aios_control_plane_host_seed_executor.py"
spec = importlib.util.spec_from_file_location("host_seed_executor", MODULE_PATH)
m = importlib.util.module_from_spec(spec)
assert spec and spec.loader
import sys
sys.modules[spec.name] = m
spec.loader.exec_module(m)
def run(*args: str, cwd: Path) -> str:
return subprocess.check_output(args, cwd=cwd, text=True).strip()
def sha(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def canonical(value: object) -> bytes:
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()
def setup_case(tmp_path: Path, *, risk: str = "high", tamper_bootstrap: bool = False):
repo = tmp_path / "src"
bare = tmp_path / "repo.git"
repo.mkdir()
run("git", "init", "-b", "main", cwd=repo)
run("git", "config", "user.email", "test@example.invalid", cwd=repo)
run("git", "config", "user.name", "test", cwd=repo)
(repo / "a.txt").write_text("base\n", encoding="utf-8")
(repo / "bin.sh").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
(repo / "bin.sh").chmod(0o755)
run("git", "add", "-A", cwd=repo)
run("git", "commit", "-m", "base", cwd=repo)
base = run("git", "rev-parse", "HEAD", cwd=repo)
run("git", "clone", "--bare", str(repo), str(bare), cwd=tmp_path)
(repo / "a.txt").write_text("changed\n", encoding="utf-8")
(repo / "c.txt").write_text("added\n", encoding="utf-8")
run("git", "add", "-N", "c.txt", cwd=repo)
patch = subprocess.check_output(["git", "diff", "--binary", base], cwd=repo)
after = {
"a.txt": sha((repo / "a.txt").read_bytes()),
"bin.sh": sha((repo / "bin.sh").read_bytes()),
"c.txt": sha((repo / "c.txt").read_bytes()),
}
package_members = {f"payload/repo/after/{k}": v for k, v in after.items()}
release_tree = sha(json.dumps({k: package_members[k] for k in sorted(package_members)}, sort_keys=True, separators=(",", ":")).encode())
manifest = {
"schema": "aios.control-plane-immutable-candidate.v6",
"candidate": "V9",
"repository": "aios/aios",
"base_oid": base,
"release_id": "control-plane-test-v9",
"changed_file_count": 2,
"changed_files": [
{"path": "a.txt", "status": "modified", "after_sha256": after["a.txt"]},
{"path": "c.txt", "status": "added", "after_sha256": after["c.txt"]},
],
"release_member_count": 3,
"package_members": package_members,
"release_tree_sha256": release_tree,
}
manifest_raw = canonical(manifest)
bootstrap = b"#!/usr/bin/env python3\nprint('seed helper evidence only')\n"
bootstrap_sha = sha(bootstrap)
artifact_sha = "a" * 64
goal = {
"schema": "aios.control-plane-immutable-candidate-goal.v6",
"candidate": "V9",
"repository": "aios/aios",
"base_oid": base,
"release_id": "control-plane-test-v9",
"artifact_sha256": artifact_sha,
"manifest_sha256": sha(manifest_raw),
"release_tree_sha256": release_tree,
"seed_bootstrap_sha256": bootstrap_sha,
"host_seed_authorized": False,
}
goal_raw = canonical(goal)
# Reset the source worktree to base; delivery files live in a separate commit.
run("git", "reset", "--hard", base, cwd=repo)
delivery = repo / "control-plane" / "candidates" / "v9"
delivery.mkdir(parents=True)
(delivery / "candidate.patch").write_bytes(patch)
(delivery / "implementation_manifest_v9.json").write_bytes(manifest_raw)
(delivery / "machine_goal_v9.json").write_bytes(goal_raw)
(delivery / "aios_control_plane_immutable_seed_v9.py").write_bytes(
b"tampered\n" if tamper_bootstrap else bootstrap
)
run("git", "add", "-A", cwd=repo)
run("git", "commit", "-m", "candidate payload", cwd=repo)
payload_commit = run("git", "rev-parse", "HEAD", cwd=repo)
run("git", "push", str(bare), f"{payload_commit}:refs/heads/agent/candidate-payload", cwd=repo)
intent = {
"candidate": "V9",
"release_id": "control-plane-test-v9",
"base_oid": base,
"payload_commit": payload_commit,
"patch_path": "control-plane/candidates/v9/candidate.patch",
"manifest_path": "control-plane/candidates/v9/implementation_manifest_v9.json",
"machine_goal_path": "control-plane/candidates/v9/machine_goal_v9.json",
"seed_bootstrap_path": "control-plane/candidates/v9/aios_control_plane_immutable_seed_v9.py",
"artifact_sha256": artifact_sha,
"manifest_sha256": sha(manifest_raw),
"machine_goal_sha256": sha(goal_raw),
"release_tree_sha256": release_tree,
"seed_bootstrap_sha256": bootstrap_sha,
"target_host": "cn-network-mkt",
}
intent_digest = sha(canonical(intent))
task_id = "host-seed-authz-v9-cn-network-mkt"
now = datetime(2026, 8, 22, 12, 0, tzinfo=timezone.utc)
expires_text = (now + timedelta(minutes=30)).isoformat().replace("+00:00", "Z")
authorization_digest = sha(canonical({
"task_id": task_id,
"intent_sha256": intent_digest,
"expires_at": expires_text,
}))
binding = {
"task_id": task_id,
"repository": "aios/aios",
"base_oid": payload_commit,
"target_branch": "agent/host-seed-authorization-v9-cn-network-mkt",
"risk": risk,
"description": "HOST_SEED_AUTHORIZATION:" + authorization_digest,
"idempotency_key": "host-seed-authz-v9-1",
"expected_branch_oid": payload_commit,
"branch_mode": "CREATE_NEW",
"generation": 1,
"job_id": "job-" + "1" * 32,
}
binding_root = tmp_path / "bindings"
binding_root.mkdir()
binding_raw = json.dumps(binding, sort_keys=True).encode()
(binding_root / f"{task_id}.json").write_bytes(binding_raw)
(binding_root / f"{task_id}.json").chmod(0o600)
request = {
"schema": "aios.control-plane-host-seed-request.v1",
"operation": "HOST_SEED_IMMUTABLE_CANDIDATE",
"repository": "aios/aios",
"intent": intent,
"authorization": {
"task_id": task_id,
"binding_sha256": sha(binding_raw),
"expires_at": expires_text,
"intent_sha256": intent_digest,
"authorization_sha256": authorization_digest,
},
}
request_path = repo / "control-plane" / "host-seed" / "request.json"
request_path.parent.mkdir(parents=True, exist_ok=True)
request_path.write_bytes(canonical(request))
run("git", "add", str(request_path.relative_to(repo)), cwd=repo)
run("git", "commit", "-m", "host seed request", cwd=repo)
request_commit = run("git", "rev-parse", "HEAD", cwd=repo)
run("git", "push", str(bare), f"{request_commit}:{m.REQUEST_REF}", cwd=repo)
config = m.Config(
git_dir=bare,
task_binding_root=binding_root,
state_root=tmp_path / "state",
release_root=tmp_path / "releases",
host_identity="cn-network-mkt",
task_binding_owner=None,
)
return config, now, after, request
def test_execute_publishes_exact_release_and_is_idempotent(tmp_path: Path):
config, now, after, request = setup_case(tmp_path)
receipt = m.execute(config, now=now, require_root=False)
assert receipt["state"] == "EXECUTED"
assert receipt["service_mutation"] is False
assert receipt["service_restart"] is False
assert receipt["projector_started"] is False
assert receipt["task_creation"] is False
assert receipt["production_write"] is False
final = config.release_root / "control-plane-test-v9"
assert final.is_dir() and not final.is_symlink()
assert final.stat().st_mode & 0o777 == 0o755
assert sha((final / "a.txt").read_bytes()) == after["a.txt"]
assert sha((final / "bin.sh").read_bytes()) == after["bin.sh"]
assert sha((final / "c.txt").read_bytes()) == after["c.txt"]
assert (final / "bin.sh").stat().st_mode & 0o111
again = m.execute(config, now=now, require_root=False)
assert again == receipt
def test_execute_rejects_non_high_risk_authorization(tmp_path: Path):
config, now, _, _ = setup_case(tmp_path, risk="engineering")
with pytest.raises(m.HostSeedError, match="AUTHORIZATION_RISK_MISMATCH"):
m.execute(config, now=now, require_root=False)
assert not config.release_root.exists()
def test_execute_rejects_tampered_seed_bootstrap(tmp_path: Path):
config, now, _, _ = setup_case(tmp_path, tamper_bootstrap=True)
with pytest.raises(m.HostSeedError, match="SEED_BOOTSTRAP_HASH_MISMATCH"):
m.execute(config, now=now, require_root=False)
assert not config.release_root.exists()
def test_request_rejects_wrong_host(tmp_path: Path):
config, now, _, _ = setup_case(tmp_path)
config = m.Config(
git_dir=config.git_dir,
task_binding_root=config.task_binding_root,
state_root=config.state_root,
release_root=config.release_root,
host_identity="other-host",
task_binding_owner=None,
)
with pytest.raises(m.HostSeedError, match="TARGET_HOST_MISMATCH"):
m.execute(config, now=now, require_root=False)
def test_existing_release_drift_is_fail_closed(tmp_path: Path):
config, now, _, _ = setup_case(tmp_path)
first = m.execute(config, now=now, require_root=False)
assert first["state"] == "EXECUTED"
(config.release_root / "control-plane-test-v9" / "a.txt").write_text("drift\n")
# remove durable receipt so existing-release verification path is exercised.
for p in (config.state_root / "receipts").glob("*.json"):
p.unlink()
with pytest.raises(m.HostSeedError, match="EXISTING_RELEASE_HASH_MISMATCH"):
m.execute(config, now=now, require_root=False)
def test_no_request_ref_is_safe_noop(tmp_path: Path):
repo = tmp_path / "src"
bare = tmp_path / "repo.git"
repo.mkdir()
run("git", "init", "-b", "main", cwd=repo)
run("git", "config", "user.email", "test@example.invalid", cwd=repo)
run("git", "config", "user.name", "test", cwd=repo)
(repo / "a").write_text("x\n")
run("git", "add", "a", cwd=repo)
run("git", "commit", "-m", "base", cwd=repo)
run("git", "clone", "--bare", str(repo), str(bare), cwd=tmp_path)
cfg = m.Config(
git_dir=bare,
task_binding_root=tmp_path / "bindings",
state_root=tmp_path / "state",
release_root=tmp_path / "releases",
host_identity="cn-network-mkt",
task_binding_owner=None,
)
result = m.execute(cfg, now=datetime(2026, 8, 22, tzinfo=timezone.utc), require_root=False)
assert result["state"] == "NO_REQUEST"
assert result["service_mutation"] is False
assert result["task_creation"] is False
assert not cfg.release_root.exists()
def test_authorization_expiry_cannot_be_extended_without_new_binding(tmp_path: Path):
config, now, _, _ = setup_case(tmp_path)
# Rewrite only the request expiry and recompute the request commit, but keep
# the original governed-task binding description. The authorization digest
# therefore no longer matches the binding and must fail closed.
repo = tmp_path / "src"
req_path = repo / "control-plane" / "host-seed" / "request.json"
value = json.loads(req_path.read_text())
value["authorization"]["expires_at"] = (now + timedelta(minutes=45)).isoformat().replace("+00:00", "Z")
value["authorization"]["authorization_sha256"] = sha(canonical({
"task_id": value["authorization"]["task_id"],
"intent_sha256": value["authorization"]["intent_sha256"],
"expires_at": value["authorization"]["expires_at"],
}))
req_path.write_bytes(canonical(value))
run("git", "add", str(req_path.relative_to(repo)), cwd=repo)
run("git", "commit", "-m", "tamper expiry", cwd=repo)
new_commit = run("git", "rev-parse", "HEAD", cwd=repo)
run("git", "push", "--force", str(config.git_dir), f"{new_commit}:{m.REQUEST_REF}", cwd=repo)
with pytest.raises(m.HostSeedError, match="AUTHORIZATION_DESCRIPTION_MISMATCH"):
m.execute(config, now=now, require_root=False)
def test_systemd_contract_has_no_network_or_implicit_activation():
root = Path(__file__).parents[1]
service = (root / "deploy/systemd/aios-control-plane-host-seed-executor.service").read_text()
timer = (root / "deploy/systemd/aios-control-plane-host-seed-executor.timer").read_text()
assert "User=root" in service
assert "IPAddressDeny=any" in service
assert "RestrictAddressFamilies=AF_UNIX" in service
assert "ReadOnlyPaths=/data/aios-gitops/repo.git" in service
assert "ReadWritePaths=/opt/aios/releases" in service
assert "systemctl start" not in service
assert "projector" not in service.lower()
assert "OnUnitActiveSec=30s" in timer
def test_release_root_symlink_is_rejected(tmp_path: Path):
config, now, _, _ = setup_case(tmp_path)
real = tmp_path / "real-releases"
real.mkdir()
config.release_root.symlink_to(real, target_is_directory=True)
with pytest.raises(m.HostSeedError, match="RELEASE_ROOT_SYMLINK_REJECTED"):
m.execute(config, now=now, require_root=False)
def test_install_and_rollback_scripts_have_valid_bash_syntax():
root = Path(__file__).parents[1]
for relative in (
"deploy/install/install_control_plane_host_seed_executor.sh",
"deploy/install/rollback_control_plane_host_seed_executor.sh",
):
subprocess.run(
["/usr/bin/bash", "-n", str(root / relative)],
check=True,
text=True,
capture_output=True,
)