Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e6f197c34 | ||
|
|
910a16a998 |
-74
@@ -1,74 +0,0 @@
|
|||||||
# RESULT-CODEX-V2R1-PERSONAL-WEIXIN-FOUNDATION-RETRY5-20260715T183951UTC8
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
status: FAIL
|
|
||||||
previous_result_commit: 825dff221a321efccd862333e3cbc041f4fde65b
|
|
||||||
|
|
||||||
implementation_branch: patch/v2r1-personal-weixin-foundation-codex-retry5-20260715T183951UTC8
|
|
||||||
implementation_commit: null
|
|
||||||
implementation_parent: d5b51e1342541bf51887782f530c3d2971155996
|
|
||||||
|
|
||||||
owner_build_script_sha256: MISSING
|
|
||||||
owner_contract_script_sha256: MISSING
|
|
||||||
owner_script_hash_gate: FAIL
|
|
||||||
owner_script_syntax_gate: NOT_RUN_MISSING_FILES
|
|
||||||
|
|
||||||
local_base_digest_equivalence: NOT_RUN_MISSING_OWNER_FILES
|
|
||||||
base_image_ref: aios-webops-runtime@sha256:b9cff350ee8ca96989809012ccec2edd2cfc8785ebca092519904ff5e1a1946d
|
|
||||||
base_image_digest: sha256:b9cff350ee8ca96989809012ccec2edd2cfc8785ebca092519904ff5e1a1946d
|
|
||||||
base_local_image_id: null
|
|
||||||
|
|
||||||
build_engine: NOT_RUN_MISSING_OWNER_FILES
|
|
||||||
build_network: none
|
|
||||||
remote_resolution_attempted: false
|
|
||||||
image_build: NOT_RUN_MISSING_OWNER_FILES
|
|
||||||
container_non_root: NOT_RUN_MISSING_OWNER_FILES
|
|
||||||
runtime_uid: null
|
|
||||||
image_id: null
|
|
||||||
image_digest: null
|
|
||||||
image_digest_kind: null
|
|
||||||
rootfs_chain_sha256: null
|
|
||||||
|
|
||||||
runtime_wheel: NOT_RUN_MISSING_OWNER_FILES
|
|
||||||
fresh_venv_install: NOT_RUN_MISSING_OWNER_FILES
|
|
||||||
external_import: NOT_RUN_MISSING_OWNER_FILES
|
|
||||||
package_identity_tests: NOT_RUN_MISSING_OWNER_FILES
|
|
||||||
portal_check: NOT_RUN_MISSING_OWNER_FILES
|
|
||||||
migration_dry_run: NOT_RUN_MISSING_OWNER_FILES
|
|
||||||
temporary_migration_apply: NOT_RUN_MISSING_OWNER_FILES
|
|
||||||
model_security: NOT_RUN_MISSING_OWNER_FILES
|
|
||||||
repository_single_consume: NOT_RUN_MISSING_OWNER_FILES
|
|
||||||
repository_idempotency: NOT_RUN_MISSING_OWNER_FILES
|
|
||||||
repository_conflict_fail_closed: NOT_RUN_MISSING_OWNER_FILES
|
|
||||||
repository_concurrency_contract: NOT_RUN_MISSING_OWNER_FILES
|
|
||||||
image_contract: NOT_RUN_MISSING_OWNER_FILES
|
|
||||||
gitleaks: NOT_RUN_MISSING_OWNER_FILES
|
|
||||||
changed_paths_exact_18: NOT_RUN_NO_IMPLEMENTATION
|
|
||||||
worktree_clean: true
|
|
||||||
|
|
||||||
production_changed: false
|
|
||||||
baseline_after: V1R4C6
|
|
||||||
release_status: paused
|
|
||||||
b6_b7_started: false
|
|
||||||
canary_started: false
|
|
||||||
owner_action_required: >-
|
|
||||||
Place the two exact owner-authored files in /tmp without committing them:
|
|
||||||
/tmp/pwx_retry5_local_image_build.sh (SHA-256 1f962e92ffacf4c145e16ebbe3089fd268ca9bd0e088dbf2b8474eb530d01e23)
|
|
||||||
and /tmp/pwx_retry5_finalize_image_contract.py (SHA-256 2652d2b4c58a096ae24d2396d85a764e8a0e018c55718640338fe925d268177b).
|
|
||||||
next_gate: Re-run the mandatory exact SHA-256 and syntax gates; do not create staging or an implementation worktree until both pass.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Mandatory owner-file gate evidence
|
|
||||||
|
|
||||||
| Command | Exit | Result |
|
|
||||||
|---|---:|---|
|
|
||||||
| `sha256sum /tmp/pwx_retry5_local_image_build.sh` | 1 | Missing file; expected SHA-256 cannot be verified. |
|
|
||||||
| `sha256sum /tmp/pwx_retry5_finalize_image_contract.py` | 1 | Missing file; expected SHA-256 cannot be verified. |
|
|
||||||
| `bash -n /tmp/pwx_retry5_local_image_build.sh` | 127 | Missing file. |
|
|
||||||
| `python3 -m py_compile /tmp/pwx_retry5_finalize_image_contract.py` | 1 | Missing file. |
|
|
||||||
|
|
||||||
No staging directory, implementation worktree/branch, wheel, test environment, image context, container build, network/proxy/DNS/registry change, canary, or production action was created or executed. The stop was fail-closed before any Foundation source path could be changed.
|
|
||||||
|
|
||||||
## Git publication evidence
|
|
||||||
|
|
||||||
The result branch contains one commit with exactly this RESULT path. The worktree, bare repository, and `gitea` remote OIDs were checked after a normal (non-force) push and must match the commit recorded below.
|
|
||||||
@@ -1,119 +1,89 @@
|
|||||||
"""Append-only, tamper-evident audit chain for AIOS V2R1."""
|
"""Append-only audit chain for AIOS V2R1."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from contextlib import closing
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from typing import Any, TypeVar
|
from typing import Any
|
||||||
|
|
||||||
from .tenant_context import ConflictError, canonical_json, isoformat, require_ident
|
from .tenant_context import canonical_json, isoformat, require_ident
|
||||||
|
|
||||||
T = TypeVar("T")
|
|
||||||
|
|
||||||
|
|
||||||
class AuditLog:
|
class AuditLog:
|
||||||
def __init__(self, database: str | Path) -> None:
|
def __init__(self, database: str | Path) -> None:
|
||||||
self.database = Path(database)
|
self.database = str(database)
|
||||||
self.database.parent.mkdir(parents=True, exist_ok=True)
|
with sqlite3.connect(self.database) as db:
|
||||||
self._initialize()
|
db.execute("PRAGMA journal_mode=WAL")
|
||||||
os.chmod(self.database, 0o600)
|
db.execute(
|
||||||
|
"""
|
||||||
def _connect(self) -> sqlite3.Connection:
|
CREATE TABLE IF NOT EXISTS audit_events (
|
||||||
db = sqlite3.connect(str(self.database), timeout=30)
|
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
db.execute("PRAGMA journal_mode=WAL")
|
tenant_id TEXT NOT NULL,
|
||||||
db.execute("PRAGMA synchronous=FULL")
|
trace_id TEXT NOT NULL,
|
||||||
db.execute("PRAGMA busy_timeout=30000")
|
event_type TEXT NOT NULL,
|
||||||
return db
|
payload TEXT NOT NULL,
|
||||||
|
previous_hash TEXT NOT NULL,
|
||||||
def _storage(self, operation: Callable[[], T]) -> T:
|
event_hash TEXT NOT NULL UNIQUE,
|
||||||
failure: ConflictError | None = None
|
created_at TEXT NOT NULL
|
||||||
try:
|
|
||||||
return operation()
|
|
||||||
except sqlite3.Error:
|
|
||||||
failure = ConflictError("audit storage unavailable")
|
|
||||||
raise failure
|
|
||||||
|
|
||||||
def _initialize(self) -> None:
|
|
||||||
def initialize() -> None:
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS audit_events (
|
|
||||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
tenant_id TEXT NOT NULL,
|
|
||||||
trace_id TEXT NOT NULL,
|
|
||||||
event_type TEXT NOT NULL,
|
|
||||||
payload TEXT NOT NULL,
|
|
||||||
previous_hash TEXT NOT NULL,
|
|
||||||
event_hash TEXT NOT NULL UNIQUE,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
)
|
||||||
self._storage(initialize)
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
def append(
|
def append(self, *, tenant_id: str, trace_id: str, event_type: str, payload: dict[str, Any]) -> str:
|
||||||
self, *, tenant_id: str, trace_id: str, event_type: str,
|
|
||||||
payload: dict[str, Any],
|
|
||||||
) -> str:
|
|
||||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
||||||
trace_id = require_ident("trace_id", trace_id)
|
trace_id = require_ident("trace_id", trace_id)
|
||||||
event_type = require_ident("event_type", event_type)
|
event_type = require_ident("event_type", event_type)
|
||||||
|
with sqlite3.connect(self.database) as db:
|
||||||
def write() -> str:
|
previous = db.execute(
|
||||||
with closing(self._connect()) as connection, connection as db:
|
"SELECT event_hash FROM audit_events ORDER BY seq DESC LIMIT 1"
|
||||||
db.execute("BEGIN IMMEDIATE")
|
).fetchone()
|
||||||
previous = db.execute(
|
previous_hash = previous[0] if previous else "GENESIS"
|
||||||
"SELECT event_hash FROM audit_events ORDER BY seq DESC LIMIT 1"
|
body = {
|
||||||
).fetchone()
|
"tenant_id": tenant_id,
|
||||||
previous_hash = previous[0] if previous else "GENESIS"
|
"trace_id": trace_id,
|
||||||
body = {
|
"event_type": event_type,
|
||||||
"tenant_id": tenant_id, "trace_id": trace_id,
|
"payload": payload,
|
||||||
"event_type": event_type, "payload": payload,
|
"previous_hash": previous_hash,
|
||||||
"previous_hash": previous_hash, "created_at": isoformat(),
|
"created_at": isoformat(),
|
||||||
}
|
}
|
||||||
digest = hashlib.sha256(canonical_json(body)).hexdigest()
|
digest = hashlib.sha256(
|
||||||
db.execute(
|
canonical_json(body)
|
||||||
"INSERT INTO audit_events VALUES(NULL,?,?,?,?,?,?,?)",
|
).hexdigest()
|
||||||
(
|
db.execute(
|
||||||
tenant_id, trace_id, event_type,
|
"INSERT INTO audit_events VALUES(NULL,?,?,?,?,?,?,?)",
|
||||||
json.dumps(payload, ensure_ascii=False),
|
(
|
||||||
previous_hash, digest, body["created_at"],
|
tenant_id,
|
||||||
),
|
trace_id,
|
||||||
)
|
event_type,
|
||||||
db.commit()
|
json.dumps(payload, ensure_ascii=False),
|
||||||
return digest
|
previous_hash,
|
||||||
|
digest,
|
||||||
return self._storage(write)
|
body["created_at"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return digest
|
||||||
|
|
||||||
def verify(self) -> bool:
|
def verify(self) -> bool:
|
||||||
def check() -> bool:
|
previous = "GENESIS"
|
||||||
previous = "GENESIS"
|
with sqlite3.connect(self.database) as db:
|
||||||
with closing(self._connect()) as connection, connection as db:
|
rows = db.execute(
|
||||||
rows = db.execute(
|
"SELECT tenant_id,trace_id,event_type,payload,previous_hash,event_hash,created_at "
|
||||||
"SELECT tenant_id,trace_id,event_type,payload,previous_hash,"
|
"FROM audit_events ORDER BY seq"
|
||||||
"event_hash,created_at FROM audit_events ORDER BY seq"
|
)
|
||||||
)
|
for row in rows:
|
||||||
for row in rows:
|
body = {
|
||||||
try:
|
"tenant_id": row[0],
|
||||||
payload = json.loads(row[3])
|
"trace_id": row[1],
|
||||||
except (json.JSONDecodeError, TypeError):
|
"event_type": row[2],
|
||||||
return False
|
"payload": json.loads(row[3]),
|
||||||
body = {
|
"previous_hash": row[4],
|
||||||
"tenant_id": row[0], "trace_id": row[1],
|
"created_at": row[6],
|
||||||
"event_type": row[2], "payload": payload,
|
}
|
||||||
"previous_hash": row[4], "created_at": row[6],
|
if row[4] != previous:
|
||||||
}
|
return False
|
||||||
if row[4] != previous:
|
digest = hashlib.sha256(canonical_json(body)).hexdigest()
|
||||||
return False
|
if digest != row[5]:
|
||||||
digest = hashlib.sha256(canonical_json(body)).hexdigest()
|
return False
|
||||||
if digest != row[5]:
|
previous = digest
|
||||||
return False
|
return True
|
||||||
previous = digest
|
|
||||||
return True
|
|
||||||
|
|
||||||
return self._storage(check)
|
|
||||||
|
|||||||
@@ -1,37 +1,13 @@
|
|||||||
"""AIOS V2R1 Tenant Cell lifecycle and atomic registry primitives."""
|
"""AIOS V2R1 Tenant Cell lifecycle CLI primitives."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import fcntl
|
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tempfile
|
|
||||||
import threading
|
|
||||||
from typing import Iterator
|
|
||||||
|
|
||||||
from .tenant_context import ValidationError, require_ident
|
from .tenant_context import ValidationError, require_ident
|
||||||
|
|
||||||
_THREAD_LOCKS_GUARD = threading.Lock()
|
|
||||||
_THREAD_LOCKS: dict[str, threading.RLock] = {}
|
|
||||||
_STATES = {"created", "starting", "healthy", "upgrading", "rollback", "failed"}
|
|
||||||
_TRANSITIONS = {
|
|
||||||
"created": {"starting", "failed"},
|
|
||||||
"starting": {"healthy", "failed"},
|
|
||||||
"healthy": {"upgrading", "failed"},
|
|
||||||
"upgrading": {"healthy", "rollback", "failed"},
|
|
||||||
"rollback": {"healthy", "failed"},
|
|
||||||
"failed": {"starting"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _lock_for(path: Path) -> threading.RLock:
|
|
||||||
key = str(path.resolve())
|
|
||||||
with _THREAD_LOCKS_GUARD:
|
|
||||||
return _THREAD_LOCKS.setdefault(key, threading.RLock())
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class CellRecord:
|
class CellRecord:
|
||||||
@@ -47,116 +23,51 @@ class CellManager:
|
|||||||
def __init__(self, registry: str | Path) -> None:
|
def __init__(self, registry: str | Path) -> None:
|
||||||
self.registry = Path(registry)
|
self.registry = Path(registry)
|
||||||
self.registry.parent.mkdir(parents=True, exist_ok=True)
|
self.registry.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self._lock_file = self.registry.with_name(self.registry.name + ".lock")
|
if not self.registry.exists():
|
||||||
self._thread_lock = _lock_for(self.registry)
|
self.registry.write_text("{\"cells\":{}}\n", encoding="utf-8")
|
||||||
with self._exclusive():
|
|
||||||
if not self.registry.exists():
|
|
||||||
self._save({"cells": {}})
|
|
||||||
else:
|
|
||||||
self._load()
|
|
||||||
|
|
||||||
@contextmanager
|
def _load(self) -> dict:
|
||||||
def _exclusive(self) -> Iterator[None]:
|
return json.loads(self.registry.read_text(encoding="utf-8"))
|
||||||
with self._thread_lock:
|
|
||||||
with self._lock_file.open("a+", encoding="utf-8") as lock:
|
|
||||||
os.chmod(self._lock_file, 0o600)
|
|
||||||
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
|
|
||||||
|
|
||||||
def _load(self) -> dict[str, dict[str, dict[str, str]]]:
|
def _save(self, value: dict) -> None:
|
||||||
failure: ValidationError | None = None
|
self.registry.write_text(
|
||||||
try:
|
json.dumps(value, ensure_ascii=False, indent=2) + "\n",
|
||||||
value = json.loads(self.registry.read_text(encoding="utf-8"))
|
encoding="utf-8",
|
||||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
||||||
failure = ValidationError("cell registry unavailable")
|
|
||||||
if failure is not None:
|
|
||||||
raise failure
|
|
||||||
if not isinstance(value, dict) or set(value) != {"cells"}:
|
|
||||||
raise ValidationError("cell registry unavailable")
|
|
||||||
cells = value["cells"]
|
|
||||||
if not isinstance(cells, dict):
|
|
||||||
raise ValidationError("cell registry unavailable")
|
|
||||||
expected = {
|
|
||||||
"tenant_id", "cell_id", "version", "state",
|
|
||||||
"config_digest", "created_at",
|
|
||||||
}
|
|
||||||
for tenant_id, item in cells.items():
|
|
||||||
if (
|
|
||||||
not isinstance(item, dict) or set(item) != expected
|
|
||||||
or item.get("tenant_id") != tenant_id
|
|
||||||
or item.get("state") not in _STATES
|
|
||||||
or any(not isinstance(item.get(name), str) for name in expected)
|
|
||||||
):
|
|
||||||
raise ValidationError("cell registry unavailable")
|
|
||||||
return value
|
|
||||||
|
|
||||||
def _save(self, value: dict[str, object]) -> None:
|
|
||||||
descriptor, temporary_name = tempfile.mkstemp(
|
|
||||||
prefix=f".{self.registry.name}.", dir=self.registry.parent, text=True
|
|
||||||
)
|
)
|
||||||
temporary = Path(temporary_name)
|
|
||||||
try:
|
|
||||||
with os.fdopen(descriptor, "w", encoding="utf-8") as output:
|
|
||||||
json.dump(value, output, ensure_ascii=False, indent=2)
|
|
||||||
output.write("\n")
|
|
||||||
output.flush()
|
|
||||||
os.fsync(output.fileno())
|
|
||||||
os.chmod(temporary, 0o600)
|
|
||||||
os.replace(temporary, self.registry)
|
|
||||||
directory = os.open(self.registry.parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
||||||
try:
|
|
||||||
os.fsync(directory)
|
|
||||||
finally:
|
|
||||||
os.close(directory)
|
|
||||||
finally:
|
|
||||||
if temporary.exists():
|
|
||||||
temporary.unlink()
|
|
||||||
|
|
||||||
def create(
|
def create(self, tenant_id: str, cell_id: str, version: str, digest: str) -> CellRecord:
|
||||||
self, tenant_id: str, cell_id: str, version: str, digest: str,
|
|
||||||
) -> CellRecord:
|
|
||||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
||||||
cell_id = require_ident("cell_id", cell_id)
|
cell_id = require_ident("cell_id", cell_id)
|
||||||
version = require_ident("version", version)
|
data = self._load()
|
||||||
digest = require_ident("digest", digest)
|
if tenant_id in data["cells"]:
|
||||||
with self._exclusive():
|
raise ValidationError("tenant already has a cell")
|
||||||
data = self._load()
|
record = CellRecord(
|
||||||
if tenant_id in data["cells"]:
|
tenant_id,
|
||||||
raise ValidationError("tenant already has a cell")
|
cell_id,
|
||||||
record = CellRecord(
|
version,
|
||||||
tenant_id, cell_id, version, "created", digest,
|
"created",
|
||||||
datetime.now(timezone.utc).isoformat(),
|
digest,
|
||||||
)
|
datetime.now(timezone.utc).isoformat(),
|
||||||
data["cells"][tenant_id] = record.__dict__
|
)
|
||||||
self._save(data)
|
data["cells"][tenant_id] = record.__dict__
|
||||||
return record
|
self._save(data)
|
||||||
|
return record
|
||||||
|
|
||||||
def status(self, tenant_id: str) -> CellRecord:
|
def status(self, tenant_id: str) -> CellRecord:
|
||||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
data = self._load()
|
||||||
with self._exclusive():
|
item = data["cells"].get(tenant_id)
|
||||||
data = self._load()
|
if not item:
|
||||||
item = data["cells"].get(tenant_id)
|
raise ValidationError("unknown tenant")
|
||||||
if not item:
|
return CellRecord(**item)
|
||||||
raise ValidationError("unknown tenant")
|
|
||||||
return CellRecord(**item)
|
|
||||||
|
|
||||||
def transition(self, tenant_id: str, state: str) -> CellRecord:
|
def transition(self, tenant_id: str, state: str) -> CellRecord:
|
||||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
allowed = {"created", "starting", "healthy", "upgrading", "rollback", "failed"}
|
||||||
if state not in _STATES:
|
if state not in allowed:
|
||||||
raise ValidationError("invalid cell state")
|
raise ValidationError("invalid cell state")
|
||||||
with self._exclusive():
|
data = self._load()
|
||||||
data = self._load()
|
item = data["cells"].get(tenant_id)
|
||||||
item = data["cells"].get(tenant_id)
|
if not item:
|
||||||
if not item:
|
raise ValidationError("unknown tenant")
|
||||||
raise ValidationError("unknown tenant")
|
item["state"] = state
|
||||||
current = item["state"]
|
self._save(data)
|
||||||
if state == current:
|
return CellRecord(**item)
|
||||||
return CellRecord(**item)
|
|
||||||
if state not in _TRANSITIONS[current]:
|
|
||||||
raise ValidationError(f"invalid cell transition: {current}->{state}")
|
|
||||||
item["state"] = state
|
|
||||||
self._save(data)
|
|
||||||
return CellRecord(**item)
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import sys
|
|||||||
|
|
||||||
from .cell_manager import CellManager
|
from .cell_manager import CellManager
|
||||||
from .ingress_service import serve
|
from .ingress_service import serve
|
||||||
from .tenant_context import V2R1Error, ValidationError
|
|
||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
@@ -27,6 +26,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
transition = sub.add_parser("transition")
|
transition = sub.add_parser("transition")
|
||||||
transition.add_argument("tenant_id")
|
transition.add_argument("tenant_id")
|
||||||
transition.add_argument("state")
|
transition.add_argument("state")
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -37,42 +37,22 @@ def build_serve_parser() -> argparse.ArgumentParser:
|
|||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def _domain_failure(error: V2R1Error) -> int:
|
|
||||||
code = "VALIDATION_ERROR" if isinstance(error, ValidationError) else "RUNTIME_ERROR"
|
|
||||||
print(
|
|
||||||
json.dumps({"code": code, "error": str(error)}, ensure_ascii=False, sort_keys=True),
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return 3
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
values = list(sys.argv[1:] if argv is None else argv)
|
values = list(sys.argv[1:] if argv is None else argv)
|
||||||
service_mode = (
|
if values and values[0] == "serve":
|
||||||
bool(values) and values[0] == "serve"
|
|
||||||
and (len(values) == 1 or values[1].startswith("-"))
|
|
||||||
)
|
|
||||||
if service_mode:
|
|
||||||
serve_args = build_serve_parser().parse_args(values[1:])
|
serve_args = build_serve_parser().parse_args(values[1:])
|
||||||
serve(serve_args.host, serve_args.port)
|
serve(serve_args.host, serve_args.port)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
args = build_parser().parse_args(values)
|
args = build_parser().parse_args(values)
|
||||||
try:
|
manager = CellManager(args.registry)
|
||||||
manager = CellManager(args.registry)
|
if args.command == "create":
|
||||||
if args.command == "create":
|
result = manager.create(args.tenant_id, args.cell_id, args.version, args.digest)
|
||||||
result = manager.create(
|
elif args.command == "status":
|
||||||
args.tenant_id, args.cell_id, args.version, args.digest
|
result = manager.status(args.tenant_id)
|
||||||
)
|
elif args.command == "transition":
|
||||||
elif args.command == "status":
|
result = manager.transition(args.tenant_id, args.state)
|
||||||
result = manager.status(args.tenant_id)
|
else:
|
||||||
elif args.command == "transition":
|
return 2
|
||||||
result = manager.transition(args.tenant_id, args.state)
|
|
||||||
else:
|
|
||||||
return 2
|
|
||||||
except V2R1Error as error:
|
|
||||||
return _domain_failure(error)
|
|
||||||
|
|
||||||
print(json.dumps(result.__dict__, ensure_ascii=False, sort_keys=True))
|
print(json.dumps(result.__dict__, ensure_ascii=False, sort_keys=True))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
"""Tenant-partitioned Hermes shared agent fabric primitives."""
|
"""Tenant-partitioned Hermes shared agent fabric primitives."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from contextlib import closing
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import os
|
from contextlib import closing
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from typing import Any, TypeVar
|
from typing import Any
|
||||||
|
|
||||||
from .tenant_context import (
|
from .tenant_context import (
|
||||||
CanonicalMessage, ConflictError, ScopeError, ValidationError,
|
CanonicalMessage,
|
||||||
isoformat, require_ident,
|
ConflictError,
|
||||||
|
ScopeError,
|
||||||
|
ValidationError,
|
||||||
|
isoformat,
|
||||||
|
require_ident,
|
||||||
)
|
)
|
||||||
|
|
||||||
T = TypeVar("T")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class FabricJob:
|
class FabricJob:
|
||||||
@@ -29,7 +29,9 @@ class FabricJob:
|
|||||||
status: str = "queued"
|
status: str = "queued"
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
for name in ("job_id", "cell_id", "profile_id", "session_id", "event_id"):
|
for name in (
|
||||||
|
"job_id", "cell_id", "profile_id", "session_id", "event_id"
|
||||||
|
):
|
||||||
require_ident(name, getattr(self, name))
|
require_ident(name, getattr(self, name))
|
||||||
require_ident("tenant_id", self.tenant_id, tenant=True)
|
require_ident("tenant_id", self.tenant_id, tenant=True)
|
||||||
if self.status not in {"queued", "running", "completed", "failed", "cancelled"}:
|
if self.status not in {"queued", "running", "completed", "failed", "cancelled"}:
|
||||||
@@ -41,51 +43,34 @@ class HermesFabric:
|
|||||||
|
|
||||||
def __init__(self, database: str | Path) -> None:
|
def __init__(self, database: str | Path) -> None:
|
||||||
self.database = str(database)
|
self.database = str(database)
|
||||||
Path(self.database).parent.mkdir(parents=True, exist_ok=True)
|
with closing(sqlite3.connect(self.database)) as connection, connection as db:
|
||||||
self._initialize()
|
db.execute("PRAGMA journal_mode=WAL")
|
||||||
os.chmod(self.database, 0o600)
|
db.execute("PRAGMA synchronous=FULL")
|
||||||
|
db.execute(
|
||||||
def _connect(self) -> sqlite3.Connection:
|
"""
|
||||||
db = sqlite3.connect(self.database, timeout=30)
|
CREATE TABLE IF NOT EXISTS fabric_jobs (
|
||||||
db.execute("PRAGMA journal_mode=WAL")
|
job_id TEXT PRIMARY KEY,
|
||||||
db.execute("PRAGMA synchronous=FULL")
|
tenant_id TEXT NOT NULL,
|
||||||
db.execute("PRAGMA busy_timeout=30000")
|
cell_id TEXT NOT NULL,
|
||||||
return db
|
profile_id TEXT NOT NULL,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
def _storage(self, operation: Callable[[], T]) -> T:
|
event_id TEXT NOT NULL UNIQUE,
|
||||||
failure: ConflictError | None = None
|
status TEXT NOT NULL,
|
||||||
try:
|
created_at TEXT NOT NULL,
|
||||||
return operation()
|
updated_at TEXT NOT NULL
|
||||||
except sqlite3.Error:
|
|
||||||
failure = ConflictError("fabric storage unavailable")
|
|
||||||
raise failure
|
|
||||||
|
|
||||||
def _initialize(self) -> None:
|
|
||||||
def initialize() -> None:
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS fabric_jobs (
|
|
||||||
job_id TEXT PRIMARY KEY,
|
|
||||||
tenant_id TEXT NOT NULL,
|
|
||||||
cell_id TEXT NOT NULL,
|
|
||||||
profile_id TEXT NOT NULL,
|
|
||||||
session_id TEXT NOT NULL,
|
|
||||||
event_id TEXT NOT NULL UNIQUE,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
)
|
||||||
db.execute(
|
"""
|
||||||
"CREATE INDEX IF NOT EXISTS idx_fabric_tenant_queue "
|
)
|
||||||
"ON fabric_jobs(tenant_id, cell_id, status)"
|
db.execute(
|
||||||
)
|
"CREATE INDEX IF NOT EXISTS idx_fabric_tenant_queue "
|
||||||
self._storage(initialize)
|
"ON fabric_jobs(tenant_id, cell_id, status)"
|
||||||
|
)
|
||||||
|
|
||||||
def enqueue(
|
def enqueue(
|
||||||
self, *, message: CanonicalMessage, profile_id: str,
|
self,
|
||||||
|
*,
|
||||||
|
message: CanonicalMessage,
|
||||||
|
profile_id: str,
|
||||||
) -> FabricJob:
|
) -> FabricJob:
|
||||||
profile_id = require_ident("profile_id", profile_id)
|
profile_id = require_ident("profile_id", profile_id)
|
||||||
if not message.command_capable:
|
if not message.command_capable:
|
||||||
@@ -93,72 +78,66 @@ class HermesFabric:
|
|||||||
if not message.is_authenticated_human_command:
|
if not message.is_authenticated_human_command:
|
||||||
raise ScopeError("only authenticated human commands can create jobs")
|
raise ScopeError("only authenticated human commands can create jobs")
|
||||||
job = FabricJob(
|
job = FabricJob(
|
||||||
job_id=f"job-{message.event_id}", tenant_id=message.tenant_id,
|
job_id=f"job-{message.event_id}",
|
||||||
cell_id=message.cell_id, profile_id=profile_id,
|
tenant_id=message.tenant_id,
|
||||||
session_id=message.session_id, event_id=message.event_id,
|
cell_id=message.cell_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
session_id=message.session_id,
|
||||||
|
event_id=message.event_id,
|
||||||
)
|
)
|
||||||
now = isoformat()
|
now = isoformat()
|
||||||
failure: ConflictError | None = None
|
conflict: ConflictError | None = None
|
||||||
try:
|
try:
|
||||||
with closing(self._connect()) as connection, connection as db:
|
with closing(sqlite3.connect(self.database)) as connection, connection as db:
|
||||||
|
db.execute("PRAGMA journal_mode=WAL")
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT INTO fabric_jobs VALUES(?,?,?,?,?,?,?,?,?)",
|
"INSERT INTO fabric_jobs VALUES(?,?,?,?,?,?,?,?,?)",
|
||||||
(
|
(
|
||||||
job.job_id, job.tenant_id, job.cell_id, job.profile_id,
|
job.job_id,
|
||||||
job.session_id, job.event_id, job.status, now, now,
|
job.tenant_id,
|
||||||
|
job.cell_id,
|
||||||
|
job.profile_id,
|
||||||
|
job.session_id,
|
||||||
|
job.event_id,
|
||||||
|
job.status,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
failure = ConflictError(
|
conflict = ConflictError(
|
||||||
"fabric event already exists or violates queue uniqueness"
|
"fabric event already exists or violates queue uniqueness"
|
||||||
)
|
)
|
||||||
except sqlite3.Error:
|
if conflict is not None:
|
||||||
failure = ConflictError("fabric storage unavailable")
|
raise conflict
|
||||||
if failure is not None:
|
|
||||||
raise failure
|
|
||||||
return job
|
return job
|
||||||
|
|
||||||
def claim(
|
def claim(self, tenant_id: str, cell_id: str, profile_id: str) -> FabricJob | None:
|
||||||
self, tenant_id: str, cell_id: str, profile_id: str,
|
require_ident("tenant_id", tenant_id, tenant=True)
|
||||||
) -> FabricJob | None:
|
require_ident("cell_id", cell_id)
|
||||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
require_ident("profile_id", profile_id)
|
||||||
cell_id = require_ident("cell_id", cell_id)
|
with closing(sqlite3.connect(self.database)) as connection, connection as db:
|
||||||
profile_id = require_ident("profile_id", profile_id)
|
db.execute("BEGIN IMMEDIATE")
|
||||||
|
row = db.execute(
|
||||||
def claim_one() -> FabricJob | None:
|
"SELECT * FROM fabric_jobs WHERE tenant_id=? AND cell_id=? "
|
||||||
with closing(self._connect()) as connection, connection as db:
|
"AND profile_id=? AND status='queued' ORDER BY created_at LIMIT 1",
|
||||||
db.execute("BEGIN IMMEDIATE")
|
(tenant_id, cell_id, profile_id),
|
||||||
row = db.execute(
|
).fetchone()
|
||||||
"SELECT * FROM fabric_jobs WHERE tenant_id=? AND cell_id=? "
|
if not row:
|
||||||
"AND profile_id=? AND status='queued' ORDER BY created_at LIMIT 1",
|
|
||||||
(tenant_id, cell_id, profile_id),
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
|
||||||
db.commit()
|
|
||||||
return None
|
|
||||||
updated = db.execute(
|
|
||||||
"UPDATE fabric_jobs SET status='running', updated_at=? "
|
|
||||||
"WHERE job_id=? AND status='queued'",
|
|
||||||
(isoformat(), row[0]),
|
|
||||||
)
|
|
||||||
if updated.rowcount != 1:
|
|
||||||
db.rollback()
|
|
||||||
return None
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return FabricJob(
|
return None
|
||||||
job_id=row[0], tenant_id=row[1], cell_id=row[2],
|
db.execute(
|
||||||
profile_id=row[3], session_id=row[4], event_id=row[5],
|
"UPDATE fabric_jobs SET status='running', updated_at=? WHERE job_id=?",
|
||||||
status="running",
|
(isoformat(), row[0]),
|
||||||
)
|
)
|
||||||
|
db.commit()
|
||||||
return self._storage(claim_one)
|
return FabricJob(*row[:7])
|
||||||
|
|
||||||
def health(self) -> dict[str, Any]:
|
def health(self) -> dict[str, Any]:
|
||||||
def query() -> int:
|
with closing(sqlite3.connect(self.database)) as connection, connection as db:
|
||||||
with closing(self._connect()) as connection, connection as db:
|
count = db.execute("SELECT count(*) FROM fabric_jobs").fetchone()[0]
|
||||||
return int(db.execute("SELECT count(*) FROM fabric_jobs").fetchone()[0])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"component": "hermes_fabric", "jobs": self._storage(query),
|
"component": "hermes_fabric",
|
||||||
|
"jobs": count,
|
||||||
"checked_at": datetime.now(timezone.utc).isoformat(),
|
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""Hermes shared-fabric profile isolation primitives."""
|
"""Hermes shared fabric profile isolation primitives."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
|
||||||
from .tenant_context import ValidationError, require_ident
|
from .tenant_context import ValidationError, require_ident
|
||||||
|
|
||||||
@@ -20,12 +20,7 @@ class HermesProfile:
|
|||||||
require_ident("tenant_id", self.tenant_id, tenant=True)
|
require_ident("tenant_id", self.tenant_id, tenant=True)
|
||||||
for name in ("cell_id", "profile_id"):
|
for name in ("cell_id", "profile_id"):
|
||||||
require_ident(name, getattr(self, name))
|
require_ident(name, getattr(self, name))
|
||||||
if (
|
if not self.hermes_home.is_absolute() or not self.workspace.is_absolute():
|
||||||
not isinstance(self.hermes_home, Path)
|
|
||||||
or not isinstance(self.workspace, Path)
|
|
||||||
or not self.hermes_home.is_absolute()
|
|
||||||
or not self.workspace.is_absolute()
|
|
||||||
):
|
|
||||||
raise ValidationError("profile paths must be absolute")
|
raise ValidationError("profile paths must be absolute")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -38,28 +33,13 @@ class HermesProfile:
|
|||||||
"AIOS_PROFILE_ID": self.profile_id,
|
"AIOS_PROFILE_ID": self.profile_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _reject_symlink_components(root: Path) -> None:
|
|
||||||
current = Path(root.anchor)
|
|
||||||
for part in root.parts[1:]:
|
|
||||||
current = current / part
|
|
||||||
if current.is_symlink():
|
|
||||||
raise ValidationError("profile root contains symlink")
|
|
||||||
|
|
||||||
def _validate_roots(self) -> None:
|
|
||||||
for root in (self.hermes_home, self.workspace):
|
|
||||||
self._reject_symlink_components(root)
|
|
||||||
|
|
||||||
def prepare(self) -> None:
|
def prepare(self) -> None:
|
||||||
self._validate_roots()
|
self.hermes_home.mkdir(parents=True, exist_ok=True)
|
||||||
for root in (self.hermes_home, self.workspace):
|
self.workspace.mkdir(parents=True, exist_ok=True)
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
os.chmod(self.hermes_home, 0o700)
|
||||||
self._validate_roots()
|
os.chmod(self.workspace, 0o700)
|
||||||
for root in (self.hermes_home, self.workspace):
|
|
||||||
os.chmod(root, 0o700)
|
|
||||||
|
|
||||||
def assert_path_allowed(self, path: str | Path) -> None:
|
def assert_path_allowed(self, path: str | Path) -> None:
|
||||||
self._validate_roots()
|
|
||||||
target = Path(path).resolve()
|
target = Path(path).resolve()
|
||||||
for root in (self.hermes_home.resolve(), self.workspace.resolve()):
|
for root in (self.hermes_home.resolve(), self.workspace.resolve()):
|
||||||
if target == root or root in target.parents:
|
if target == root or root in target.parents:
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import socket
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -14,31 +11,16 @@ from urllib.parse import urlsplit
|
|||||||
|
|
||||||
MAX_REQUEST_BYTES = 1024 * 1024
|
MAX_REQUEST_BYTES = 1024 * 1024
|
||||||
MAX_RESPONSE_BYTES = 4096
|
MAX_RESPONSE_BYTES = 4096
|
||||||
REQUEST_IO_TIMEOUT_SECONDS = 0.5
|
|
||||||
REQUEST_NETWORK_DEADLINE_SECONDS = 1.0
|
|
||||||
HEX_DIGITS = frozenset("0123456789abcdefABCDEF")
|
|
||||||
HEADER_NAME_BYTES = frozenset(
|
|
||||||
b"!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
||||||
)
|
|
||||||
INTERNAL_ERROR_BODY = b'{"status":"INTERNAL_ERROR"}'
|
|
||||||
MUTATION_ROUTES = frozenset(
|
MUTATION_ROUTES = frozenset(
|
||||||
{"/v1/ingress", "/v1/receipts/status", "/v1/outbound-receipts"}
|
{"/v1/ingress", "/v1/receipts/status", "/v1/outbound-receipts"}
|
||||||
)
|
)
|
||||||
Processor = Callable[[str, bytes], tuple[int, Mapping[str, Any]]]
|
Processor = Callable[[str, bytes], tuple[int, Mapping[str, Any]]]
|
||||||
|
|
||||||
|
|
||||||
class RequestRejected(Exception):
|
|
||||||
def __init__(self, status: int, payload_status: str) -> None:
|
|
||||||
super().__init__(payload_status)
|
|
||||||
self.status = status
|
|
||||||
self.payload_status = payload_status
|
|
||||||
|
|
||||||
|
|
||||||
class IngressHTTPServer(ThreadingHTTPServer):
|
class IngressHTTPServer(ThreadingHTTPServer):
|
||||||
"""Threaded loopback server with an optional externally supplied processor."""
|
"""Threaded loopback server with an optional externally supplied processor."""
|
||||||
|
|
||||||
daemon_threads = False
|
daemon_threads = True
|
||||||
block_on_close = True
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -46,387 +28,39 @@ class IngressHTTPServer(ThreadingHTTPServer):
|
|||||||
handler: type[BaseHTTPRequestHandler],
|
handler: type[BaseHTTPRequestHandler],
|
||||||
processor: Processor | None,
|
processor: Processor | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._closing = threading.Event()
|
|
||||||
self._lifecycle_lock = threading.RLock()
|
|
||||||
self._connections_lock = threading.Lock()
|
|
||||||
self._connections: set[socket.socket] = set()
|
|
||||||
self._server_close_lock = threading.Lock()
|
|
||||||
self.processor = processor
|
self.processor = processor
|
||||||
super().__init__(address, handler)
|
super().__init__(address, handler)
|
||||||
|
|
||||||
@property
|
|
||||||
def is_closing(self) -> bool:
|
|
||||||
return self._closing.is_set()
|
|
||||||
|
|
||||||
def get_request(self) -> tuple[socket.socket, tuple[str, int]]:
|
|
||||||
"""Register each accepted socket before handler scheduling can begin."""
|
|
||||||
connection, client_address = super().get_request()
|
|
||||||
with self._lifecycle_lock:
|
|
||||||
if self._closing.is_set():
|
|
||||||
reject = True
|
|
||||||
else:
|
|
||||||
with self._connections_lock:
|
|
||||||
self._connections.add(connection)
|
|
||||||
reject = False
|
|
||||||
if reject:
|
|
||||||
self._shutdown_and_close_connection(connection)
|
|
||||||
raise OSError("server is closing")
|
|
||||||
return connection, client_address
|
|
||||||
|
|
||||||
def unregister_connection(self, connection: socket.socket) -> None:
|
|
||||||
with self._connections_lock:
|
|
||||||
self._connections.discard(connection)
|
|
||||||
|
|
||||||
def _shutdown_and_close_connection(self, connection: socket.socket) -> None:
|
|
||||||
try:
|
|
||||||
connection.shutdown(socket.SHUT_RDWR)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
connection.close()
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
self.unregister_connection(connection)
|
|
||||||
|
|
||||||
def _close_accepted_connections(self) -> None:
|
|
||||||
with self._connections_lock:
|
|
||||||
connections = tuple(self._connections)
|
|
||||||
for connection in connections:
|
|
||||||
self._shutdown_and_close_connection(connection)
|
|
||||||
|
|
||||||
def process_request(
|
|
||||||
self,
|
|
||||||
request: socket.socket,
|
|
||||||
client_address: tuple[str, int],
|
|
||||||
) -> None:
|
|
||||||
"""Linearize request admission with the transition to closing."""
|
|
||||||
with self._lifecycle_lock:
|
|
||||||
if self._closing.is_set():
|
|
||||||
reject = True
|
|
||||||
else:
|
|
||||||
reject = False
|
|
||||||
try:
|
|
||||||
super().process_request(request, client_address)
|
|
||||||
except Exception:
|
|
||||||
self._shutdown_and_close_connection(request)
|
|
||||||
raise
|
|
||||||
if reject:
|
|
||||||
self._shutdown_and_close_connection(request)
|
|
||||||
|
|
||||||
def shutdown_request(self, request: socket.socket) -> None:
|
|
||||||
try:
|
|
||||||
request.shutdown(socket.SHUT_RDWR)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
super().shutdown_request(request)
|
|
||||||
finally:
|
|
||||||
self.unregister_connection(request)
|
|
||||||
|
|
||||||
def call_processor(self, path: str, body: bytes) -> tuple[bool, Any]:
|
|
||||||
"""Start processing atomically with respect to server shutdown."""
|
|
||||||
with self._lifecycle_lock:
|
|
||||||
if self._closing.is_set() or self.processor is None:
|
|
||||||
return False, None
|
|
||||||
return True, self.processor(path, body)
|
|
||||||
|
|
||||||
def _begin_closing(self) -> None:
|
|
||||||
with self._lifecycle_lock:
|
|
||||||
self._closing.set()
|
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
|
||||||
self._begin_closing()
|
|
||||||
self._close_accepted_connections()
|
|
||||||
super().shutdown()
|
|
||||||
|
|
||||||
def server_close(self) -> None:
|
|
||||||
"""Interrupt accepted sockets before the base class joins handlers."""
|
|
||||||
with self._server_close_lock:
|
|
||||||
self._begin_closing()
|
|
||||||
self._close_accepted_connections()
|
|
||||||
super().server_close()
|
|
||||||
|
|
||||||
def handle_error(
|
|
||||||
self,
|
|
||||||
request: socket.socket,
|
|
||||||
client_address: tuple[str, int],
|
|
||||||
) -> None:
|
|
||||||
"""Close unexpected handler failures without printing request data."""
|
|
||||||
try:
|
|
||||||
request.shutdown(socket.SHUT_RDWR)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class _ValidatingHeaderReader:
|
|
||||||
"""Validate raw header lines before the standard library normalizes them."""
|
|
||||||
|
|
||||||
def __init__(self, handler: "IngressRequestHandler", reader: Any) -> None:
|
|
||||||
self._handler = handler
|
|
||||||
self._reader = reader
|
|
||||||
|
|
||||||
def readline(self, limit: int = -1) -> bytes:
|
|
||||||
try:
|
|
||||||
line = self._reader.readline(limit)
|
|
||||||
except TimeoutError as exc:
|
|
||||||
raise RequestRejected(408, "REQUEST_TIMEOUT") from exc
|
|
||||||
self._handler._ensure_network_read_active()
|
|
||||||
self._validate(line)
|
|
||||||
return line
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validate(line: bytes) -> None:
|
|
||||||
if line == b"\r\n":
|
|
||||||
return
|
|
||||||
if not line or not line.endswith(b"\r\n"):
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
raw = line[:-2]
|
|
||||||
if not raw or raw[:1] in {b" ", b"\t"}:
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
separator = raw.find(b":")
|
|
||||||
if separator <= 0:
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
name = raw[:separator]
|
|
||||||
value = raw[separator + 1 :]
|
|
||||||
if any(byte not in HEADER_NAME_BYTES for byte in name):
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
if any((byte < 0x20 and byte != 0x09) or byte == 0x7F for byte in value):
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
if name.lower() == b"content-length":
|
|
||||||
numeric = value[1:] if value.startswith(b" ") else value
|
|
||||||
if not numeric or any(byte < 0x30 or byte > 0x39 for byte in numeric):
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
|
|
||||||
|
|
||||||
class IngressRequestHandler(BaseHTTPRequestHandler):
|
class IngressRequestHandler(BaseHTTPRequestHandler):
|
||||||
"""Bounded JSON transport; application processing is injected later."""
|
"""Bounded JSON transport; application processing is injected later."""
|
||||||
|
|
||||||
server: IngressHTTPServer
|
server: IngressHTTPServer
|
||||||
|
|
||||||
def setup(self) -> None:
|
|
||||||
super().setup()
|
|
||||||
self.connection.settimeout(REQUEST_IO_TIMEOUT_SECONDS)
|
|
||||||
self._network_read_lock = threading.Lock()
|
|
||||||
self._network_read_expired = threading.Event()
|
|
||||||
self._network_read_deadline: float | None = None
|
|
||||||
self._network_read_timer: threading.Timer | None = None
|
|
||||||
self._network_read_active = False
|
|
||||||
|
|
||||||
def finish(self) -> None:
|
|
||||||
self._complete_network_read()
|
|
||||||
try:
|
|
||||||
super().finish()
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _start_network_read(self) -> None:
|
|
||||||
self._complete_network_read()
|
|
||||||
timer = threading.Timer(
|
|
||||||
REQUEST_NETWORK_DEADLINE_SECONDS,
|
|
||||||
self._expire_network_read,
|
|
||||||
)
|
|
||||||
timer.name = f"aios-ingress-read-deadline-{id(self)}"
|
|
||||||
timer.daemon = True
|
|
||||||
with self._network_read_lock:
|
|
||||||
self._network_read_expired.clear()
|
|
||||||
self._network_read_deadline = (
|
|
||||||
time.monotonic() + REQUEST_NETWORK_DEADLINE_SECONDS
|
|
||||||
)
|
|
||||||
self._network_read_active = True
|
|
||||||
self._network_read_timer = timer
|
|
||||||
timer.start()
|
|
||||||
|
|
||||||
def _expire_network_read(self) -> None:
|
|
||||||
with self._network_read_lock:
|
|
||||||
if not self._network_read_active:
|
|
||||||
return
|
|
||||||
self._network_read_expired.set()
|
|
||||||
self.close_connection = True
|
|
||||||
try:
|
|
||||||
self.connection.shutdown(socket.SHUT_RD)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _complete_network_read(self) -> None:
|
|
||||||
timer: threading.Timer | None
|
|
||||||
with getattr(self, "_network_read_lock", threading.Lock()):
|
|
||||||
self._network_read_active = False
|
|
||||||
self._network_read_deadline = None
|
|
||||||
timer = self._network_read_timer
|
|
||||||
self._network_read_timer = None
|
|
||||||
if timer is not None:
|
|
||||||
timer.cancel()
|
|
||||||
if timer is not threading.current_thread():
|
|
||||||
timer.join(timeout=REQUEST_IO_TIMEOUT_SECONDS)
|
|
||||||
|
|
||||||
def _ensure_network_read_active(self) -> None:
|
|
||||||
if self.server.is_closing:
|
|
||||||
raise RequestRejected(503, "SERVICE_UNAVAILABLE")
|
|
||||||
deadline = self._network_read_deadline
|
|
||||||
if self._network_read_expired.is_set() or (
|
|
||||||
deadline is not None and time.monotonic() >= deadline
|
|
||||||
):
|
|
||||||
self._network_read_expired.set()
|
|
||||||
raise RequestRejected(408, "REQUEST_TIMEOUT")
|
|
||||||
|
|
||||||
def parse_request(self) -> bool:
|
|
||||||
original_reader = self.rfile
|
|
||||||
self.rfile = _ValidatingHeaderReader(self, original_reader) # type: ignore[assignment]
|
|
||||||
try:
|
|
||||||
return super().parse_request()
|
|
||||||
finally:
|
|
||||||
self.rfile = original_reader
|
|
||||||
|
|
||||||
def handle_one_request(self) -> None:
|
|
||||||
self._start_network_read()
|
|
||||||
try:
|
|
||||||
super().handle_one_request()
|
|
||||||
except RequestRejected as rejection:
|
|
||||||
self.close_connection = True
|
|
||||||
self._send_safely(rejection.status, {"status": rejection.payload_status})
|
|
||||||
except TimeoutError:
|
|
||||||
self.close_connection = True
|
|
||||||
self._send_safely(408, {"status": "REQUEST_TIMEOUT"})
|
|
||||||
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
||||||
self.close_connection = True
|
|
||||||
except Exception:
|
|
||||||
self.close_connection = True
|
|
||||||
self._send_safely(500, {"status": "INTERNAL_ERROR"})
|
|
||||||
finally:
|
|
||||||
self._complete_network_read()
|
|
||||||
|
|
||||||
def send_error(
|
|
||||||
self,
|
|
||||||
code: int,
|
|
||||||
message: str | None = None,
|
|
||||||
explain: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Replace BaseHTTPRequestHandler HTML errors with bounded JSON."""
|
|
||||||
self.close_connection = True
|
|
||||||
if code == 501:
|
|
||||||
self._method_not_allowed()
|
|
||||||
return
|
|
||||||
self._send(int(code), {"status": "HTTP_ERROR"})
|
|
||||||
|
|
||||||
def log_message(self, format: str, *args: object) -> None:
|
def log_message(self, format: str, *args: object) -> None:
|
||||||
return
|
return
|
||||||
|
|
||||||
def _send_safely(self, status: int, payload: Mapping[str, Any]) -> None:
|
|
||||||
try:
|
|
||||||
self._send(status, payload)
|
|
||||||
except OSError:
|
|
||||||
self.close_connection = True
|
|
||||||
|
|
||||||
def _path(self) -> str:
|
def _path(self) -> str:
|
||||||
target = self.path
|
return urlsplit(self.path).path
|
||||||
if not target or any(
|
|
||||||
ord(character) < 0x20 or ord(character) == 0x7F for character in target
|
|
||||||
):
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
for index, character in enumerate(target):
|
|
||||||
if character == "%" and (
|
|
||||||
index + 2 >= len(target)
|
|
||||||
or any(value not in HEX_DIGITS for value in target[index + 1 : index + 3])
|
|
||||||
):
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
try:
|
|
||||||
parts = urlsplit(target)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST") from exc
|
|
||||||
if parts.fragment:
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
if target.startswith("/"):
|
|
||||||
if parts.scheme or parts.netloc:
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
elif parts.scheme in {"http", "https"} and parts.netloc:
|
|
||||||
if parts.hostname is None:
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
try:
|
|
||||||
_ = parts.port
|
|
||||||
except ValueError as exc:
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST") from exc
|
|
||||||
else:
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
return parts.path or "/"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _encoded_response(
|
|
||||||
status: object,
|
|
||||||
payload: object,
|
|
||||||
) -> tuple[int, bytes]:
|
|
||||||
if (
|
|
||||||
type(status) is not int
|
|
||||||
or not 200 <= status <= 599
|
|
||||||
or status in {204, 205, 304}
|
|
||||||
or not isinstance(payload, Mapping)
|
|
||||||
):
|
|
||||||
return 500, INTERNAL_ERROR_BODY
|
|
||||||
try:
|
|
||||||
body = json.dumps(
|
|
||||||
dict(payload),
|
|
||||||
ensure_ascii=False,
|
|
||||||
separators=(",", ":"),
|
|
||||||
sort_keys=True,
|
|
||||||
allow_nan=False,
|
|
||||||
).encode("utf-8")
|
|
||||||
except Exception:
|
|
||||||
return 500, INTERNAL_ERROR_BODY
|
|
||||||
if len(body) > MAX_RESPONSE_BYTES:
|
|
||||||
return 500, INTERNAL_ERROR_BODY
|
|
||||||
return status, body
|
|
||||||
|
|
||||||
def _send(self, status: int, payload: Mapping[str, Any]) -> None:
|
def _send(self, status: int, payload: Mapping[str, Any]) -> None:
|
||||||
self._complete_network_read()
|
body = json.dumps(
|
||||||
status, body = self._encoded_response(status, payload)
|
dict(payload), ensure_ascii=False, separators=(",", ":"), sort_keys=True
|
||||||
|
).encode("utf-8")
|
||||||
|
if len(body) > MAX_RESPONSE_BYTES:
|
||||||
|
status = 500
|
||||||
|
body = b'{"status":"INTERNAL_ERROR"}'
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
self.send_header("Content-Length", str(len(body)))
|
self.send_header("Content-Length", str(len(body)))
|
||||||
self.send_header("Cache-Control", "no-store")
|
self.send_header("Cache-Control", "no-store")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
if getattr(self, "command", None) != "HEAD":
|
if self.command != "HEAD":
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
|
||||||
def _method_not_allowed(self) -> None:
|
def _method_not_allowed(self) -> None:
|
||||||
self._send(405, {"status": "METHOD_NOT_ALLOWED"})
|
self._send(405, {"status": "METHOD_NOT_ALLOWED"})
|
||||||
|
|
||||||
def _reject(self, status: int, payload_status: str) -> None:
|
|
||||||
self.close_connection = True
|
|
||||||
self._send(status, {"status": payload_status})
|
|
||||||
|
|
||||||
def _content_length(self) -> int:
|
|
||||||
if self.headers.get_all("Transfer-Encoding", []):
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
values = self.headers.get_all("Content-Length", [])
|
|
||||||
if len(values) > 1:
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
if not values:
|
|
||||||
return 0
|
|
||||||
raw_length = values[0]
|
|
||||||
if (
|
|
||||||
not raw_length
|
|
||||||
or not raw_length.isascii()
|
|
||||||
or not raw_length.isdigit()
|
|
||||||
):
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
try:
|
|
||||||
length = int(raw_length)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST") from exc
|
|
||||||
if length > MAX_REQUEST_BYTES:
|
|
||||||
raise RequestRejected(413, "REQUEST_TOO_LARGE")
|
|
||||||
return length
|
|
||||||
|
|
||||||
def _read_exact_body(self, length: int) -> bytes:
|
|
||||||
try:
|
|
||||||
body = self.rfile.read(length)
|
|
||||||
except TimeoutError as exc:
|
|
||||||
raise RequestRejected(408, "REQUEST_TIMEOUT") from exc
|
|
||||||
self._ensure_network_read_active()
|
|
||||||
if len(body) != length:
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
return body
|
|
||||||
|
|
||||||
def do_GET(self) -> None:
|
def do_GET(self) -> None:
|
||||||
path = self._path()
|
path = self._path()
|
||||||
if path == "/health/live":
|
if path == "/health/live":
|
||||||
@@ -449,34 +83,22 @@ class IngressRequestHandler(BaseHTTPRequestHandler):
|
|||||||
else:
|
else:
|
||||||
self._send(404, {"status": "NOT_FOUND"})
|
self._send(404, {"status": "NOT_FOUND"})
|
||||||
return
|
return
|
||||||
try:
|
|
||||||
length = self._content_length()
|
|
||||||
except RequestRejected as rejection:
|
|
||||||
self._reject(rejection.status, rejection.payload_status)
|
|
||||||
return
|
|
||||||
processor = self.server.processor
|
processor = self.server.processor
|
||||||
if processor is None:
|
if processor is None:
|
||||||
self._send(503, {"status": "SERVICE_NOT_CONFIGURED"})
|
self._send(503, {"status": "SERVICE_NOT_CONFIGURED"})
|
||||||
return
|
return
|
||||||
if self.server.is_closing:
|
|
||||||
self._reject(503, "SERVICE_UNAVAILABLE")
|
|
||||||
return
|
|
||||||
try:
|
try:
|
||||||
body = self._read_exact_body(length)
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
except RequestRejected as rejection:
|
if length < 0 or length > MAX_REQUEST_BYTES:
|
||||||
self._reject(rejection.status, rejection.payload_status)
|
self._send(413, {"status": "REQUEST_TOO_LARGE"})
|
||||||
return
|
|
||||||
self._complete_network_read()
|
|
||||||
try:
|
|
||||||
started, result = self.server.call_processor(path, body)
|
|
||||||
if not started:
|
|
||||||
self._reject(503, "SERVICE_UNAVAILABLE")
|
|
||||||
return
|
return
|
||||||
status, payload = result
|
body = self.rfile.read(length)
|
||||||
|
status, payload = processor(path, body)
|
||||||
|
if not isinstance(status, int) or not isinstance(payload, Mapping):
|
||||||
|
raise TypeError("invalid processor response")
|
||||||
|
self._send(status, payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._send(500, {"status": "INTERNAL_ERROR"})
|
self._send(500, {"status": "INTERNAL_ERROR"})
|
||||||
return
|
|
||||||
self._send(status, payload)
|
|
||||||
|
|
||||||
do_DELETE = _method_not_allowed
|
do_DELETE = _method_not_allowed
|
||||||
do_HEAD = _method_not_allowed
|
do_HEAD = _method_not_allowed
|
||||||
@@ -496,8 +118,8 @@ def build_server(
|
|||||||
address = ipaddress.ip_address(host)
|
address = ipaddress.ip_address(host)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError("host must be a loopback IP address") from exc
|
raise ValueError("host must be a loopback IP address") from exc
|
||||||
if address.version != 4 or not address.is_loopback:
|
if not address.is_loopback:
|
||||||
raise ValueError("host must be an IPv4 loopback address")
|
raise ValueError("host must be a loopback IP address")
|
||||||
return IngressHTTPServer((host, port), IngressRequestHandler, processor)
|
return IngressHTTPServer((host, port), IngressRequestHandler, processor)
|
||||||
|
|
||||||
|
|
||||||
@@ -506,7 +128,5 @@ def serve(host: str = "127.0.0.1", port: int = 18792) -> None:
|
|||||||
server = build_server(host, port)
|
server = build_server(host, port)
|
||||||
try:
|
try:
|
||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
except KeyboardInterrupt:
|
|
||||||
return
|
|
||||||
finally:
|
finally:
|
||||||
server.server_close()
|
server.server_close()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import Literal
|
|||||||
|
|
||||||
from .tenant_context import ScopeError, ValidationError, require_ident
|
from .tenant_context import ScopeError, ValidationError, require_ident
|
||||||
|
|
||||||
|
|
||||||
MemoryKind = Literal["session", "profile", "cell", "tenant", "shared_artifact"]
|
MemoryKind = Literal["session", "profile", "cell", "tenant", "shared_artifact"]
|
||||||
|
|
||||||
|
|
||||||
@@ -48,8 +49,6 @@ class MemoryScope:
|
|||||||
if write:
|
if write:
|
||||||
raise ScopeError("shared artifacts are immutable")
|
raise ScopeError("shared artifacts are immutable")
|
||||||
return
|
return
|
||||||
if self.kind != requested.kind:
|
|
||||||
raise ScopeError("cross-kind memory access denied")
|
|
||||||
if self.profile_id != requested.profile_id and requested.kind in {"profile", "session"}:
|
if self.profile_id != requested.profile_id and requested.kind in {"profile", "session"}:
|
||||||
raise ScopeError("cross-profile memory access denied")
|
raise ScopeError("cross-profile memory access denied")
|
||||||
if requested.kind == "session" and self.session_id != requested.session_id:
|
if requested.kind == "session" and self.session_id != requested.session_id:
|
||||||
@@ -57,7 +56,5 @@ class MemoryScope:
|
|||||||
|
|
||||||
|
|
||||||
def qualify_memory_key(scope: MemoryScope, key: str) -> str:
|
def qualify_memory_key(scope: MemoryScope, key: str) -> str:
|
||||||
normalized = require_ident("memory_key", key)
|
key = require_ident("memory_key", key)
|
||||||
if normalized != key:
|
return f"{scope.namespace}/{key}"
|
||||||
raise ValidationError("memory_key must be canonical")
|
|
||||||
return f"{scope.namespace}/{normalized}"
|
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
"""Tenant-scoped append-only message ledger and exact quote resolver."""
|
"""Tenant-scoped append-only message ledger and exact quote resolver."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from contextlib import closing
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from contextlib import closing
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from typing import TypeVar
|
from typing import Iterable
|
||||||
|
|
||||||
from .tenant_context import (
|
from .tenant_context import (
|
||||||
CanonicalMessage, ConflictError, ScopeError, ValidationError,
|
CanonicalMessage,
|
||||||
isoformat, normalize_message_id, utc_now,
|
ConflictError,
|
||||||
|
ScopeError,
|
||||||
|
ValidationError,
|
||||||
|
isoformat,
|
||||||
|
normalize_message_id,
|
||||||
|
parse_time,
|
||||||
|
utc_now,
|
||||||
)
|
)
|
||||||
|
|
||||||
T = TypeVar("T")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class LedgerRecord:
|
class LedgerRecord:
|
||||||
@@ -34,7 +37,11 @@ class LedgerRecord:
|
|||||||
|
|
||||||
|
|
||||||
class MessageLedger:
|
class MessageLedger:
|
||||||
"""Exact-ID ledger with no fuzzy, prefix, or time-nearest fallback."""
|
"""Exact-ID ledger.
|
||||||
|
|
||||||
|
The resolver never uses time windows, prefixes, similarity, or nearest-message
|
||||||
|
fallbacks. Ambiguity and absence fail closed.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, database: str | Path, *, ttl_days: int = 7) -> None:
|
def __init__(self, database: str | Path, *, ttl_days: int = 7) -> None:
|
||||||
if not 1 <= ttl_days <= 365:
|
if not 1 <= ttl_days <= 365:
|
||||||
@@ -56,64 +63,64 @@ class MessageLedger:
|
|||||||
db.execute("PRAGMA busy_timeout=30000")
|
db.execute("PRAGMA busy_timeout=30000")
|
||||||
return db
|
return db
|
||||||
|
|
||||||
def _storage(self, operation: Callable[[], T]) -> T:
|
|
||||||
failure: ConflictError | None = None
|
|
||||||
try:
|
|
||||||
return operation()
|
|
||||||
except sqlite3.Error:
|
|
||||||
failure = ConflictError("ledger storage unavailable")
|
|
||||||
# Raise after the driver exception handler, keeping SQLite details out of
|
|
||||||
# both the public exception and its implicit context.
|
|
||||||
raise failure
|
|
||||||
|
|
||||||
def _initialize(self) -> None:
|
def _initialize(self) -> None:
|
||||||
def initialize() -> None:
|
with closing(self._connect()) as connection, connection as db:
|
||||||
with closing(self._connect()) as connection, connection as db:
|
db.executescript(
|
||||||
db.executescript(
|
"""
|
||||||
"""
|
CREATE TABLE IF NOT EXISTS message_ledger (
|
||||||
CREATE TABLE IF NOT EXISTS message_ledger (
|
tenant_id TEXT NOT NULL,
|
||||||
tenant_id TEXT NOT NULL,
|
cell_id TEXT NOT NULL,
|
||||||
cell_id TEXT NOT NULL,
|
channel TEXT NOT NULL,
|
||||||
channel TEXT NOT NULL,
|
channel_account_id TEXT NOT NULL,
|
||||||
channel_account_id TEXT NOT NULL,
|
peer_id TEXT NOT NULL,
|
||||||
peer_id TEXT NOT NULL,
|
direction TEXT NOT NULL CHECK(direction IN ('inbound','outbound','internal')),
|
||||||
direction TEXT NOT NULL CHECK(direction IN ('inbound','outbound','internal')),
|
message_id TEXT NOT NULL,
|
||||||
message_id TEXT NOT NULL,
|
event_id TEXT NOT NULL,
|
||||||
event_id TEXT NOT NULL,
|
body TEXT NOT NULL,
|
||||||
body TEXT NOT NULL,
|
occurred_at TEXT NOT NULL,
|
||||||
occurred_at TEXT NOT NULL,
|
expires_at TEXT NOT NULL,
|
||||||
expires_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL,
|
PRIMARY KEY(
|
||||||
PRIMARY KEY(
|
tenant_id, cell_id, channel, channel_account_id,
|
||||||
tenant_id, cell_id, channel, channel_account_id,
|
peer_id, direction, message_id
|
||||||
peer_id, direction, message_id
|
),
|
||||||
),
|
UNIQUE(tenant_id, cell_id, event_id)
|
||||||
UNIQUE(tenant_id, cell_id, event_id)
|
);
|
||||||
);
|
CREATE INDEX IF NOT EXISTS idx_message_reply_lookup ON message_ledger(
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_reply_lookup ON message_ledger(
|
tenant_id, cell_id, channel, channel_account_id,
|
||||||
tenant_id, cell_id, channel, channel_account_id,
|
peer_id, message_id, expires_at
|
||||||
peer_id, message_id, expires_at
|
);
|
||||||
);
|
"""
|
||||||
"""
|
)
|
||||||
)
|
|
||||||
self._storage(initialize)
|
|
||||||
|
|
||||||
def append(self, message: CanonicalMessage) -> LedgerRecord:
|
def append(self, message: CanonicalMessage) -> LedgerRecord:
|
||||||
|
"""Append one exact message through the runtime write path.
|
||||||
|
|
||||||
|
This is intentionally the owner of validation, idempotency, and the
|
||||||
|
immediate SQLite transaction. Runtime callers must not depend on the
|
||||||
|
legacy ``remember`` compatibility name.
|
||||||
|
"""
|
||||||
message_id = normalize_message_id(message.message_id)
|
message_id = normalize_message_id(message.message_id)
|
||||||
now = utc_now()
|
now = utc_now()
|
||||||
expires = isoformat(now + timedelta(days=self.ttl_days))
|
expires = isoformat(now + timedelta(days=self.ttl_days))
|
||||||
key = (
|
key = (
|
||||||
message.tenant_id, message.cell_id, message.channel,
|
message.tenant_id,
|
||||||
message.channel_account_id, message.peer_id, message.direction, message_id,
|
message.cell_id,
|
||||||
|
message.channel,
|
||||||
|
message.channel_account_id,
|
||||||
|
message.peer_id,
|
||||||
|
message.direction,
|
||||||
|
message_id,
|
||||||
)
|
)
|
||||||
|
storage_error: ConflictError | None = None
|
||||||
def write() -> LedgerRecord:
|
try:
|
||||||
with closing(self._connect()) as connection, connection as db:
|
with closing(self._connect()) as connection, connection as db:
|
||||||
db.execute("BEGIN IMMEDIATE")
|
db.execute("BEGIN IMMEDIATE")
|
||||||
existing = db.execute(
|
existing = db.execute(
|
||||||
"SELECT event_id, body, occurred_at, expires_at FROM message_ledger "
|
"SELECT event_id, body, occurred_at, expires_at FROM message_ledger "
|
||||||
"WHERE tenant_id=? AND cell_id=? AND channel=? AND channel_account_id=? "
|
"WHERE tenant_id=? AND cell_id=? AND channel=? AND channel_account_id=? "
|
||||||
"AND peer_id=? AND direction=? AND message_id=?", key,
|
"AND peer_id=? AND direction=? AND message_id=?",
|
||||||
|
key,
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if existing:
|
if existing:
|
||||||
if existing["body"] != message.body:
|
if existing["body"] != message.body:
|
||||||
@@ -126,56 +133,50 @@ class MessageLedger:
|
|||||||
)
|
)
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT INTO message_ledger VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
"INSERT INTO message_ledger VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
(*key, message.event_id, message.body, message.occurred_at,
|
(*key, message.event_id, message.body, message.occurred_at, expires, isoformat(now)),
|
||||||
expires, isoformat(now)),
|
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return LedgerRecord(
|
except sqlite3.Error:
|
||||||
*key, message.event_id, message.body, message.occurred_at, expires
|
storage_error = ConflictError("ledger storage unavailable")
|
||||||
)
|
if storage_error is not None:
|
||||||
|
# Raise outside the sqlite exception handler so callers cannot inspect
|
||||||
return self._storage(write)
|
# a driver exception through __cause__ or __context__.
|
||||||
|
raise storage_error
|
||||||
|
return LedgerRecord(*key, message.event_id, message.body, message.occurred_at, expires)
|
||||||
|
|
||||||
def remember(self, message: CanonicalMessage) -> LedgerRecord:
|
def remember(self, message: CanonicalMessage) -> LedgerRecord:
|
||||||
"""Backward-compatible API wrapper outside the runtime write path."""
|
"""Backward-compatible API wrapper for callers outside the runtime path."""
|
||||||
return self.append(message)
|
return self.append(message)
|
||||||
|
|
||||||
def matches(self, message: CanonicalMessage) -> bool:
|
def matches(self, message: CanonicalMessage) -> bool:
|
||||||
|
"""Whether this exact message is already durably represented."""
|
||||||
message_id = normalize_message_id(message.message_id)
|
message_id = normalize_message_id(message.message_id)
|
||||||
|
with closing(self._connect()) as connection, connection as db:
|
||||||
def query() -> bool:
|
row = db.execute(
|
||||||
with closing(self._connect()) as connection, connection as db:
|
"SELECT event_id, body FROM message_ledger WHERE tenant_id=? AND "
|
||||||
row = db.execute(
|
"cell_id=? AND channel=? AND channel_account_id=? AND peer_id=? "
|
||||||
"SELECT event_id, body FROM message_ledger WHERE tenant_id=? AND "
|
"AND direction=? AND message_id=?",
|
||||||
"cell_id=? AND channel=? AND channel_account_id=? AND peer_id=? "
|
(
|
||||||
"AND direction=? AND message_id=?",
|
message.tenant_id, message.cell_id, message.channel,
|
||||||
(
|
message.channel_account_id, message.peer_id, message.direction,
|
||||||
message.tenant_id, message.cell_id, message.channel,
|
message_id,
|
||||||
message.channel_account_id, message.peer_id, message.direction,
|
),
|
||||||
message_id,
|
).fetchone()
|
||||||
),
|
return bool(row and row["event_id"] == message.event_id and row["body"] == message.body)
|
||||||
).fetchone()
|
|
||||||
return bool(
|
|
||||||
row and row["event_id"] == message.event_id
|
|
||||||
and row["body"] == message.body
|
|
||||||
)
|
|
||||||
|
|
||||||
return self._storage(query)
|
|
||||||
|
|
||||||
def remove(self, record: LedgerRecord) -> None:
|
def remove(self, record: LedgerRecord) -> None:
|
||||||
def delete() -> None:
|
"""Compensate a newly appended record if a later ingress step fails."""
|
||||||
with closing(self._connect()) as connection, connection as db:
|
with closing(self._connect()) as connection, connection as db:
|
||||||
db.execute(
|
db.execute(
|
||||||
"DELETE FROM message_ledger WHERE tenant_id=? AND cell_id=? AND "
|
"DELETE FROM message_ledger WHERE tenant_id=? AND cell_id=? AND "
|
||||||
"channel=? AND channel_account_id=? AND peer_id=? AND direction=? "
|
"channel=? AND channel_account_id=? AND peer_id=? AND direction=? "
|
||||||
"AND message_id=? AND event_id=?",
|
"AND message_id=? AND event_id=?",
|
||||||
(
|
(
|
||||||
record.tenant_id, record.cell_id, record.channel,
|
record.tenant_id, record.cell_id, record.channel,
|
||||||
record.channel_account_id, record.peer_id, record.direction,
|
record.channel_account_id, record.peer_id, record.direction,
|
||||||
record.message_id, record.event_id,
|
record.message_id, record.event_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
self._storage(delete)
|
|
||||||
|
|
||||||
def resolve_quote(self, message: CanonicalMessage) -> str:
|
def resolve_quote(self, message: CanonicalMessage) -> str:
|
||||||
if message.quoted_body is not None:
|
if message.quoted_body is not None:
|
||||||
@@ -183,22 +184,24 @@ class MessageLedger:
|
|||||||
if message.reply_to_message_id is None:
|
if message.reply_to_message_id is None:
|
||||||
raise ScopeError("quoted_text_unavailable")
|
raise ScopeError("quoted_text_unavailable")
|
||||||
reply_id = normalize_message_id(
|
reply_id = normalize_message_id(
|
||||||
message.reply_to_message_id, field_name="reply_to_message_id"
|
message.reply_to_message_id,
|
||||||
|
field_name="reply_to_message_id",
|
||||||
)
|
)
|
||||||
|
with closing(self._connect()) as connection, connection as db:
|
||||||
def query() -> list[sqlite3.Row]:
|
rows = db.execute(
|
||||||
with closing(self._connect()) as connection, connection as db:
|
"SELECT direction, body, event_id FROM message_ledger "
|
||||||
return db.execute(
|
"WHERE tenant_id=? AND cell_id=? AND channel=? AND channel_account_id=? "
|
||||||
"SELECT direction, body, event_id FROM message_ledger "
|
"AND peer_id=? AND message_id=? AND expires_at>?",
|
||||||
"WHERE tenant_id=? AND cell_id=? AND channel=? AND channel_account_id=? "
|
(
|
||||||
"AND peer_id=? AND message_id=? AND expires_at>?",
|
message.tenant_id,
|
||||||
(
|
message.cell_id,
|
||||||
message.tenant_id, message.cell_id, message.channel,
|
message.channel,
|
||||||
message.channel_account_id, message.peer_id, reply_id, isoformat(),
|
message.channel_account_id,
|
||||||
),
|
message.peer_id,
|
||||||
).fetchall()
|
reply_id,
|
||||||
|
isoformat(),
|
||||||
rows = self._storage(query)
|
),
|
||||||
|
).fetchall()
|
||||||
if len(rows) == 1:
|
if len(rows) == 1:
|
||||||
return str(rows[0]["body"])
|
return str(rows[0]["body"])
|
||||||
if len(rows) > 1:
|
if len(rows) > 1:
|
||||||
@@ -206,22 +209,16 @@ class MessageLedger:
|
|||||||
raise ScopeError("quoted_text_unavailable")
|
raise ScopeError("quoted_text_unavailable")
|
||||||
|
|
||||||
def purge_expired(self) -> int:
|
def purge_expired(self) -> int:
|
||||||
def purge() -> int:
|
with closing(self._connect()) as connection, connection as db:
|
||||||
with closing(self._connect()) as connection, connection as db:
|
cursor = db.execute(
|
||||||
cursor = db.execute(
|
"DELETE FROM message_ledger WHERE expires_at<=?", (isoformat(),)
|
||||||
"DELETE FROM message_ledger WHERE expires_at<=?", (isoformat(),)
|
)
|
||||||
)
|
return cursor.rowcount
|
||||||
return cursor.rowcount
|
|
||||||
return self._storage(purge)
|
|
||||||
|
|
||||||
def integrity_check(self) -> str:
|
def integrity_check(self) -> str:
|
||||||
def check() -> str:
|
with closing(self._connect()) as connection, connection as db:
|
||||||
with closing(self._connect()) as connection, connection as db:
|
return str(db.execute("PRAGMA integrity_check").fetchone()[0])
|
||||||
return str(db.execute("PRAGMA integrity_check").fetchone()[0])
|
|
||||||
return self._storage(check)
|
|
||||||
|
|
||||||
def count(self) -> int:
|
def count(self) -> int:
|
||||||
def query() -> int:
|
with closing(self._connect()) as connection, connection as db:
|
||||||
with closing(self._connect()) as connection, connection as db:
|
return int(db.execute("SELECT count(*) FROM message_ledger").fetchone()[0])
|
||||||
return int(db.execute("SELECT count(*) FROM message_ledger").fetchone()[0])
|
|
||||||
return self._storage(query)
|
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
"""Tenant and Cell scoped, value-free secret reference model."""
|
"""Tenant and Cell scoped secret reference model.
|
||||||
|
|
||||||
|
This module never stores secret values. It stores ownership and retrieval scope
|
||||||
|
metadata for integration with a real secret broker.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Literal
|
from typing import Literal
|
||||||
|
|
||||||
from .tenant_context import ScopeError, ValidationError, require_ident
|
from .tenant_context import ValidationError, require_ident
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -18,44 +22,12 @@ class SecretRef:
|
|||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
for name in ("secret_id", "cell_id", "version"):
|
for name in ("secret_id", "cell_id", "version"):
|
||||||
value = getattr(self, name)
|
require_ident(name, getattr(self, name))
|
||||||
if require_ident(name, value) != value:
|
require_ident("tenant_id", self.tenant_id, tenant=True)
|
||||||
raise ValidationError(f"{name} must be canonical")
|
|
||||||
if require_ident("tenant_id", self.tenant_id, tenant=True) != self.tenant_id:
|
|
||||||
raise ValidationError("tenant_id must be canonical")
|
|
||||||
if self.kind not in {"channel", "model", "tool", "runtime"}:
|
if self.kind not in {"channel", "model", "tool", "runtime"}:
|
||||||
raise ValidationError("invalid secret kind")
|
raise ValidationError("invalid secret kind")
|
||||||
self._validate_broker_path()
|
if not self.broker_path.startswith("secret://"):
|
||||||
|
|
||||||
def _validate_broker_path(self) -> None:
|
|
||||||
if not isinstance(self.broker_path, str) or not self.broker_path.startswith("secret://"):
|
|
||||||
raise ValidationError("secret broker path required")
|
raise ValidationError("secret broker path required")
|
||||||
suffix = self.broker_path[len("secret://"):]
|
|
||||||
if (
|
|
||||||
not suffix or "?" in suffix or "#" in suffix or chr(92) in suffix
|
|
||||||
or any(part in {"", ".", ".."} for part in suffix.split("/"))
|
|
||||||
):
|
|
||||||
raise ValidationError("invalid secret broker path")
|
|
||||||
for part in suffix.split("/"):
|
|
||||||
if require_ident("secret broker path segment", part) != part:
|
|
||||||
raise ValidationError("invalid secret broker path")
|
|
||||||
|
|
||||||
def can_be_used_by(self, *, tenant_id: str, cell_id: str) -> bool:
|
def can_be_used_by(self, *, tenant_id: str, cell_id: str) -> bool:
|
||||||
return self.tenant_id == tenant_id and self.cell_id == cell_id
|
return self.tenant_id == tenant_id and self.cell_id == cell_id
|
||||||
|
|
||||||
def resolve(self, broker: Any) -> str:
|
|
||||||
"""Resolve through one explicit broker boundary without leaking provider errors."""
|
|
||||||
failure: ScopeError | None = None
|
|
||||||
value: object = None
|
|
||||||
try:
|
|
||||||
reader = getattr(broker, "read_secret")
|
|
||||||
if not callable(reader):
|
|
||||||
raise TypeError("invalid broker")
|
|
||||||
value = reader(self)
|
|
||||||
if not isinstance(value, str) or not value:
|
|
||||||
raise ValueError("invalid broker result")
|
|
||||||
except Exception:
|
|
||||||
failure = ScopeError("secret broker unavailable")
|
|
||||||
if failure is not None:
|
|
||||||
raise failure
|
|
||||||
return value
|
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
# Personal Weixin v1 security contract
|
|
||||||
|
|
||||||
Status: normative design contract for R10 B6/B7. It authorizes no runtime or production change.
|
|
||||||
|
|
||||||
## Trust boundaries and ownership
|
|
||||||
|
|
||||||
The browser is untrusted. The Portal authenticates the human user and resolves tenant identity from the server session. The Portal adapter is least-privileged: it may request account-scoped operations but must not store provider credentials, tokens, raw iLink state, or OpenClaw protocol state. OpenClaw owns provider credentials and protocol state. The Gateway transport authenticator, not an event-body boolean or identity field, establishes whether an inbound event is authentic.
|
|
||||||
|
|
||||||
Every operation is keyed by server-resolved `(tenantId, accountId)`. Entitlements are allow-only, server-owned records. A default account is display preference, never authorization. The browser-visible `accountId` is an untrusted reference to a server-preallocated account slot: the server MUST resolve it through the authenticated user's tenant and current entitlement on list, readiness, login start, login wait, status, inbound challenge, canary, and rollback checks. Start and every wait poll re-run that resolution. Missing, arbitrary, stale, cross-tenant, or no-longer-entitled values fail closed before account existence or state is disclosed; there is no default/first/configured-account fallback.
|
|
||||||
|
|
||||||
## Login state and secret handling
|
|
||||||
|
|
||||||
`sessionKey` is a required, nonempty opaque value returned by OpenClaw. The adapter MUST forward its exact bytes unchanged. It MUST NOT trim, normalize, case-fold, derive, regenerate, alias, or substitute the value. A missing or empty `sessionKey` fails closed and MUST NOT fall back to `accountId` or any identifier. Durable state stores only a one-way session-key digest where correlation is required; raw `sessionKey` is not persisted.
|
|
||||||
|
|
||||||
`verifyCode` is accepted only as an authenticated, CSRF-protected wait POST test/input field, matches 4-8 decimal digits, is forwarded once, and is discarded. `challengeCode` is accepted only as an authenticated Gateway event test/input field, is compared through a one-way digest, and is discarded. Neither secret may be persisted in raw form or appear in URLs, responses, logs, traces, metrics labels, analytics, audit payloads, exceptions, or background-job arguments. Golden fixtures containing these values are synthetic test vectors only and MUST NOT be copied into production telemetry or responses.
|
|
||||||
|
|
||||||
QR data URLs are transient no-store response data with a 1 MiB bound and MUST NOT be logged or persisted. Canonical login outcomes are `qr_pending`, `need_verifycode`, `invalid_verifycode`, `expired`, `failed`, `already_connected`, and `confirmed`. Any unknown upstream status maps fail-closed to canonical `failed` with `errorCode=unknown_upstream_status`; raw unknown status is bounded evidence only. Upstream `binded_redirect` maps only to `already_connected` with `connected=false`, `alreadyConnected=true`, and `terminal=true`, never to `confirmed`.
|
|
||||||
|
|
||||||
## Canonical readiness
|
|
||||||
|
|
||||||
Readiness is one live, authenticated snapshot from the Gateway/OpenClaw boundary with one unified response shape for `ready`, `not_ready`, and `error`. It MUST include Gateway reachability/error, exact plugin id/version/digest, selected account identity, configured/enabled/running/last-error state, current tenant/account entitlement, reasons, observation time, and freshness deadline. `ready=true` is legal only when every required field is present, the snapshot is still fresh, expected plugin version/digest match, the selected account is entitled/configured/enabled/running with null error, and the Gateway is reachable. Missing fields, an old snapshot, digest mismatch, no entitlement, not-running state, unauthenticated evidence, or any contradiction returns `ready=false` with `code=not_ready` or `code=error` and at least one stable reason. Cached, synthetic, file-only, default-account, or service-up-only evidence cannot produce ready.
|
|
||||||
|
|
||||||
## Authenticated inbound-event challenge
|
|
||||||
|
|
||||||
The event body contains exactly `eventId`, `accountId`, `senderId`, `chatId`, `challengeCode`, and `observedAt`. It does not and cannot assert authentication, tenant id, Portal user id, or session key. Those values are absent by contract. Transport authentication MUST cover HTTP method, path, timestamp header, nonce header, and raw body digest; enforce a bounded clock window; durably reserve nonce/event replay keys for at least that window; compare signatures in constant time; and complete before body values reach logs or mutable business state.
|
|
||||||
|
|
||||||
After transport authentication, the server hashes the ephemeral `challengeCode` and resolves the pending durable challenge. That server-created record already owns tenant id, Portal user id, entitled account id, session-key digest, issued/expiry/consumed times, and state (`issued`, `consumed`, or `expired`). The body `accountId` must exactly match the record and current entitlement; it never supplies tenant/user/session authority. The first valid authenticated event atomically binds its provider `senderId` and `chatId`.
|
|
||||||
|
|
||||||
Issuance and consumption MUST run in a database transaction with row locking or an equivalent compare-and-swap primitive. `issued -> consumed` is single-use and atomic. An exact replay of the same authenticated event is idempotent. A changed event payload, sender, chat, account, durable tenant/user/account/session binding, expired or already-consumed record, duplicate event with different payload, missing entitlement, or unauthenticated transport fails closed with zero mutation. Raw challenge codes and raw session keys are never stored.
|
|
||||||
|
|
||||||
## Unsupported unbind
|
|
||||||
|
|
||||||
End-to-end unbind is unsupported in R10. Every public or internal unbind entry point MUST return the exact schema response with HTTP 501, `ok=false`, `supported=false`, `code=unbind_not_supported`, and `mutationCount=0`. The 501 decision occurs before token deletion, account update, session or challenge mutation, Gateway/OpenClaw call, business-state audit mutation, or background-job enqueue.
|
|
||||||
|
|
||||||
## Web, canary, and rollback controls
|
|
||||||
|
|
||||||
Only exact B6/B7 allowlisted paths in the R10 amendment may change. Portal endpoints require authenticated Django sessions, CSRF protection for mutations, server-scoped object lookup, IDOR tests, rate limits, request-size limits, `Cache-Control: no-store`, and structured redaction of session keys, verification codes, challenge codes, QR data, tokens, and provider payloads. Stable public errors never expose upstream bodies or cross-tenant existence.
|
|
||||||
|
|
||||||
The canary uses one explicitly entitled tenant/account pair and proves restart, concurrent waits, atomic challenge consumption, exact replay, altered replay denial, cross-tenant denial, all canonical statuses/readiness shapes, and zero-mutation 501 behavior. Promotion requires byte-identical artifacts and digests from the verified canary. Rollback restores the immediately preceding byte-identical artifacts/configuration and re-runs canonical readiness; database rollback is forbidden unless an explicitly approved manifest proves backward incompatibility and supplies a separate recovery plan.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"tenant.account.entitlement","tenantId":"tenant-canary","accountId":"account-canary","effect":"allow","policyVersion":"policy-v1","issuedAtMs":1784068000000}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"personal.weixin.readiness","tenantId":"tenant-canary","selectedAccountId":"account-canary","gateway":{"reachable":true,"errorCode":null},"plugin":{"id":"openclaw-weixin","loaded":true,"version":"2.4.6","digestSha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"accounts":[{"accountId":"account-canary","name":"Canary Personal Weixin","configured":true,"enabled":true,"running":true,"lastError":null,"isDefault":false,"entitled":true}],"ready":true,"code":"ready","reasons":[],"observedAtMs":1784068060000,"freshUntilMs":1784068120000}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"personal.weixin.readiness","tenantId":"tenant-canary","selectedAccountId":"account-canary","gateway":{"reachable":true,"errorCode":null},"plugin":{"id":"openclaw-weixin","loaded":true,"version":"2.4.6","digestSha256":null},"accounts":[{"accountId":"account-canary","name":"Canary Personal Weixin","configured":true,"enabled":true,"running":true,"lastError":null,"isDefault":false,"entitled":true}],"ready":false,"code":"not_ready","reasons":[{"code":"field_missing","field":"plugin.digestSha256"}],"observedAtMs":1784068060000,"freshUntilMs":1784068120000}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"personal.weixin.readiness","tenantId":"tenant-canary","selectedAccountId":"account-canary","gateway":{"reachable":true,"errorCode":null},"plugin":{"id":"openclaw-weixin","loaded":true,"version":"2.4.6","digestSha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"accounts":[{"accountId":"account-canary","name":"Canary Personal Weixin","configured":true,"enabled":true,"running":true,"lastError":null,"isDefault":false,"entitled":true}],"ready":false,"code":"not_ready","reasons":[{"code":"snapshot_stale","field":"freshUntilMs"}],"observedAtMs":1784060000000,"freshUntilMs":1784060060000}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"personal.weixin.readiness","tenantId":"tenant-canary","selectedAccountId":"account-canary","gateway":{"reachable":true,"errorCode":null},"plugin":{"id":"openclaw-weixin","loaded":true,"version":"2.4.6","digestSha256":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"accounts":[{"accountId":"account-canary","name":"Canary Personal Weixin","configured":true,"enabled":true,"running":true,"lastError":null,"isDefault":false,"entitled":true}],"ready":false,"code":"not_ready","reasons":[{"code":"plugin_digest_mismatch","field":"plugin.digestSha256"}],"observedAtMs":1784068060000,"freshUntilMs":1784068120000}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"personal.weixin.readiness","tenantId":"tenant-canary","selectedAccountId":"account-canary","gateway":{"reachable":true,"errorCode":null},"plugin":{"id":"openclaw-weixin","loaded":true,"version":"2.4.6","digestSha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"accounts":[{"accountId":"account-canary","name":"Canary Personal Weixin","configured":true,"enabled":true,"running":true,"lastError":null,"isDefault":false,"entitled":false}],"ready":false,"code":"not_ready","reasons":[{"code":"account_not_entitled","field":"accounts[0].entitled"}],"observedAtMs":1784068060000,"freshUntilMs":1784068120000}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"personal.weixin.readiness","tenantId":"tenant-canary","selectedAccountId":"account-canary","gateway":{"reachable":true,"errorCode":null},"plugin":{"id":"openclaw-weixin","loaded":true,"version":"2.4.6","digestSha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"accounts":[{"accountId":"account-canary","name":"Canary Personal Weixin","configured":true,"enabled":true,"running":false,"lastError":"plugin_not_running","isDefault":false,"entitled":true}],"ready":false,"code":"not_ready","reasons":[{"code":"account_not_running","field":"accounts[0].running"}],"observedAtMs":1784068060000,"freshUntilMs":1784068120000}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"personal.weixin.readiness","tenantId":"tenant-canary","selectedAccountId":"account-canary","gateway":{"reachable":false,"errorCode":"gateway_unreachable"},"plugin":{"id":"openclaw-weixin","loaded":false,"version":null,"digestSha256":null},"accounts":[{"accountId":"account-canary","name":"Canary Personal Weixin","configured":true,"enabled":true,"running":false,"lastError":"gateway_unreachable","isDefault":false,"entitled":true}],"ready":false,"code":"error","reasons":[{"code":"gateway_error","field":"gateway.reachable"}],"observedAtMs":1784068060000,"freshUntilMs":1784068060000}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.start.request","accountId":"account-canary","force":false,"timeoutMs":30000}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.wait.request","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","verifyCode":"4321","currentQrDataUrl":"data:image/png;base64,iVBORw0KGgo=","timeoutMs":30000}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.result","status":"qr_pending","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","connected":false,"alreadyConnected":false,"terminal":false,"needsVerifyCode":false,"qrDataUrl":"data:image/png;base64,iVBORw0KGgo="}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.result","status":"need_verifycode","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","connected":false,"alreadyConnected":false,"terminal":false,"needsVerifyCode":true}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.result","status":"invalid_verifycode","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","connected":false,"alreadyConnected":false,"terminal":false,"needsVerifyCode":true,"errorCode":"invalid_verifycode"}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.result","status":"expired","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","connected":false,"alreadyConnected":false,"terminal":true,"needsVerifyCode":false,"errorCode":"login_expired"}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.result","status":"failed","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","connected":false,"alreadyConnected":false,"terminal":true,"needsVerifyCode":false,"errorCode":"upstream_failed"}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.result","status":"already_connected","upstreamStatus":"binded_redirect","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","connected":false,"alreadyConnected":true,"terminal":true,"needsVerifyCode":false}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.result","status":"confirmed","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","connected":true,"alreadyConnected":false,"terminal":true,"needsVerifyCode":false,"userId":"sender-canary"}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.result","status":"failed","upstreamStatus":"future_unknown_state","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","connected":false,"alreadyConnected":false,"terminal":true,"needsVerifyCode":false,"errorCode":"unknown_upstream_status"}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"eventId":"event-20260715-A1","accountId":"account-canary","senderId":"sender-canary","chatId":"chat-canary","challengeCode":"A1B2C3D4","observedAt":1784068120000}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.unbind.result","httpStatus":501,"ok":false,"supported":false,"code":"unbind_not_supported","mutationCount":0}
|
|
||||||
@@ -1,986 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"$id": "https://aios.imruyi.top/contracts/personal-weixin/v1/golden-contract.schema.json",
|
|
||||||
"title": "AIOS Personal Weixin Golden Contract v1",
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/$defs/entitlement"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"$ref": "#/$defs/readiness"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"$ref": "#/$defs/startRequest"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"$ref": "#/$defs/waitRequest"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"$ref": "#/$defs/loginResult"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"$ref": "#/$defs/inboundEvent"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"$ref": "#/$defs/unbindResult"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"$defs": {
|
|
||||||
"identifier": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"maxLength": 128,
|
|
||||||
"pattern": "^[A-Za-z0-9._~:@-]+$"
|
|
||||||
},
|
|
||||||
"sessionKey": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"maxLength": 256,
|
|
||||||
"pattern": "^[A-Za-z0-9._~:-]+$"
|
|
||||||
},
|
|
||||||
"schemaVersion": {
|
|
||||||
"const": "aios.personal-weixin.golden.v1"
|
|
||||||
},
|
|
||||||
"entitlement": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"schemaVersion",
|
|
||||||
"kind",
|
|
||||||
"tenantId",
|
|
||||||
"accountId",
|
|
||||||
"effect",
|
|
||||||
"policyVersion",
|
|
||||||
"issuedAtMs"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"schemaVersion": {
|
|
||||||
"$ref": "#/$defs/schemaVersion"
|
|
||||||
},
|
|
||||||
"kind": {
|
|
||||||
"const": "tenant.account.entitlement"
|
|
||||||
},
|
|
||||||
"tenantId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"accountId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"effect": {
|
|
||||||
"const": "allow"
|
|
||||||
},
|
|
||||||
"policyVersion": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"issuedAtMs": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"readinessReason": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"code",
|
|
||||||
"field"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"code": {
|
|
||||||
"enum": [
|
|
||||||
"field_missing",
|
|
||||||
"snapshot_stale",
|
|
||||||
"plugin_digest_mismatch",
|
|
||||||
"account_not_entitled",
|
|
||||||
"account_not_running",
|
|
||||||
"gateway_error"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"field": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"maxLength": 128
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"readiness": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"schemaVersion",
|
|
||||||
"kind",
|
|
||||||
"tenantId",
|
|
||||||
"selectedAccountId",
|
|
||||||
"gateway",
|
|
||||||
"plugin",
|
|
||||||
"accounts",
|
|
||||||
"ready",
|
|
||||||
"code",
|
|
||||||
"reasons",
|
|
||||||
"observedAtMs",
|
|
||||||
"freshUntilMs"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"schemaVersion": {
|
|
||||||
"$ref": "#/$defs/schemaVersion"
|
|
||||||
},
|
|
||||||
"kind": {
|
|
||||||
"const": "personal.weixin.readiness"
|
|
||||||
},
|
|
||||||
"tenantId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"selectedAccountId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"gateway": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"reachable",
|
|
||||||
"errorCode"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"reachable": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"errorCode": {
|
|
||||||
"type": [
|
|
||||||
"string",
|
|
||||||
"null"
|
|
||||||
],
|
|
||||||
"minLength": 1,
|
|
||||||
"maxLength": 128
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"plugin": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"id",
|
|
||||||
"loaded",
|
|
||||||
"version",
|
|
||||||
"digestSha256"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"const": "openclaw-weixin"
|
|
||||||
},
|
|
||||||
"loaded": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"version": {
|
|
||||||
"type": [
|
|
||||||
"string",
|
|
||||||
"null"
|
|
||||||
],
|
|
||||||
"minLength": 1,
|
|
||||||
"maxLength": 64
|
|
||||||
},
|
|
||||||
"digestSha256": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^[a-f0-9]{64}$"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"accounts": {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 100,
|
|
||||||
"items": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"accountId",
|
|
||||||
"name",
|
|
||||||
"configured",
|
|
||||||
"enabled",
|
|
||||||
"running",
|
|
||||||
"lastError",
|
|
||||||
"isDefault",
|
|
||||||
"entitled"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"accountId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"maxLength": 128
|
|
||||||
},
|
|
||||||
"configured": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"enabled": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"running": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"lastError": {
|
|
||||||
"type": [
|
|
||||||
"string",
|
|
||||||
"null"
|
|
||||||
],
|
|
||||||
"minLength": 1,
|
|
||||||
"maxLength": 256
|
|
||||||
},
|
|
||||||
"isDefault": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"entitled": {
|
|
||||||
"type": "boolean"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ready": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"code": {
|
|
||||||
"enum": [
|
|
||||||
"ready",
|
|
||||||
"not_ready",
|
|
||||||
"error"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"reasons": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 32,
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/$defs/readinessReason"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"observedAtMs": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 0
|
|
||||||
},
|
|
||||||
"freshUntilMs": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"ready": {
|
|
||||||
"const": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"ready"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"properties": {
|
|
||||||
"code": {
|
|
||||||
"const": "ready"
|
|
||||||
},
|
|
||||||
"reasons": {
|
|
||||||
"maxItems": 0
|
|
||||||
},
|
|
||||||
"gateway": {
|
|
||||||
"properties": {
|
|
||||||
"reachable": {
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"errorCode": {
|
|
||||||
"const": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"plugin": {
|
|
||||||
"properties": {
|
|
||||||
"loaded": {
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"version": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"digestSha256": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^[a-f0-9]{64}$"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"accounts": {
|
|
||||||
"items": {
|
|
||||||
"properties": {
|
|
||||||
"configured": {
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"enabled": {
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"running": {
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"lastError": {
|
|
||||||
"const": null
|
|
||||||
},
|
|
||||||
"entitled": {
|
|
||||||
"const": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"else": {
|
|
||||||
"properties": {
|
|
||||||
"code": {
|
|
||||||
"enum": [
|
|
||||||
"not_ready",
|
|
||||||
"error"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"reasons": {
|
|
||||||
"minItems": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"startRequest": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"schemaVersion",
|
|
||||||
"kind",
|
|
||||||
"accountId",
|
|
||||||
"force",
|
|
||||||
"timeoutMs"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"schemaVersion": {
|
|
||||||
"$ref": "#/$defs/schemaVersion"
|
|
||||||
},
|
|
||||||
"kind": {
|
|
||||||
"const": "web.login.start.request"
|
|
||||||
},
|
|
||||||
"accountId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"force": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"timeoutMs": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1000,
|
|
||||||
"maximum": 120000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"waitRequest": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"schemaVersion",
|
|
||||||
"kind",
|
|
||||||
"accountId",
|
|
||||||
"sessionKey",
|
|
||||||
"timeoutMs"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"schemaVersion": {
|
|
||||||
"$ref": "#/$defs/schemaVersion"
|
|
||||||
},
|
|
||||||
"kind": {
|
|
||||||
"const": "web.login.wait.request"
|
|
||||||
},
|
|
||||||
"accountId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"sessionKey": {
|
|
||||||
"$ref": "#/$defs/sessionKey"
|
|
||||||
},
|
|
||||||
"verifyCode": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^[0-9]{4,8}$"
|
|
||||||
},
|
|
||||||
"currentQrDataUrl": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"maxLength": 1048576,
|
|
||||||
"pattern": "^data:image/png;base64,"
|
|
||||||
},
|
|
||||||
"timeoutMs": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1000,
|
|
||||||
"maximum": 120000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"loginResult": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"schemaVersion",
|
|
||||||
"kind",
|
|
||||||
"status",
|
|
||||||
"accountId",
|
|
||||||
"sessionKey",
|
|
||||||
"connected",
|
|
||||||
"alreadyConnected",
|
|
||||||
"terminal",
|
|
||||||
"needsVerifyCode"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"schemaVersion": {
|
|
||||||
"$ref": "#/$defs/schemaVersion"
|
|
||||||
},
|
|
||||||
"kind": {
|
|
||||||
"const": "web.login.result"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"enum": [
|
|
||||||
"qr_pending",
|
|
||||||
"need_verifycode",
|
|
||||||
"invalid_verifycode",
|
|
||||||
"expired",
|
|
||||||
"failed",
|
|
||||||
"already_connected",
|
|
||||||
"confirmed"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"accountId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"sessionKey": {
|
|
||||||
"$ref": "#/$defs/sessionKey"
|
|
||||||
},
|
|
||||||
"connected": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"alreadyConnected": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"terminal": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"needsVerifyCode": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"qrDataUrl": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"maxLength": 1048576,
|
|
||||||
"pattern": "^data:image/png;base64,"
|
|
||||||
},
|
|
||||||
"userId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"errorCode": {
|
|
||||||
"enum": [
|
|
||||||
"invalid_verifycode",
|
|
||||||
"login_expired",
|
|
||||||
"upstream_failed",
|
|
||||||
"unknown_upstream_status"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"upstreamStatus": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"maxLength": 128
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"status": {
|
|
||||||
"const": "qr_pending"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"required": [
|
|
||||||
"qrDataUrl"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"connected": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"alreadyConnected": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"terminal": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"needsVerifyCode": {
|
|
||||||
"const": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"not": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"userId"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"errorCode"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"upstreamStatus"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"status": {
|
|
||||||
"const": "need_verifycode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"properties": {
|
|
||||||
"connected": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"alreadyConnected": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"terminal": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"needsVerifyCode": {
|
|
||||||
"const": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"not": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"qrDataUrl"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"userId"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"errorCode"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"upstreamStatus"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"status": {
|
|
||||||
"const": "invalid_verifycode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"required": [
|
|
||||||
"errorCode"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"errorCode": {
|
|
||||||
"const": "invalid_verifycode"
|
|
||||||
},
|
|
||||||
"connected": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"alreadyConnected": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"terminal": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"needsVerifyCode": {
|
|
||||||
"const": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"not": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"qrDataUrl"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"userId"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"upstreamStatus"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"status": {
|
|
||||||
"const": "expired"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"required": [
|
|
||||||
"errorCode"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"errorCode": {
|
|
||||||
"const": "login_expired"
|
|
||||||
},
|
|
||||||
"connected": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"alreadyConnected": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"terminal": {
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"needsVerifyCode": {
|
|
||||||
"const": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"not": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"qrDataUrl"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"userId"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"upstreamStatus"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"status": {
|
|
||||||
"const": "failed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"required": [
|
|
||||||
"errorCode"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"connected": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"alreadyConnected": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"terminal": {
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"needsVerifyCode": {
|
|
||||||
"const": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"not": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"qrDataUrl"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"userId"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"required": [
|
|
||||||
"upstreamStatus"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"status": {
|
|
||||||
"const": "failed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"properties": {
|
|
||||||
"errorCode": {
|
|
||||||
"const": "unknown_upstream_status"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"not": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"qrDataUrl"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"userId"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"required": [
|
|
||||||
"status",
|
|
||||||
"errorCode"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"status": {
|
|
||||||
"const": "failed"
|
|
||||||
},
|
|
||||||
"errorCode": {
|
|
||||||
"const": "unknown_upstream_status"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"required": [
|
|
||||||
"upstreamStatus"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"required": [
|
|
||||||
"status",
|
|
||||||
"errorCode"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"status": {
|
|
||||||
"const": "failed"
|
|
||||||
},
|
|
||||||
"errorCode": {
|
|
||||||
"const": "upstream_failed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"not": {
|
|
||||||
"required": [
|
|
||||||
"upstreamStatus"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"status": {
|
|
||||||
"const": "already_connected"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"required": [
|
|
||||||
"upstreamStatus"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"upstreamStatus": {
|
|
||||||
"const": "binded_redirect"
|
|
||||||
},
|
|
||||||
"connected": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"alreadyConnected": {
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"terminal": {
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"needsVerifyCode": {
|
|
||||||
"const": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"not": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"qrDataUrl"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"userId"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"errorCode"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"status": {
|
|
||||||
"const": "confirmed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"required": [
|
|
||||||
"userId"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"connected": {
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"alreadyConnected": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"terminal": {
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"needsVerifyCode": {
|
|
||||||
"const": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"not": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"qrDataUrl"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"errorCode"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"upstreamStatus"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"inboundEvent": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"eventId",
|
|
||||||
"accountId",
|
|
||||||
"senderId",
|
|
||||||
"chatId",
|
|
||||||
"challengeCode",
|
|
||||||
"observedAt"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"eventId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"accountId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"senderId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"chatId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"challengeCode": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 6,
|
|
||||||
"maxLength": 64,
|
|
||||||
"pattern": "^[A-Z0-9]+$"
|
|
||||||
},
|
|
||||||
"observedAt": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"unbindResult": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"schemaVersion",
|
|
||||||
"kind",
|
|
||||||
"httpStatus",
|
|
||||||
"ok",
|
|
||||||
"supported",
|
|
||||||
"code",
|
|
||||||
"mutationCount"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"schemaVersion": {
|
|
||||||
"$ref": "#/$defs/schemaVersion"
|
|
||||||
},
|
|
||||||
"kind": {
|
|
||||||
"const": "web.unbind.result"
|
|
||||||
},
|
|
||||||
"httpStatus": {
|
|
||||||
"const": 501
|
|
||||||
},
|
|
||||||
"ok": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"supported": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"code": {
|
|
||||||
"const": "unbind_not_supported"
|
|
||||||
},
|
|
||||||
"mutationCount": {
|
|
||||||
"const": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -18,14 +18,12 @@
|
|||||||
- All OpenClaw loader work uses an isolated temporary or candidate config/state directory.
|
- All OpenClaw loader work uses an isolated temporary or candidate config/state directory.
|
||||||
- `ACCEPTED` is terminal. Legacy handling is reachable only when bridge mode is explicitly `disabled`.
|
- `ACCEPTED` is terminal. Legacy handling is reachable only when bridge mode is explicitly `disabled`.
|
||||||
- Never claim exactly-once Hermes delivery or use time/approximation as message authority.
|
- Never claim exactly-once Hermes delivery or use time/approximation as message authority.
|
||||||
- D0 is a non-blocker documentation correction. Its reviewed commit is the R9 `START`; it does not consume a blocker label or relax any B3/B4/B5 evidence gate.
|
|
||||||
- B2 is both the canonical-package blocker and the executable completeness gate for Ledger, Memory, Secret, Audit, Hermes Profile, Cell Manager/CLI, and Fabric. Existing correct behavior is recorded as characterization; a production edit requires an observed contract RED first.
|
|
||||||
|
|
||||||
After docs review, initialize the stable per-worktree ledger from the then-current tip rather than a hardcoded SHA:
|
After docs review, initialize the stable per-worktree ledger from the then-current tip rather than a hardcoded SHA:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
test -z "$(git status --porcelain)"
|
test -z "$(git status --porcelain)"
|
||||||
test "$(git branch --show-current)" = "integration/v2r1-runtime-20260715-r9"
|
test "$(git branch --show-current)" = "integration/v2r1-runtime-20260714"
|
||||||
start_sha="$(git rev-parse HEAD)"
|
start_sha="$(git rev-parse HEAD)"
|
||||||
start_parent="$(git rev-parse HEAD^)"
|
start_parent="$(git rev-parse HEAD^)"
|
||||||
start_tree="$(git rev-parse HEAD^{tree})"
|
start_tree="$(git rev-parse HEAD^{tree})"
|
||||||
@@ -172,24 +170,8 @@ test "$tree" = "$(git rev-parse HEAD^{tree})"
|
|||||||
- Create: `tests/v2r1/test_ingress_service.py`
|
- Create: `tests/v2r1/test_ingress_service.py`
|
||||||
- Create: `tests/test_v2r1_package_identity.py`
|
- Create: `tests/test_v2r1_package_identity.py`
|
||||||
- Modify: the exact 23 Appendix A test files, and no unlisted legacy import file
|
- Modify: the exact 23 Appendix A test files, and no unlisted legacy import file
|
||||||
- Modify only after an observed matrix RED: `active-runtime/app/aios_v2r1/message_ledger.py`
|
|
||||||
- Modify only after an observed matrix RED: `active-runtime/app/aios_v2r1/memory_scope.py`
|
|
||||||
- Modify only after an observed matrix RED: `active-runtime/app/aios_v2r1/secret_scope.py`
|
|
||||||
- Modify only after an observed matrix RED: `active-runtime/app/aios_v2r1/audit.py`
|
|
||||||
- Modify only after an observed matrix RED: `active-runtime/app/aios_v2r1/hermes_profile.py`
|
|
||||||
- Modify only after an observed matrix RED: `active-runtime/app/aios_v2r1/cell_manager.py`
|
|
||||||
- Modify only after an observed matrix RED: `active-runtime/app/aios_v2r1/hermes_fabric.py`
|
|
||||||
- Expand: `tests/v2r1/test_message_ledger_append.py`
|
|
||||||
- Create: `tests/v2r1/test_memory_scope.py`
|
|
||||||
- Create: `tests/v2r1/test_secret_scope.py`
|
|
||||||
- Create: `tests/v2r1/test_audit.py`
|
|
||||||
- Create: `tests/v2r1/test_hermes_profile.py`
|
|
||||||
- Create: `tests/v2r1/test_cell_manager.py`
|
|
||||||
- Expand: `tests/v2r1/test_hermes_fabric.py`
|
|
||||||
|
|
||||||
**Exact contract:** V2R1 imports only as `aios_v2r1`; no `active_runtime` shim/alias/namespace extension/copied tree. Wheel includes `aios_v2r1`, excludes `active_runtime` and legacy `app`. Legacy repository tests change `active_runtime.app.*` to `app.*` and run with `PYTHONPATH='active-runtime:active-runtime/app'`. Mutation routes return bounded `SERVICE_NOT_CONFIGURED` until B3/B4 processors exist. Existing cell CLI stays compatible and `serve` is separate. B2 changes exactly 43 paths: the original 29 package/import paths plus the seven named runtime modules and seven named matrix-test paths above.
|
**Exact contract:** V2R1 imports only as `aios_v2r1`; no `active_runtime` shim/alias/namespace extension/copied tree. Wheel includes `aios_v2r1`, excludes `active_runtime` and legacy `app`. Legacy repository tests change `active_runtime.app.*` to `app.*` and run with `PYTHONPATH='active-runtime:active-runtime/app'`. Mutation routes return bounded `SERVICE_NOT_CONFIGURED` until B3/B4 processors exist. Existing cell CLI stays compatible and `serve` is separate.
|
||||||
|
|
||||||
**Executable completeness matrix:** Message Ledger proves exact-ID query with no fallback, idempotency, collision conflict, tenant/cell/direction isolation, expiry/purge, restart, concurrency, and sanitized SQLite failures. Memory proves every namespace, cross-scope denial, shared-artifact read-only access, qualified keys, traversal rejection, and disabled search. Secret proves reference/path/scope/kind/version/identifier validation, no value leakage, and fail-closed broker boundaries. Audit proves genesis, tamper/delete/reorder detection, Unicode canonicalization, concurrency, restart, validation, tenant isolation, and sanitized SQLite failures. Hermes Profile proves exact environment, absolute roots, `0700` modes, traversal/symlink denial, scope, idempotent prepare, and no inherited environment. Cell Manager/CLI proves one cell per tenant, an explicit state graph, atomic/concurrent persistence, corrupt-registry fail-closed behavior, stable CLI exit codes/JSON, and restart. Fabric proves claimed jobs return `running` consistently with durable state, tenant/cell/profile isolation, one concurrent claimant, rejection of non-claimable states, restart, and sanitized SQLite failures.
|
|
||||||
|
|
||||||
- [ ] Rehydrate the stable blocker ledger using the global snippet and require its latest label is `B1`.
|
- [ ] Rehydrate the stable blocker ledger using the global snippet and require its latest label is `B1`.
|
||||||
- [ ] Rehydrate the exact explorer manifest path and compare sorted actual/expected lists between exact marker lines. The marker names are assembled from split literals so this command cannot match itself:
|
- [ ] Rehydrate the exact explorer manifest path and compare sorted actual/expected lists between exact marker lines. The marker names are assembled from split literals so this command cannot match itself:
|
||||||
@@ -214,21 +196,16 @@ cmp -s "$actual" "$expected"
|
|||||||
|
|
||||||
Expected: exact match to 23 paths. Drift requires another docs review.
|
Expected: exact match to 23 paths. Drift requires another docs review.
|
||||||
|
|
||||||
- [ ] Add package/service/CLI tests and the seven-component matrix before any production repair. Run each focused case and record the exact command plus its expected contract failure. If current behavior already satisfies the contract, record it as characterization and do not manufacture a failure or edit that production module.
|
- [ ] Add package/service/CLI tests; run initial RED:
|
||||||
- [ ] Run the aggregate initial RED/characterization command:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PYTHONDONTWRITEBYTECODE=1 PYTEST_ADDOPTS='-p no:cacheprovider' PYTHONPATH='active-runtime:active-runtime/app' \
|
PYTHONDONTWRITEBYTECODE=1 PYTEST_ADDOPTS='-p no:cacheprovider' PYTHONPATH='active-runtime:active-runtime/app' \
|
||||||
python3 -m pytest tests/v2r1/test_ingress_service.py tests/test_v2r1_package_identity.py \
|
python3 -m pytest tests/v2r1/test_ingress_service.py tests/test_v2r1_package_identity.py -q
|
||||||
tests/v2r1/test_message_ledger_append.py tests/v2r1/test_memory_scope.py \
|
|
||||||
tests/v2r1/test_secret_scope.py tests/v2r1/test_audit.py \
|
|
||||||
tests/v2r1/test_hermes_profile.py tests/v2r1/test_cell_manager.py \
|
|
||||||
tests/v2r1/test_hermes_fabric.py -q
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected: package metadata, ingress service, and identity tests begin RED because their implementation is absent. Each matrix case is either a recorded contract RED or a recorded characterization; no production repair precedes its corresponding RED.
|
Expected RED: package metadata, ingress service, and identity tests do not exist.
|
||||||
|
|
||||||
- [ ] Implement package/disabled processor boundary and replace imports in exactly Appendix A. Apply only the minimum runtime repairs demonstrated by matrix REDs; do not delete or weaken a matrix test. Prove no shim/old import:
|
- [ ] Implement package/disabled processor boundary and replace imports in exactly Appendix A. Prove no shim/old import:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
test ! -e active_runtime
|
test ! -e active_runtime
|
||||||
@@ -238,15 +215,11 @@ test -z "$(git grep -l -E '^(from|import) active_runtime\.app' -- tests || true)
|
|||||||
- [ ] Run full collect and full suite:
|
- [ ] Run full collect and full suite:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PYTHONDONTWRITEBYTECODE=1 PYTEST_ADDOPTS='-p no:cacheprovider' PYTHONPATH='active-runtime:active-runtime/app' \
|
|
||||||
python3 -m pytest tests/v2r1 tests/integration tests/test_v2r1_package_identity.py -q
|
|
||||||
PYTHONDONTWRITEBYTECODE=1 PYTEST_ADDOPTS='-p no:cacheprovider' PYTHONPATH='active-runtime:active-runtime/app' python3 -m pytest --collect-only -q
|
PYTHONDONTWRITEBYTECODE=1 PYTEST_ADDOPTS='-p no:cacheprovider' PYTHONPATH='active-runtime:active-runtime/app' python3 -m pytest --collect-only -q
|
||||||
PYTHONDONTWRITEBYTECODE=1 PYTEST_ADDOPTS='-p no:cacheprovider' PYTHONPATH='active-runtime:active-runtime/app' python3 -m pytest -q
|
PYTHONDONTWRITEBYTECODE=1 PYTEST_ADDOPTS='-p no:cacheprovider' PYTHONPATH='active-runtime:active-runtime/app' python3 -m pytest -q
|
||||||
PYTHONDONTWRITEBYTECODE=1 python3 -m compileall -q active-runtime/app/aios_v2r1
|
|
||||||
(cd openclaw/plugins/aios-tenant-boundary && npm test)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected GREEN: the focused carried suite reports at least `160 passed`, including every matrix bullet; full collection has zero import errors; the complete pytest suite, 15 Node tests, and Python compileall pass. Re-run B1's real isolated default/opt-in loader sequence and require the same hook evidence. Pre-existing failures still block B2.
|
Expected GREEN: zero collection/import errors and complete suite passes; pre-existing failures still block B2.
|
||||||
|
|
||||||
- [ ] Build and inspect isolated wheel:
|
- [ ] Build and inspect isolated wheel:
|
||||||
|
|
||||||
@@ -268,29 +241,22 @@ PY
|
|||||||
|
|
||||||
Expected GREEN: one wheel, one V2R1 namespace, installed identity `aios_v2r1`.
|
Expected GREEN: one wheel, one V2R1 namespace, installed identity `aios_v2r1`.
|
||||||
|
|
||||||
- [ ] Stage the exact 43 paths (six package paths, Appendix A's 23 import paths, seven matrix runtime modules, and seven matrix-test paths), commit, append ledger:
|
- [ ] Stage six named paths plus exact explorer list, commit, append ledger:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
actual="$(git rev-parse --git-path v2r1-active-runtime-imports.actual)"
|
actual="$(git rev-parse --git-path v2r1-active-runtime-imports.actual)"
|
||||||
git add active-runtime/pyproject.toml active-runtime/app/aios_v2r1/__init__.py \
|
git add active-runtime/pyproject.toml active-runtime/app/aios_v2r1/__init__.py \
|
||||||
active-runtime/app/aios_v2r1/ingress_service.py active-runtime/app/aios_v2r1/cli.py \
|
active-runtime/app/aios_v2r1/ingress_service.py active-runtime/app/aios_v2r1/cli.py \
|
||||||
tests/v2r1/test_ingress_service.py tests/test_v2r1_package_identity.py \
|
tests/v2r1/test_ingress_service.py tests/test_v2r1_package_identity.py
|
||||||
active-runtime/app/aios_v2r1/message_ledger.py active-runtime/app/aios_v2r1/memory_scope.py \
|
|
||||||
active-runtime/app/aios_v2r1/secret_scope.py active-runtime/app/aios_v2r1/audit.py \
|
|
||||||
active-runtime/app/aios_v2r1/hermes_profile.py active-runtime/app/aios_v2r1/cell_manager.py \
|
|
||||||
active-runtime/app/aios_v2r1/hermes_fabric.py tests/v2r1/test_message_ledger_append.py \
|
|
||||||
tests/v2r1/test_memory_scope.py tests/v2r1/test_secret_scope.py tests/v2r1/test_audit.py \
|
|
||||||
tests/v2r1/test_hermes_profile.py tests/v2r1/test_cell_manager.py tests/v2r1/test_hermes_fabric.py
|
|
||||||
while IFS= read -r path; do git add -- "$path"; done <"$actual"
|
while IFS= read -r path; do git add -- "$path"; done <"$actual"
|
||||||
git commit -m "feat(v2r1): package canonical authenticated ingress"
|
git commit -m "feat(v2r1): package canonical authenticated ingress"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Review B2
|
### Review B2
|
||||||
|
|
||||||
- [ ] Require `git diff --check`, exactly 43 paths (the original 29 plus the exact 14 matrix paths), exact-list/no-shim checks, focused matrix/carried tests, full collect/full pytest, Node 15, real loader default/opt-in, compileall, and wheel identity.
|
- [ ] Require `git diff --check`, exactly 29 paths (six named plus Appendix A), exact-list/no-shim checks, full collect/full pytest, and wheel identity.
|
||||||
- [ ] Require B2 parent equals B1 in ledger.
|
- [ ] Require B2 parent equals B1 in ledger.
|
||||||
- [ ] Prove mutation routes remain fail-closed until authenticated processors are installed.
|
- [ ] Prove mutation routes remain fail-closed until authenticated processors are installed.
|
||||||
- [ ] Prove every production matrix edit has a recorded pre-edit RED and every already-correct case is recorded as characterization. The partial R9 ledger must contain exactly `START,B1,B2`; B3/B4/B5 remain absent.
|
|
||||||
- [ ] After all prior B2 review checks pass, append B2 and verify the recorded commit identity:
|
- [ ] After all prior B2 review checks pass, append B2 and verify the recorded commit identity:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,188 +0,0 @@
|
|||||||
# V2R1 R10 personal Weixin release-plan amendment
|
|
||||||
|
|
||||||
Status: reviewed design-only amendment. It adds D1, B6, and B7 to the R9 linear release plan. It does not authorize runtime, candidate, production, baseline, tag, or Fleet mutation.
|
|
||||||
|
|
||||||
Authority remains `docs/superpowers/plans/2026-07-14-v2r1-runtime-release-plan.md` at B2 tip `68d27879103990aac692207a8512b3725c6d0d41`. R9 `START` is `a9a0b4ae396e33c9bfc35a5f5694e5e354c3a47c`; accepted B1 is `57103c11d21192a50febe846d30cee6fc5da4ac1`; accepted B2 is `68d27879103990aac692207a8512b3725c6d0d41`. Their trees and path sets are immutable. This amendment does not relabel, squash, reorder, or absorb any of the original five blockers.
|
|
||||||
|
|
||||||
## Exact linear ledger
|
|
||||||
|
|
||||||
The only valid ancestry is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
START -> B1 -> B2 -> D1 -> B3 -> B4 -> B5 -> B6 -> B7
|
|
||||||
```
|
|
||||||
|
|
||||||
`D1` is a non-blocker design dependency whose parent is exactly B2. `B3` parent is D1; every later blocker parent is the immediately preceding row. `START..B7` contains exactly eight non-merge commits: B1, B2, D1, B3, B4, B5, B6, B7. B1-B5 remain five distinct original blocker commits; B6 and B7 are distinct new blocker commits. No cherry-pick collapse, squash, fixup, merge commit, history rewrite, or force push is allowed.
|
|
||||||
|
|
||||||
B3/B4 retain every R9 evidence gate. In particular, unresolved `EVIDENCE_REQUIRED` for the exact provider `client_id -> message_id` binding stops B4 with no implementation, commit, or ledger append. B5 can land only after accepted B4. Even after B5 lands, every forward `stage`, `verify-stage`, or `promote` operation remains blocked until accepted B6 and B7 commits exist and all gates below pass.
|
|
||||||
|
|
||||||
## D1 design-only commit
|
|
||||||
|
|
||||||
D1 changes exactly these 23 paths and no others:
|
|
||||||
|
|
||||||
```text
|
|
||||||
docs/superpowers/plans/2026-07-15-v2r1-personal-weixin-r10-release-plan-amendment.md
|
|
||||||
contracts/personal-weixin/v1/SECURITY.md
|
|
||||||
contracts/personal-weixin/v1/golden-contract.schema.json
|
|
||||||
contracts/personal-weixin/v1/fixtures/01-tenant-account-entitlement.json
|
|
||||||
contracts/personal-weixin/v1/fixtures/02-readiness-ready.json
|
|
||||||
contracts/personal-weixin/v1/fixtures/03-readiness-field-missing.json
|
|
||||||
contracts/personal-weixin/v1/fixtures/04-readiness-stale.json
|
|
||||||
contracts/personal-weixin/v1/fixtures/05-readiness-plugin-digest-mismatch.json
|
|
||||||
contracts/personal-weixin/v1/fixtures/06-readiness-account-not-entitled.json
|
|
||||||
contracts/personal-weixin/v1/fixtures/07-readiness-account-not-running.json
|
|
||||||
contracts/personal-weixin/v1/fixtures/08-readiness-error.json
|
|
||||||
contracts/personal-weixin/v1/fixtures/09-web-login-start-request.json
|
|
||||||
contracts/personal-weixin/v1/fixtures/10-web-login-wait-request.json
|
|
||||||
contracts/personal-weixin/v1/fixtures/11-qr-pending.json
|
|
||||||
contracts/personal-weixin/v1/fixtures/12-need-verify-code.json
|
|
||||||
contracts/personal-weixin/v1/fixtures/13-invalid-verify-code.json
|
|
||||||
contracts/personal-weixin/v1/fixtures/14-expired.json
|
|
||||||
contracts/personal-weixin/v1/fixtures/15-failed.json
|
|
||||||
contracts/personal-weixin/v1/fixtures/16-already-connected.json
|
|
||||||
contracts/personal-weixin/v1/fixtures/17-confirmed.json
|
|
||||||
contracts/personal-weixin/v1/fixtures/18-unknown-upstream-fail-closed.json
|
|
||||||
contracts/personal-weixin/v1/fixtures/19-authenticated-inbound-event.json
|
|
||||||
contracts/personal-weixin/v1/fixtures/20-unbind-unsupported.json
|
|
||||||
```
|
|
||||||
|
|
||||||
D1 validation requires JSON parse, Draft 2020-12 schema validation of every fixture, negative mutation tests, exact 23-path comparison, `git diff --check`, placeholder/secret scan, and confirmation that no runtime/deploy/release-active path changed.
|
|
||||||
|
|
||||||
## Preserved R9 blocker allowlists
|
|
||||||
|
|
||||||
B1 and B2 are already accepted at the immutable SHAs above. Their actual commit path sets, parent SHAs, and trees are the allowlists; compare with `git diff-tree --no-commit-id --name-only -r <sha>` and reject any mismatch. B1 has six actual paths under `openclaw/plugins/aios-tenant-boundary/`; B2 has exactly 43 actual package/import/matrix paths. No later blocker may amend them.
|
|
||||||
|
|
||||||
B3 changes exactly these 15 R9 paths:
|
|
||||||
|
|
||||||
```text
|
|
||||||
active-runtime/app/aios_v2r1/identity.py
|
|
||||||
active-runtime/app/aios_v2r1/tenant_context.py
|
|
||||||
active-runtime/app/aios_v2r1/ingress_service.py
|
|
||||||
active-runtime/app/aios_v2r1/bridge_auth.py
|
|
||||||
active-runtime/app/aios_v2r1/weixin_projection.py
|
|
||||||
fixes/patch_openclaw_weixin_v2r1_ingress.py
|
|
||||||
tests/v2r1/test_bridge_auth.py
|
|
||||||
tests/v2r1/test_weixin_projection.py
|
|
||||||
tests/integration/test_v2r1_bridge_transport.py
|
|
||||||
tests/test_weixin_v2r1_ingress_bridge.py
|
|
||||||
tests/fixtures/weixin_v2r1/text.json
|
|
||||||
tests/fixtures/weixin_v2r1/quoted-text.json
|
|
||||||
tests/fixtures/weixin_v2r1/image-with-text.json
|
|
||||||
tests/fixtures/weixin_v2r1/unresolved-reference.json
|
|
||||||
release/v2r1c0-rc1/weixin-bridge-evidence.json
|
|
||||||
```
|
|
||||||
|
|
||||||
B4 changes exactly these 19 R9 paths and remains evidence-stopped until both R9 evidence files are accepted:
|
|
||||||
|
|
||||||
```text
|
|
||||||
active-runtime/app/aios_v2r1/outbox.py
|
|
||||||
active-runtime/app/aios_v2r1/hermes_http.py
|
|
||||||
active-runtime/app/aios_v2r1/hermes_worker.py
|
|
||||||
active-runtime/app/aios_v2r1/outbound_receipt.py
|
|
||||||
active-runtime/app/aios_v2r1/ingress_service.py
|
|
||||||
active-runtime/app/aios_v2r1/message_ledger.py
|
|
||||||
tests/v2r1/test_outbox.py
|
|
||||||
tests/v2r1/test_hermes_http.py
|
|
||||||
tests/v2r1/test_outbound_receipt.py
|
|
||||||
tests/integration/test_v2r1_delivery_flow.py
|
|
||||||
fixes/patch_openclaw_weixin_outbound_quotes.py
|
|
||||||
fixes/patch_openclaw_weixin_outbound_receipts.py
|
|
||||||
openclaw/plugins/aios-tenant-boundary/dist/receipt-client.js
|
|
||||||
openclaw/plugins/aios-tenant-boundary/dist/outbound-receipt-journal.js
|
|
||||||
openclaw/plugins/aios-tenant-boundary/test/receipt-client.test.js
|
|
||||||
tests/test_weixin_outbound_quote_timestamp.py
|
|
||||||
tests/test_weixin_outbound_receipt_boundary.py
|
|
||||||
release/v2r1c0-rc1/hermes-http-contract-evidence.json
|
|
||||||
release/v2r1c0-rc1/weixin-outbound-binding-evidence.json
|
|
||||||
```
|
|
||||||
|
|
||||||
B5 changes exactly these 14 R9 paths:
|
|
||||||
|
|
||||||
```text
|
|
||||||
deploy/v2r1/release_ctl.py
|
|
||||||
deploy/v2r1/capture_live_preflight.py
|
|
||||||
deploy/v2r1/install.sh
|
|
||||||
deploy/v2r1/validate.sh
|
|
||||||
deploy/v2r1/healthcheck.sh
|
|
||||||
deploy/v2r1/rollback.sh
|
|
||||||
deploy/v2r1/aios-v2r1-candidate-gateway.service
|
|
||||||
deploy/v2r1/aios-v2r1-production-gateway.service
|
|
||||||
deploy/v2r1/aios-v2r1-ingress.service
|
|
||||||
deploy/v2r1/aios-v2r1-hermes-worker.service
|
|
||||||
deploy/v2r1/openclaw-candidate.json5
|
|
||||||
release/v2r1c0-rc1/FLEET_UNSUPPORTED
|
|
||||||
tests/deploy/test_v2r1_release_contract.py
|
|
||||||
release/v2r1c0-rc1/RELEASE.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## B6: evidence-bound OpenClaw personal-Weixin candidate
|
|
||||||
|
|
||||||
B6 changes exactly these 13 paths:
|
|
||||||
|
|
||||||
```text
|
|
||||||
openclaw/candidates/personal-weixin-r10/README.md
|
|
||||||
openclaw/candidates/personal-weixin-r10/manifest.json
|
|
||||||
openclaw/candidates/personal-weixin-r10/src/golden-contract.js
|
|
||||||
openclaw/candidates/personal-weixin-r10/src/gateway-inbound-auth.js
|
|
||||||
openclaw/candidates/personal-weixin-r10/plugin/openclaw.plugin.json
|
|
||||||
openclaw/candidates/personal-weixin-r10/plugin/index.js
|
|
||||||
fixes/patch_openclaw_personal_weixin_login.py
|
|
||||||
fixes/patch_openclaw_personal_weixin_gateway_auth.py
|
|
||||||
openclaw/candidates/personal-weixin-r10/test/golden-contract.test.js
|
|
||||||
openclaw/candidates/personal-weixin-r10/test/gateway-auth.test.js
|
|
||||||
tests/personal_weixin/test_golden_fixtures.py
|
|
||||||
tests/integration/test_personal_weixin_gateway_contract.py
|
|
||||||
release/v2r1c0-rc1/personal-weixin-b6-evidence.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Before B6 code, evidence records exact installed upstream source commit, every patch target path/digest, plugin loader version/digest, Gateway auth owner, credential files, and candidate-only state/config. Stop on inference or drift. Tests are RED before implementation and prove: required nonempty opaque `sessionKey`; no account fallback; exact canonical statuses and readiness shapes; unknown status fail-closed; 4-8 digit one-shot `verifyCode` with no persistence/log/response; exact event body with no authentication/tenant/user/session assertions; transport signature/skew/nonce/replay checks; exact 501 zero mutation; byte-idempotent patches; build/runtime inspect in isolated candidate state. The B6 evidence JSON is produced only after these gates pass.
|
|
||||||
|
|
||||||
## B7: tenant-scoped Portal adapter and durable challenge
|
|
||||||
|
|
||||||
B7 changes exactly these 13 paths:
|
|
||||||
|
|
||||||
```text
|
|
||||||
active-runtime/app/aios_v2r1/personal_weixin/official_login.py
|
|
||||||
active-runtime/app/aios_v2r1/personal_weixin/adapter.py
|
|
||||||
portal/personal_weixin/views.py
|
|
||||||
portal/personal_weixin/urls.py
|
|
||||||
portal/personal_weixin/services.py
|
|
||||||
portal/personal_weixin/templates/personal_weixin/bind.html
|
|
||||||
tests/personal_weixin/test_adapter.py
|
|
||||||
tests/personal_weixin/test_portal_views.py
|
|
||||||
tests/integration/test_personal_weixin_golden_flow.py
|
|
||||||
release/v2r1c0-rc1/personal-weixin-source-baseline.json
|
|
||||||
release/v2r1c0-rc1/personal-weixin-source-baseline.sha256
|
|
||||||
release/v2r1c0-rc1/personal-weixin-canary-contract.json
|
|
||||||
release/v2r1c0-rc1/personal-weixin-rollback-contract.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Tests are RED before B7 implementation and prove: Django authentication/CSRF; server-session tenant resolution; browser `accountId` treated only as a preallocated-slot reference and current entitlement rechecked on every list/readiness/start/wait/status; exact `sessionKey` forwarding without fallback; live canonical readiness; authenticated Gateway transport context separate from the six-field event body; durable challenge record owning tenant/user/account/session digest; hash-only secret storage; transactional/CAS single consumption; exact replay idempotence; altered replay/concurrency/expiry/cross-tenant denial with zero mutation; no provider token/raw iLink/verify code/challenge code persistence or leakage; size/rate/no-store controls; restart; and unbind 501 before all mutations/calls/jobs.
|
|
||||||
|
|
||||||
## Canonical contract gates
|
|
||||||
|
|
||||||
`sessionKey` is nonempty and exact. Canonical results are:
|
|
||||||
|
|
||||||
```text
|
|
||||||
qr_pending connected=false alreadyConnected=false terminal=false needsVerifyCode=false
|
|
||||||
need_verifycode connected=false alreadyConnected=false terminal=false needsVerifyCode=true
|
|
||||||
invalid_verifycode connected=false alreadyConnected=false terminal=false needsVerifyCode=true
|
|
||||||
expired connected=false alreadyConnected=false terminal=true needsVerifyCode=false
|
|
||||||
failed connected=false alreadyConnected=false terminal=true needsVerifyCode=false
|
|
||||||
already_connected connected=false alreadyConnected=true terminal=true needsVerifyCode=false
|
|
||||||
confirmed connected=true alreadyConnected=false terminal=true needsVerifyCode=false
|
|
||||||
```
|
|
||||||
|
|
||||||
`binded_redirect` maps to `already_connected`. An unknown upstream status maps to canonical `failed/errorCode=unknown_upstream_status`. Readiness always uses the same complete shape: missing fields, stale evidence, plugin digest mismatch, absent entitlement, not-running account, and Gateway error all produce `ready=false`; only a live authenticated all-green snapshot produces `ready=true/code=ready`.
|
|
||||||
|
|
||||||
The inbound event body is exactly `eventId/accountId/senderId/chatId/challengeCode/observedAt`; transport authentication and server-resolved tenant/user/session context are not body fields. Fixture secrets are test inputs only. Raw `verifyCode` and `challengeCode` never enter persistence, logs, responses, URLs, analytics, traces, metrics labels, or job arguments. Raw `sessionKey` appears only where the golden login request/result contract requires it; it is never persisted or logged and is forwarded byte-exact without fallback.
|
|
||||||
|
|
||||||
## Merge, canary, promotion, and rollback gates
|
|
||||||
|
|
||||||
For each D1/B3-B7 row: require the exact parent row SHA, one non-merge commit, exact sorted path allowlist, clean `diff --check`, focused and carried tests, reviewer acceptance, then append `label/SHA/parent/tree` to the worktree-local ledger. No row may start from an unaccepted predecessor.
|
|
||||||
|
|
||||||
After B7, rebuild once from the accepted B7 tree. Stage one isolated candidate using B5 controls and no active configuration. Canary exactly one explicitly entitled `(tenant-canary, account-canary)` pair. Before traffic, require live Gateway/plugin/account readiness with expected digests and freshness. Exercise all golden outcomes, invalid and unknown fail-closed paths, restart, concurrent waits, atomic challenge consumption, exact replay, altered replay, expiry, unauthenticated event, tenant/account mismatch, cross-tenant IDOR, secret redaction, and exact end-to-end unbind 501 with zero durable/runtime/upstream/job mutation. Any missing evidence, extra account, digest drift, stale snapshot, noncanonical status, secret observation, or mutation fails the canary and blocks promotion.
|
|
||||||
|
|
||||||
Promotion accepts only the exact artifact/config/plugin/source digests verified by canary; it never rebuilds, repatches, switches branches, edits `VERSION`/`baseline/current.json`, creates a tag, invokes Fleet, or changes unrelated services. Candidate and production artifacts must be byte-identical. B5 live preflight is recaptured and reconciled immediately before `verify-stage` and `promote`.
|
|
||||||
|
|
||||||
Rollback is armed before canary with the immediately preceding production artifact/config/plugin digests, units, credentials metadata, and health contract. On any gate failure, STOP blocks forward actions; invoke only B5's recorded rollback-only path. Restore byte-identical prior artifacts/config, never the candidate rebuild; do not delete STOP automatically; do not roll back the database absent a separately approved incompatibility plan. Verify previous digests, listeners, units, secret paths/modes, canonical readiness, and tenant/account isolation. If verification fails, record `ROLLBACK_FAILED`, keep STOP, and escalate without forward mutation.
|
|
||||||
@@ -19,7 +19,6 @@ Alternative C remains the selected architecture because active full-execution au
|
|||||||
- B3 stops unless the installed Weixin payload shape, patch target hashes, authority-map ownership, and transport signing boundary are evidenced.
|
- B3 stops unless the installed Weixin payload shape, patch target hashes, authority-map ownership, and transport signing boundary are evidenced.
|
||||||
- B4 stops unless Hermes request/response schema, router ownership, downstream timeout, idempotency behavior, and exact outbound message binding are evidenced.
|
- B4 stops unless Hermes request/response schema, router ownership, downstream timeout, idempotency behavior, and exact outbound message binding are evidenced.
|
||||||
- B5 stops unless B3 and B4 are accepted and the reconciled sentinel plus the V1R4C6 live-runtime snapshot are current for the candidate digest.
|
- B5 stops unless B3 and B4 are accepted and the reconciled sentinel plus the V1R4C6 live-runtime snapshot are current for the candidate digest.
|
||||||
- The R9 D0 documentation correction becomes ledger `START` without consuming a blocker. It expands B2 executable coverage only; all authenticated transport, evidence, terminal, isolation, and release gates remain unchanged.
|
|
||||||
|
|
||||||
The present outbound evidence is insufficient, so B4 and B5 are currently stopped. This document defines the fail-closed path; it does not claim the missing evidence exists.
|
The present outbound evidence is insufficient, so B4 and B5 are currently stopped. This document defines the fail-closed path; it does not claim the missing evidence exists.
|
||||||
|
|
||||||
@@ -131,12 +130,6 @@ The release package has one V2R1 import identity: `aios_v2r1`. There is no `acti
|
|||||||
|
|
||||||
`active-runtime/pyproject.toml` packages `aios_v2r1` from `active-runtime/app`. Repository tests use `PYTHONPATH='active-runtime:active-runtime/app'`: legacy modules import as `app.*`, while V2R1 imports as `aios_v2r1.*`. The implementation plan enumerates all 23 known `active_runtime.app` test imports and replaces them explicitly. Full collection, full pytest, and wheel inspection must prove the wheel contains one V2R1 namespace and that source and installed imports resolve to `aios_v2r1` only.
|
`active-runtime/pyproject.toml` packages `aios_v2r1` from `active-runtime/app`. Repository tests use `PYTHONPATH='active-runtime:active-runtime/app'`: legacy modules import as `app.*`, while V2R1 imports as `aios_v2r1.*`. The implementation plan enumerates all 23 known `active_runtime.app` test imports and replaces them explicitly. Full collection, full pytest, and wheel inspection must prove the wheel contains one V2R1 namespace and that source and installed imports resolve to `aios_v2r1` only.
|
||||||
|
|
||||||
Packaging acceptance is also the executable completeness gate for seven preserved runtime components. Message Ledger must prove exact-ID/no-fallback semantics, idempotency/conflict, tenant/cell/direction isolation, expiry/purge/restart/concurrency, and sanitized SQLite errors. Memory must prove every namespace, cross-scope denial, shared-artifact read-only behavior, qualified-key traversal rejection, and disabled search. Secret must prove reference, broker path, scope, kind, version, identifier, no-value-leakage, and fail-closed broker behavior. Audit must prove genesis and tamper/delete/reorder detection, Unicode canonicalization, concurrency, restart, validation, tenant isolation, and sanitized SQLite errors. Hermes Profile must prove exact environment, absolute `0700` roots, traversal/symlink denial, scope, idempotence, and no environment inheritance. Cell Manager/CLI must prove one cell per tenant, an explicit state graph, atomic concurrent persistence, corrupt-registry fail-closed behavior, stable JSON/exit codes, and restart. Fabric must prove that claims return `running` consistently with durable state, isolation, one concurrent claimant, non-claimable states, restart, and sanitized SQLite errors.
|
|
||||||
|
|
||||||
The original 29 B2 paths are extended by exactly fourteen reviewed paths: seven production modules (`message_ledger.py`, `memory_scope.py`, `secret_scope.py`, `audit.py`, `hermes_profile.py`, `cell_manager.py`, and `hermes_fabric.py`) and seven matrix tests (`test_message_ledger_append.py`, `test_memory_scope.py`, `test_secret_scope.py`, `test_audit.py`, `test_hermes_profile.py`, `test_cell_manager.py`, and `test_hermes_fabric.py`). `cli.py` remains one of the original package paths. A production module changes only after its focused test has been observed failing for the intended contract; already-correct behavior is a characterization and must not be weakened merely to manufacture a RED.
|
|
||||||
|
|
||||||
The B2 review requires exactly 43 changed paths, at least 160 passing carried V2R1/integration/package tests with every matrix bullet represented, zero full-collection errors, a passing complete pytest suite, the 15 Node tests, the real isolated loader in default and opt-in modes, compileall, wheel identity, and a clean tree. These coverage additions do not authorize B3 bridge, B4 Hermes HTTP/outbound, B5 deployment, active service, main, tag, `VERSION`, or baseline mutation.
|
|
||||||
|
|
||||||
## Exact projection, quote, and attachment authority
|
## Exact projection, quote, and attachment authority
|
||||||
|
|
||||||
`WeixinProjection` runs in Python after transport authentication. It preserves numeric Weixin IDs from the JSON token as decimal strings and rejects a value that has already passed through unsafe JavaScript-number conversion.
|
`WeixinProjection` runs in Python after transport authentication. It preserves numeric Weixin IDs from the JSON token as decimal strings and rejects a value that has already passed through unsafe JavaScript-number conversion.
|
||||||
@@ -291,4 +284,4 @@ Preparation or release stops when any of these is true:
|
|||||||
|
|
||||||
## Acceptance
|
## Acceptance
|
||||||
|
|
||||||
The design is review-complete when the plan contains exactly five blocker commits, dynamically anchors their parent chain at B1 start, enumerates all legacy import edits, and has executable RED/GREEN/review gates. R9 preparation additionally requires D0 as `START`, B1 as its child, B2 as one 43-path child commit, the full seven-component executable matrix, and a partial ledger containing exactly `START,B1,B2`. Runtime release acceptance additionally requires all B3/B4/B5 evidence gates, the full Python collection and suite, the real Node/OpenClaw loader harness, exact receipt semantics, isolated candidate staging, deterministic artifacts, current live evidence, and rehearsed constrained rollback.
|
The design is review-complete when the plan contains exactly five blocker commits, dynamically anchors their parent chain at B1 start, enumerates all legacy import edits, and has executable RED/GREEN/review gates. Runtime release acceptance additionally requires all B3/B4/B5 evidence gates, the full Python collection and suite, the real Node/OpenClaw loader harness, exact receipt semantics, isolated candidate staging, deterministic artifacts, current live evidence, and rehearsed constrained rollback.
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"task_id": "TASK-CODEX-V2R1-PERSONAL-WEIXIN-B6B7-BOOTSTRAP-AND-CANARY-20260715T120000UTC8",
|
|
||||||
"phase": "A",
|
|
||||||
"type": "branch",
|
|
||||||
"branch": "patch/v2r1-personal-weixin-b6b7-impl-20260715T115309UTC8",
|
|
||||||
"base_ref": "design/v2r1-personal-weixin-golden-contract-20260715",
|
|
||||||
"base_commit": "d74229927b2dadca7719455984aea10d010c5123",
|
|
||||||
"created_by": "aiman",
|
|
||||||
"created_at": "2026-07-15T03:53:09Z",
|
|
||||||
"direct_main_push": false,
|
|
||||||
"owner_approval_required": false,
|
|
||||||
"owner_approved": true,
|
|
||||||
"owner_approval_scope": "Phase A bootstrap for owner-approved B6/B7 personal-Weixin implementation; no production, runtime, database, secret, tag, baseline, or controller/ChatGPT worktree changes.",
|
|
||||||
"production_change": false,
|
|
||||||
"user_action_required": false
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
import aios_v2r1
|
import aios_v2r1
|
||||||
from aios_v2r1 import cli
|
|
||||||
from aios_v2r1.ingress_service import IngressHTTPServer
|
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
@@ -28,38 +23,3 @@ def test_packaging_metadata_declares_only_the_canonical_app_package() -> None:
|
|||||||
assert 'package-dir = {"" = "app"}' in metadata
|
assert 'package-dir = {"" = "app"}' in metadata
|
||||||
assert "active_runtime" not in metadata
|
assert "active_runtime" not in metadata
|
||||||
assert 'aios-cell = "aios_v2r1.cli:main"' in metadata
|
assert 'aios-cell = "aios_v2r1.cli:main"' in metadata
|
||||||
|
|
||||||
|
|
||||||
def test_registry_path_named_serve_keeps_legacy_status_dispatch(
|
|
||||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
||||||
) -> None:
|
|
||||||
calls: list[tuple[str, str]] = []
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Result:
|
|
||||||
tenant_id: str
|
|
||||||
state: str
|
|
||||||
|
|
||||||
class FakeManager:
|
|
||||||
def __init__(self, registry: str) -> None:
|
|
||||||
calls.append(("registry", registry))
|
|
||||||
|
|
||||||
def status(self, tenant_id: str) -> Result:
|
|
||||||
calls.append(("status", tenant_id))
|
|
||||||
return Result(tenant_id=tenant_id, state="healthy")
|
|
||||||
|
|
||||||
monkeypatch.setattr(cli, "CellManager", FakeManager)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
cli,
|
|
||||||
"serve",
|
|
||||||
lambda host, port: pytest.fail("legacy registry command entered service mode"),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert cli.main(["serve", "status", "tenant-a"]) == 0
|
|
||||||
assert calls == [("registry", "serve"), ("status", "tenant-a")]
|
|
||||||
assert '"tenant_id": "tenant-a"' in capsys.readouterr().out
|
|
||||||
|
|
||||||
|
|
||||||
def test_packaged_server_waits_for_non_daemon_request_handlers() -> None:
|
|
||||||
assert IngressHTTPServer.daemon_threads is False
|
|
||||||
assert IngressHTTPServer.block_on_close is True
|
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
"""Executable completeness contract for the append-only audit chain."""
|
|
||||||
from __future__ import annotations
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from pathlib import Path
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
import pytest
|
|
||||||
from aios_v2r1.audit import AuditLog
|
|
||||||
from aios_v2r1.tenant_context import ConflictError, ValidationError
|
|
||||||
|
|
||||||
def append(log: AuditLog, index: int = 0, *, tenant_id: str = "tenant-a") -> str:
|
|
||||||
return log.append(tenant_id=tenant_id, trace_id=f"trace-{index}",
|
|
||||||
event_type="tool_call",
|
|
||||||
payload={"index": index, "text": "虾马 café"})
|
|
||||||
|
|
||||||
def test_first_event_is_anchored_to_genesis(tmp_path: Path) -> None:
|
|
||||||
database = tmp_path / "audit.sqlite3"
|
|
||||||
log = AuditLog(database)
|
|
||||||
append(log)
|
|
||||||
with sqlite3.connect(database) as db:
|
|
||||||
assert db.execute("SELECT previous_hash FROM audit_events").fetchone()[0] == "GENESIS"
|
|
||||||
assert log.verify()
|
|
||||||
|
|
||||||
def test_unicode_payload_is_canonical_across_restart(tmp_path: Path) -> None:
|
|
||||||
database = tmp_path / "audit.sqlite3"
|
|
||||||
digest = append(AuditLog(database))
|
|
||||||
reopened = AuditLog(database)
|
|
||||||
assert reopened.verify()
|
|
||||||
with sqlite3.connect(database) as db:
|
|
||||||
stored = db.execute("SELECT event_hash,payload FROM audit_events").fetchone()
|
|
||||||
assert stored[0] == digest
|
|
||||||
assert "虾马" in stored[1]
|
|
||||||
|
|
||||||
def test_payload_tamper_is_detected(tmp_path: Path) -> None:
|
|
||||||
database = tmp_path / "audit.sqlite3"
|
|
||||||
log = AuditLog(database)
|
|
||||||
append(log)
|
|
||||||
with sqlite3.connect(database) as db:
|
|
||||||
db.execute("UPDATE audit_events SET payload='{}'")
|
|
||||||
assert not log.verify()
|
|
||||||
|
|
||||||
def test_middle_event_deletion_is_detected(tmp_path: Path) -> None:
|
|
||||||
database = tmp_path / "audit.sqlite3"
|
|
||||||
log = AuditLog(database)
|
|
||||||
for index in range(3):
|
|
||||||
append(log, index)
|
|
||||||
with sqlite3.connect(database) as db:
|
|
||||||
db.execute("DELETE FROM audit_events WHERE seq=2")
|
|
||||||
assert not log.verify()
|
|
||||||
|
|
||||||
def test_event_reordering_is_detected(tmp_path: Path) -> None:
|
|
||||||
database = tmp_path / "audit.sqlite3"
|
|
||||||
log = AuditLog(database)
|
|
||||||
append(log, 1)
|
|
||||||
append(log, 2)
|
|
||||||
with sqlite3.connect(database) as db:
|
|
||||||
db.execute("UPDATE audit_events SET seq=100 WHERE seq=1")
|
|
||||||
assert not log.verify()
|
|
||||||
|
|
||||||
def test_concurrent_appends_form_one_valid_chain(tmp_path: Path) -> None:
|
|
||||||
database = tmp_path / "audit.sqlite3"
|
|
||||||
log = AuditLog(database)
|
|
||||||
with ThreadPoolExecutor(max_workers=16) as executor:
|
|
||||||
digests = list(executor.map(lambda index: append(log, index), range(24)))
|
|
||||||
assert len(set(digests)) == 24
|
|
||||||
assert log.verify()
|
|
||||||
with sqlite3.connect(database) as db:
|
|
||||||
assert db.execute("SELECT count(*) FROM audit_events").fetchone()[0] == 24
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("kwargs", [
|
|
||||||
{"tenant_id": "Tenant-A"}, {"trace_id": "../trace"},
|
|
||||||
{"event_type": "tool/call"}])
|
|
||||||
def test_audit_identifiers_are_validated(tmp_path: Path, kwargs: dict[str, str]) -> None:
|
|
||||||
values = {"tenant_id": "tenant-a", "trace_id": "trace-a",
|
|
||||||
"event_type": "tool_call", "payload": {"ok": True}}
|
|
||||||
values.update(kwargs)
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
AuditLog(tmp_path / "audit.sqlite3").append(**values)
|
|
||||||
|
|
||||||
def test_tenants_share_no_hash_identity_for_equal_payloads(tmp_path: Path) -> None:
|
|
||||||
log = AuditLog(tmp_path / "audit.sqlite3")
|
|
||||||
first = append(log, tenant_id="tenant-a")
|
|
||||||
second = append(log, tenant_id="tenant-b")
|
|
||||||
assert first != second
|
|
||||||
assert log.verify()
|
|
||||||
|
|
||||||
def test_database_is_owner_only(tmp_path: Path) -> None:
|
|
||||||
database = tmp_path / "audit.sqlite3"
|
|
||||||
AuditLog(database)
|
|
||||||
assert os.stat(database).st_mode & 0o777 == 0o600
|
|
||||||
|
|
||||||
def test_sqlite_failures_are_sanitized(tmp_path: Path) -> None:
|
|
||||||
database = tmp_path / "audit.sqlite3"
|
|
||||||
log = AuditLog(database)
|
|
||||||
database.write_bytes(b"not-a-sqlite-database")
|
|
||||||
with pytest.raises(ConflictError, match="audit storage unavailable") as raised:
|
|
||||||
log.verify()
|
|
||||||
assert not isinstance(raised.value, sqlite3.Error)
|
|
||||||
assert "sqlite" not in str(raised.value).lower()
|
|
||||||
assert raised.value.__cause__ is None
|
|
||||||
assert raised.value.__context__ is None
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
"""Executable completeness contract for CellManager and its CLI."""
|
|
||||||
from __future__ import annotations
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
import pytest
|
|
||||||
from aios_v2r1.cell_manager import CellManager
|
|
||||||
from aios_v2r1.cli import main
|
|
||||||
from aios_v2r1.tenant_context import ValidationError
|
|
||||||
|
|
||||||
def test_one_cell_per_tenant(tmp_path: Path) -> None:
|
|
||||||
manager = CellManager(tmp_path / "cells.json")
|
|
||||||
manager.create("tenant-a", "cell-a", "v1", "digest-a")
|
|
||||||
with pytest.raises(ValidationError, match="already"):
|
|
||||||
manager.create("tenant-a", "cell-b", "v1", "digest-b")
|
|
||||||
|
|
||||||
def test_explicit_state_graph_accepts_the_recovery_cycle(tmp_path: Path) -> None:
|
|
||||||
manager = CellManager(tmp_path / "cells.json")
|
|
||||||
manager.create("tenant-a", "cell-a", "v1", "digest-a")
|
|
||||||
for state in ("starting", "healthy", "upgrading", "rollback", "healthy"):
|
|
||||||
assert manager.transition("tenant-a", state).state == state
|
|
||||||
|
|
||||||
def test_explicit_state_graph_rejects_jump_from_created_to_healthy(tmp_path: Path) -> None:
|
|
||||||
manager = CellManager(tmp_path / "cells.json")
|
|
||||||
manager.create("tenant-a", "cell-a", "v1", "digest-a")
|
|
||||||
with pytest.raises(ValidationError, match="transition"):
|
|
||||||
manager.transition("tenant-a", "healthy")
|
|
||||||
|
|
||||||
def test_persistence_uses_atomic_replace(tmp_path: Path) -> None:
|
|
||||||
manager = CellManager(tmp_path / "cells.json")
|
|
||||||
with patch("aios_v2r1.cell_manager.os.replace", wraps=os.replace) as replaced:
|
|
||||||
manager.create("tenant-a", "cell-a", "v1", "digest-a")
|
|
||||||
assert replaced.called
|
|
||||||
|
|
||||||
def test_concurrent_distinct_tenant_creates_are_not_lost(tmp_path: Path) -> None:
|
|
||||||
registry = tmp_path / "cells.json"
|
|
||||||
manager = CellManager(registry)
|
|
||||||
def create(index: int) -> None:
|
|
||||||
manager.create(f"tenant-{index}", f"cell-{index}", "v1", f"digest-{index}")
|
|
||||||
with ThreadPoolExecutor(max_workers=16) as executor:
|
|
||||||
list(executor.map(create, range(24)))
|
|
||||||
data = json.loads(registry.read_text(encoding="utf-8"))
|
|
||||||
assert len(data["cells"]) == 24
|
|
||||||
|
|
||||||
def test_concurrent_same_tenant_has_exactly_one_winner(tmp_path: Path) -> None:
|
|
||||||
manager = CellManager(tmp_path / "cells.json")
|
|
||||||
def create(index: int) -> str:
|
|
||||||
try:
|
|
||||||
manager.create("tenant-a", f"cell-{index}", "v1", f"digest-{index}")
|
|
||||||
return "created"
|
|
||||||
except ValidationError:
|
|
||||||
return "rejected"
|
|
||||||
with ThreadPoolExecutor(max_workers=16) as executor:
|
|
||||||
results = list(executor.map(create, range(16)))
|
|
||||||
assert results.count("created") == 1
|
|
||||||
assert results.count("rejected") == 15
|
|
||||||
|
|
||||||
def test_corrupt_registry_fails_closed_with_domain_error(tmp_path: Path) -> None:
|
|
||||||
registry = tmp_path / "cells.json"
|
|
||||||
manager = CellManager(registry)
|
|
||||||
registry.write_text('{"cells":{"tenant-a":', encoding="utf-8")
|
|
||||||
with pytest.raises(ValidationError, match="registry unavailable") as raised:
|
|
||||||
manager.status("tenant-a")
|
|
||||||
assert "JSON" not in type(raised.value).__name__
|
|
||||||
assert raised.value.__cause__ is None
|
|
||||||
assert raised.value.__context__ is None
|
|
||||||
|
|
||||||
def test_restart_preserves_cell_status(tmp_path: Path) -> None:
|
|
||||||
registry = tmp_path / "cells.json"
|
|
||||||
CellManager(registry).create("tenant-a", "cell-a", "v1", "digest-a")
|
|
||||||
assert CellManager(registry).status("tenant-a").cell_id == "cell-a"
|
|
||||||
|
|
||||||
def test_cli_create_returns_stable_json(
|
|
||||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
||||||
) -> None:
|
|
||||||
registry = tmp_path / "cells.json"
|
|
||||||
assert main([str(registry), "create", "tenant-a", "cell-a", "v1", "digest-a"]) == 0
|
|
||||||
output = json.loads(capsys.readouterr().out)
|
|
||||||
assert output["tenant_id"] == "tenant-a"
|
|
||||||
assert output["state"] == "created"
|
|
||||||
|
|
||||||
def test_cli_domain_failure_returns_code_3_and_json(
|
|
||||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
||||||
) -> None:
|
|
||||||
registry = tmp_path / "cells.json"
|
|
||||||
assert main([str(registry), "status", "missing-tenant"]) == 3
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
assert captured.out == ""
|
|
||||||
assert json.loads(captured.err) == {
|
|
||||||
"code": "VALIDATION_ERROR", "error": "unknown tenant"}
|
|
||||||
|
|
||||||
def test_cli_corrupt_registry_is_sanitized(
|
|
||||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
||||||
) -> None:
|
|
||||||
registry = tmp_path / "cells.json"
|
|
||||||
CellManager(registry)
|
|
||||||
registry.write_text("TOP-SECRET invalid json", encoding="utf-8")
|
|
||||||
assert main([str(registry), "status", "tenant-a"]) == 3
|
|
||||||
error = json.loads(capsys.readouterr().err)
|
|
||||||
assert error["code"] == "VALIDATION_ERROR"
|
|
||||||
assert "TOP-SECRET" not in error["error"]
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("args", [
|
|
||||||
("Tenant-A", "cell-a", "v1", "digest-a"),
|
|
||||||
("tenant-a", "../cell", "v1", "digest-a"),
|
|
||||||
("tenant-a", "cell-a", "../v1", "digest-a"),
|
|
||||||
("tenant-a", "cell-a", "v1", "../digest")])
|
|
||||||
def test_create_validates_all_identifiers(
|
|
||||||
tmp_path: Path, args: tuple[str, str, str, str]
|
|
||||||
) -> None:
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
CellManager(tmp_path / "cells.json").create(*args)
|
|
||||||
@@ -1,114 +1,88 @@
|
|||||||
"""Executable completeness contract for the tenant-partitioned Hermes fabric."""
|
"""Gate 1 contract tests for the Hermes shared agent fabric."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from dataclasses import replace
|
|
||||||
from pathlib import Path
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import pytest
|
from tempfile import TemporaryDirectory
|
||||||
|
import unittest
|
||||||
|
|
||||||
from aios_v2r1.hermes_fabric import HermesFabric
|
from aios_v2r1.hermes_fabric import HermesFabric
|
||||||
from aios_v2r1.tenant_context import CanonicalMessage, ConflictError, ScopeError
|
from aios_v2r1.tenant_context import CanonicalMessage, ConflictError, ScopeError
|
||||||
|
|
||||||
def authenticated_command(event_id: str, **changes: object) -> CanonicalMessage:
|
|
||||||
value = CanonicalMessage(
|
def authenticated_command(
|
||||||
tenant_id="tenant-a", cell_id="cell-a", principal_id="user-a",
|
event_id: str,
|
||||||
principal_type="authenticated_user", origin_type="channel_user",
|
*,
|
||||||
channel="weixin", channel_account_id="account-a", peer_id="peer-a",
|
command_capable: bool = True,
|
||||||
conversation_id="conversation-a", session_id="session-a", task_id=None,
|
origin_type: str = "channel_user",
|
||||||
event_id=event_id, message_id=f"message-{event_id}",
|
) -> CanonicalMessage:
|
||||||
reply_to_message_id=None, direction="inbound", command_capable=True,
|
return CanonicalMessage(
|
||||||
|
tenant_id="tenant-a",
|
||||||
|
cell_id="cell-a",
|
||||||
|
principal_id="user-a",
|
||||||
|
principal_type="authenticated_user",
|
||||||
|
origin_type=origin_type,
|
||||||
|
channel="weixin",
|
||||||
|
channel_account_id="account-a",
|
||||||
|
peer_id="peer-a",
|
||||||
|
conversation_id="conversation-a",
|
||||||
|
session_id="session-a",
|
||||||
|
task_id=None,
|
||||||
|
event_id=event_id,
|
||||||
|
message_id="1234567890123456789",
|
||||||
|
reply_to_message_id=None,
|
||||||
|
direction="inbound",
|
||||||
|
command_capable=command_capable,
|
||||||
occurred_at="2026-07-13T00:00:00+00:00",
|
occurred_at="2026-07-13T00:00:00+00:00",
|
||||||
received_at="2026-07-13T00:00:01+00:00", body="/run",
|
received_at="2026-07-13T00:00:01+00:00",
|
||||||
quoted_body=None, adapter_name="weixin", adapter_version="v2")
|
body="/run",
|
||||||
return replace(value, **changes)
|
quoted_body=None,
|
||||||
|
adapter_name="weixin",
|
||||||
|
adapter_version="v2",
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fabric(tmp_path: Path) -> HermesFabric:
|
|
||||||
return HermesFabric(tmp_path / "fabric.sqlite3")
|
|
||||||
|
|
||||||
def test_duplicate_event_returns_sanitized_conflict(fabric: HermesFabric) -> None:
|
class HermesFabricContractTests(unittest.TestCase):
|
||||||
command = authenticated_command("event-a")
|
def test_duplicate_event_returns_sanitized_conflict(self) -> None:
|
||||||
assert fabric.enqueue(message=command, profile_id="profile-a").event_id == "event-a"
|
with TemporaryDirectory() as directory:
|
||||||
with pytest.raises(ConflictError) as raised:
|
fabric = HermesFabric(f"{directory}/fabric.sqlite3")
|
||||||
fabric.enqueue(message=command, profile_id="profile-a")
|
message = authenticated_command("event-a")
|
||||||
assert not isinstance(raised.value, sqlite3.IntegrityError)
|
|
||||||
assert "sqlite" not in str(raised.value).lower()
|
|
||||||
assert raised.value.__cause__ is None
|
|
||||||
assert raised.value.__context__ is None
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("changes", [
|
first = fabric.enqueue(message=message, profile_id="profile-a")
|
||||||
{"command_capable": False}, {"origin_type": "heartbeat"},
|
self.assertEqual(first.event_id, "event-a")
|
||||||
{"principal_type": "service"}, {"direction": "internal"}])
|
|
||||||
def test_non_claimable_provenance_cannot_enqueue(
|
|
||||||
fabric: HermesFabric, changes: dict[str, object]
|
|
||||||
) -> None:
|
|
||||||
with pytest.raises(ScopeError):
|
|
||||||
fabric.enqueue(message=authenticated_command("event-denied", **changes),
|
|
||||||
profile_id="profile-a")
|
|
||||||
|
|
||||||
def test_claim_returns_running_and_matches_durable_state(fabric: HermesFabric) -> None:
|
with self.assertRaises(ConflictError) as raised:
|
||||||
fabric.enqueue(message=authenticated_command("event-a"), profile_id="profile-a")
|
fabric.enqueue(message=message, profile_id="profile-a")
|
||||||
claimed = fabric.claim("tenant-a", "cell-a", "profile-a")
|
|
||||||
assert claimed is not None
|
|
||||||
assert claimed.status == "running"
|
|
||||||
with sqlite3.connect(fabric.database) as db:
|
|
||||||
assert db.execute("SELECT status FROM fabric_jobs WHERE job_id=?",
|
|
||||||
(claimed.job_id,)).fetchone()[0] == "running"
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(("tenant_id", "cell_id", "profile_id"), [
|
error = raised.exception
|
||||||
("tenant-b", "cell-a", "profile-a"), ("tenant-a", "cell-b", "profile-a"),
|
self.assertNotIsInstance(error, sqlite3.IntegrityError)
|
||||||
("tenant-a", "cell-a", "profile-b")])
|
self.assertNotIn("sqlite", str(error).lower())
|
||||||
def test_claim_scope_is_exact(
|
self.assertIsNone(error.__cause__)
|
||||||
fabric: HermesFabric, tenant_id: str, cell_id: str, profile_id: str
|
self.assertIsNone(error.__context__)
|
||||||
) -> None:
|
|
||||||
fabric.enqueue(message=authenticated_command("event-a"), profile_id="profile-a")
|
|
||||||
assert fabric.claim(tenant_id, cell_id, profile_id) is None
|
|
||||||
assert fabric.claim("tenant-a", "cell-a", "profile-a") is not None
|
|
||||||
|
|
||||||
def test_only_one_concurrent_claimant_wins(fabric: HermesFabric) -> None:
|
def test_non_command_event_cannot_enqueue(self) -> None:
|
||||||
fabric.enqueue(message=authenticated_command("event-a"), profile_id="profile-a")
|
with TemporaryDirectory() as directory:
|
||||||
with ThreadPoolExecutor(max_workers=16) as executor:
|
fabric = HermesFabric(f"{directory}/fabric.sqlite3")
|
||||||
claimed = list(executor.map(
|
|
||||||
lambda _: fabric.claim("tenant-a", "cell-a", "profile-a"), range(16)))
|
|
||||||
winners = [job for job in claimed if job is not None]
|
|
||||||
assert len(winners) == 1
|
|
||||||
assert winners[0].status == "running"
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("status", ["running", "completed", "failed", "cancelled"])
|
with self.assertRaises(ScopeError):
|
||||||
def test_nonqueued_states_are_not_claimable(
|
fabric.enqueue(
|
||||||
fabric: HermesFabric, status: str
|
message=authenticated_command(
|
||||||
) -> None:
|
"event-non-command", command_capable=False
|
||||||
job = fabric.enqueue(message=authenticated_command("event-a"), profile_id="profile-a")
|
),
|
||||||
with sqlite3.connect(fabric.database) as db:
|
profile_id="profile-a",
|
||||||
db.execute("UPDATE fabric_jobs SET status=? WHERE job_id=?", (status, job.job_id))
|
)
|
||||||
assert fabric.claim("tenant-a", "cell-a", "profile-a") is None
|
|
||||||
|
|
||||||
def test_restart_preserves_queue_and_claims_once(tmp_path: Path) -> None:
|
def test_heartbeat_provenance_cannot_enqueue(self) -> None:
|
||||||
database = tmp_path / "fabric.sqlite3"
|
with TemporaryDirectory() as directory:
|
||||||
HermesFabric(database).enqueue(message=authenticated_command("event-a"),
|
fabric = HermesFabric(f"{directory}/fabric.sqlite3")
|
||||||
profile_id="profile-a")
|
|
||||||
reopened = HermesFabric(database)
|
|
||||||
claimed = reopened.claim("tenant-a", "cell-a", "profile-a")
|
|
||||||
assert claimed is not None and claimed.status == "running"
|
|
||||||
assert reopened.claim("tenant-a", "cell-a", "profile-a") is None
|
|
||||||
|
|
||||||
def test_concurrent_duplicate_enqueue_has_one_winner(fabric: HermesFabric) -> None:
|
with self.assertRaises(ScopeError):
|
||||||
command = authenticated_command("event-a")
|
fabric.enqueue(
|
||||||
def enqueue(_: int) -> str:
|
message=authenticated_command(
|
||||||
try:
|
"event-heartbeat", origin_type="heartbeat"
|
||||||
fabric.enqueue(message=command, profile_id="profile-a")
|
),
|
||||||
return "queued"
|
profile_id="profile-a",
|
||||||
except ConflictError:
|
)
|
||||||
return "conflict"
|
|
||||||
with ThreadPoolExecutor(max_workers=16) as executor:
|
|
||||||
results = list(executor.map(enqueue, range(16)))
|
|
||||||
assert results.count("queued") == 1
|
|
||||||
assert results.count("conflict") == 15
|
|
||||||
|
|
||||||
def test_sqlite_failures_are_sanitized(fabric: HermesFabric) -> None:
|
|
||||||
Path(fabric.database).write_bytes(b"not-a-sqlite-database")
|
if __name__ == "__main__":
|
||||||
with pytest.raises(ConflictError, match="fabric storage unavailable") as raised:
|
unittest.main()
|
||||||
fabric.health()
|
|
||||||
assert not isinstance(raised.value, sqlite3.Error)
|
|
||||||
assert "sqlite" not in str(raised.value).lower()
|
|
||||||
assert raised.value.__cause__ is None
|
|
||||||
assert raised.value.__context__ is None
|
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
"""Executable completeness contract for Hermes profile isolation."""
|
|
||||||
from __future__ import annotations
|
|
||||||
from pathlib import Path
|
|
||||||
import os
|
|
||||||
import pytest
|
|
||||||
from aios_v2r1.hermes_profile import HermesProfile
|
|
||||||
from aios_v2r1.tenant_context import ValidationError
|
|
||||||
|
|
||||||
def profile(tmp_path: Path, **changes: object) -> HermesProfile:
|
|
||||||
values: dict[str, object] = {
|
|
||||||
"tenant_id": "tenant-a", "cell_id": "cell-a", "profile_id": "profile-a",
|
|
||||||
"hermes_home": tmp_path / "home", "workspace": tmp_path / "workspace"}
|
|
||||||
values.update(changes)
|
|
||||||
return HermesProfile(**values) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
def test_environment_is_exact_and_has_no_inherited_values(
|
|
||||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.setenv("HOST_SECRET", "must-not-inherit")
|
|
||||||
value = profile(tmp_path).environment
|
|
||||||
assert value == {
|
|
||||||
"HERMES_HOME": str(tmp_path / "home"), "HOME": str(tmp_path / "home"),
|
|
||||||
"AIOS_TENANT_ID": "tenant-a", "AIOS_CELL_ID": "cell-a",
|
|
||||||
"AIOS_PROFILE_ID": "profile-a"}
|
|
||||||
assert "HOST_SECRET" not in value
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("changes", [
|
|
||||||
{"hermes_home": Path("relative-home")},
|
|
||||||
{"workspace": Path("relative-workspace")}])
|
|
||||||
def test_profile_roots_must_be_absolute(
|
|
||||||
tmp_path: Path, changes: dict[str, Path]
|
|
||||||
) -> None:
|
|
||||||
with pytest.raises(ValidationError, match="absolute"):
|
|
||||||
profile(tmp_path, **changes)
|
|
||||||
|
|
||||||
def test_prepare_sets_both_roots_to_0700(tmp_path: Path) -> None:
|
|
||||||
value = profile(tmp_path)
|
|
||||||
value.prepare()
|
|
||||||
assert os.stat(value.hermes_home).st_mode & 0o777 == 0o700
|
|
||||||
assert os.stat(value.workspace).st_mode & 0o777 == 0o700
|
|
||||||
|
|
||||||
def test_prepare_is_idempotent(tmp_path: Path) -> None:
|
|
||||||
value = profile(tmp_path)
|
|
||||||
value.prepare()
|
|
||||||
marker = value.workspace / "marker"
|
|
||||||
marker.write_text("kept", encoding="utf-8")
|
|
||||||
value.prepare()
|
|
||||||
assert marker.read_text(encoding="utf-8") == "kept"
|
|
||||||
|
|
||||||
def test_parent_traversal_is_denied(tmp_path: Path) -> None:
|
|
||||||
value = profile(tmp_path)
|
|
||||||
value.prepare()
|
|
||||||
with pytest.raises(ValidationError, match="escapes"):
|
|
||||||
value.assert_path_allowed(value.workspace / ".." / "outside")
|
|
||||||
|
|
||||||
def test_symlink_inside_profile_cannot_escape(tmp_path: Path) -> None:
|
|
||||||
value = profile(tmp_path)
|
|
||||||
value.prepare()
|
|
||||||
outside = tmp_path / "outside"
|
|
||||||
outside.mkdir()
|
|
||||||
(value.workspace / "link").symlink_to(outside, target_is_directory=True)
|
|
||||||
with pytest.raises(ValidationError, match="escapes"):
|
|
||||||
value.assert_path_allowed(value.workspace / "link" / "secret")
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("root_name", ["home", "workspace"])
|
|
||||||
def test_prepare_rejects_symlink_roots(tmp_path: Path, root_name: str) -> None:
|
|
||||||
outside = tmp_path / f"outside-{root_name}"
|
|
||||||
outside.mkdir()
|
|
||||||
symlink = tmp_path / root_name
|
|
||||||
symlink.symlink_to(outside, target_is_directory=True)
|
|
||||||
changes = {"hermes_home" if root_name == "home" else "workspace": symlink}
|
|
||||||
with pytest.raises(ValidationError, match="symlink"):
|
|
||||||
profile(tmp_path, **changes).prepare()
|
|
||||||
|
|
||||||
def test_other_profile_scope_is_denied(tmp_path: Path) -> None:
|
|
||||||
first = profile(tmp_path / "first")
|
|
||||||
second = profile(tmp_path / "second", tenant_id="tenant-b",
|
|
||||||
cell_id="cell-b", profile_id="profile-b")
|
|
||||||
first.prepare()
|
|
||||||
second.prepare()
|
|
||||||
with pytest.raises(ValidationError, match="escapes"):
|
|
||||||
first.assert_path_allowed(second.workspace / "data")
|
|
||||||
|
|
||||||
def test_paths_within_either_root_are_allowed(tmp_path: Path) -> None:
|
|
||||||
value = profile(tmp_path)
|
|
||||||
value.prepare()
|
|
||||||
value.assert_path_allowed(value.hermes_home / "config.json")
|
|
||||||
value.assert_path_allowed(value.workspace / "task.txt")
|
|
||||||
@@ -2,29 +2,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import threading
|
import threading
|
||||||
import textwrap
|
|
||||||
import time
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
|
||||||
from typing import Iterator
|
from typing import Iterator
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from aios_v2r1 import cli
|
from aios_v2r1 import cli
|
||||||
from aios_v2r1.ingress_service import (
|
from aios_v2r1.ingress_service import build_server
|
||||||
MAX_RESPONSE_BYTES,
|
|
||||||
MAX_REQUEST_BYTES,
|
|
||||||
IngressHTTPServer,
|
|
||||||
IngressRequestHandler,
|
|
||||||
build_server,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
@@ -57,82 +43,6 @@ def request(
|
|||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
def raw_request(
|
|
||||||
address: tuple[str, int],
|
|
||||||
method: bytes,
|
|
||||||
path: bytes = b"/health/live",
|
|
||||||
) -> bytes:
|
|
||||||
request_bytes = (
|
|
||||||
method + b" " + path + b" HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
|
|
||||||
)
|
|
||||||
with socket.create_connection(address, timeout=5) as connection:
|
|
||||||
connection.sendall(request_bytes)
|
|
||||||
chunks: list[bytes] = []
|
|
||||||
while True:
|
|
||||||
chunk = connection.recv(8192)
|
|
||||||
if not chunk:
|
|
||||||
return b"".join(chunks)
|
|
||||||
chunks.append(chunk)
|
|
||||||
|
|
||||||
|
|
||||||
def raw_exchange(
|
|
||||||
address: tuple[str, int],
|
|
||||||
request_bytes: bytes,
|
|
||||||
*,
|
|
||||||
shutdown_write: bool = False,
|
|
||||||
receive_timeout: float = 2.0,
|
|
||||||
) -> tuple[bytes, bool]:
|
|
||||||
chunks: list[bytes] = []
|
|
||||||
timed_out = False
|
|
||||||
with socket.create_connection(address, timeout=5) as connection:
|
|
||||||
connection.settimeout(receive_timeout)
|
|
||||||
connection.sendall(request_bytes)
|
|
||||||
if shutdown_write:
|
|
||||||
connection.shutdown(socket.SHUT_WR)
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
chunk = connection.recv(8192)
|
|
||||||
except TimeoutError:
|
|
||||||
timed_out = True
|
|
||||||
break
|
|
||||||
except ConnectionResetError:
|
|
||||||
break
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
chunks.append(chunk)
|
|
||||||
return b"".join(chunks), timed_out
|
|
||||||
|
|
||||||
|
|
||||||
def parse_raw_response(response: bytes) -> tuple[int, bytes, bytes]:
|
|
||||||
headers, body = response.split(b"\r\n\r\n", 1)
|
|
||||||
status = int(headers.split(b"\r\n", 1)[0].split(b" ", 2)[1])
|
|
||||||
return status, headers, body
|
|
||||||
|
|
||||||
|
|
||||||
def post_request_bytes(headers: bytes, body: bytes = b"") -> bytes:
|
|
||||||
return (
|
|
||||||
b"POST /v1/ingress HTTP/1.1\r\n"
|
|
||||||
b"Host: localhost\r\nConnection: close\r\n"
|
|
||||||
+ headers
|
|
||||||
+ b"\r\n"
|
|
||||||
+ body
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def wait_until_listening(process: subprocess.Popen[str], address: tuple[str, int]) -> None:
|
|
||||||
deadline = time.monotonic() + 5
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
if process.poll() is not None:
|
|
||||||
_, stderr = process.communicate()
|
|
||||||
pytest.fail(f"server exited before listening: {stderr}")
|
|
||||||
try:
|
|
||||||
with socket.create_connection(address, timeout=0.1):
|
|
||||||
return
|
|
||||||
except OSError:
|
|
||||||
time.sleep(0.05)
|
|
||||||
pytest.fail("server did not start listening")
|
|
||||||
|
|
||||||
|
|
||||||
def test_live_and_unconfigured_readiness_are_exact_and_bounded() -> None:
|
def test_live_and_unconfigured_readiness_are_exact_and_bounded() -> None:
|
||||||
with running_server() as address:
|
with running_server() as address:
|
||||||
live = request(address, "GET", "/health/live")
|
live = request(address, "GET", "/health/live")
|
||||||
@@ -174,45 +84,6 @@ def test_unknown_routes_and_wrong_methods_use_bounded_sanitized_codes() -> None:
|
|||||||
assert all(len(result[1]) <= 4096 for result in (missing, wrong_method, unsupported))
|
assert all(len(result[1]) <= 4096 for result in (missing, wrong_method, unsupported))
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("method", "expected_status"),
|
|
||||||
[
|
|
||||||
(b"METHOD_SECRET_REFLECTION_TOKEN", b"405"),
|
|
||||||
(b"METHOD_SECRET_REFLECTION_TOKEN" + b"X" * 66_000, b"414"),
|
|
||||||
],
|
|
||||||
ids=["arbitrary", "overlong"],
|
|
||||||
)
|
|
||||||
def test_arbitrary_and_overlong_methods_use_bounded_json_without_reflection(
|
|
||||||
method: bytes, expected_status: bytes
|
|
||||||
) -> None:
|
|
||||||
with running_server() as address:
|
|
||||||
response = raw_request(address, method)
|
|
||||||
|
|
||||||
headers, body = response.split(b"\r\n\r\n", 1)
|
|
||||||
status_line = headers.split(b"\r\n", 1)[0]
|
|
||||||
assert status_line.split(b" ", 2)[1] == expected_status
|
|
||||||
assert b"Content-Type: application/json" in headers
|
|
||||||
assert len(body) <= 4096
|
|
||||||
assert b"METHOD_SECRET_REFLECTION_TOKEN" not in response
|
|
||||||
assert b"<html" not in response.lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("invalid_status", [True, False, 99, 100, 101, 199, 204, 205, 304, 600, 999, "200", 200.0, None])
|
|
||||||
def test_invalid_processor_status_is_sanitized_before_writing_status_line(
|
|
||||||
invalid_status: object,
|
|
||||||
) -> None:
|
|
||||||
def processor(route: str, body: bytes) -> tuple[int, dict[str, object]]:
|
|
||||||
return invalid_status, {"status": "MUST_NOT_ESCAPE"} # type: ignore[return-value]
|
|
||||||
|
|
||||||
with running_server(processor) as address:
|
|
||||||
response = raw_request(address, b"POST", b"/v1/ingress")
|
|
||||||
|
|
||||||
headers, body = response.split(b"\r\n\r\n", 1)
|
|
||||||
assert headers.split(b"\r\n", 1)[0].startswith(b"HTTP/1.0 500 ")
|
|
||||||
assert body == b'{"status":"INTERNAL_ERROR"}'
|
|
||||||
assert b"MUST_NOT_ESCAPE" not in response
|
|
||||||
|
|
||||||
|
|
||||||
def test_processor_is_injected_and_exceptions_are_not_disclosed(capsys: pytest.CaptureFixture[str]) -> None:
|
def test_processor_is_injected_and_exceptions_are_not_disclosed(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
secret = "sender-secret-body-token"
|
secret = "sender-secret-body-token"
|
||||||
|
|
||||||
@@ -238,835 +109,11 @@ def test_processor_is_injected_and_exceptions_are_not_disclosed(capsys: pytest.C
|
|||||||
assert secret not in captured.err
|
assert secret not in captured.err
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("headers", "body"),
|
|
||||||
[
|
|
||||||
(b"Transfer-Encoding: secret-framing-token\r\n", b"secret-body"),
|
|
||||||
(b"Content-Length: 0\r\nContent-Length: 0\r\n", b""),
|
|
||||||
(b"Content-Length: 1\r\nContent-Length: 2\r\n", b"x"),
|
|
||||||
(b"Content-Length: invalid-secret-length\r\n", b""),
|
|
||||||
(b"Content-Length: -1\r\n", b""),
|
|
||||||
(f"Content-Length: {MAX_REQUEST_BYTES + 1}\r\n".encode(), b""),
|
|
||||||
(b"Content-Length: 0 \r\n", b""),
|
|
||||||
(b"Content-Length:\t0\r\n", b""),
|
|
||||||
],
|
|
||||||
ids=[
|
|
||||||
"transfer-encoding",
|
|
||||||
"duplicate-same",
|
|
||||||
"duplicate-conflicting",
|
|
||||||
"invalid",
|
|
||||||
"negative",
|
|
||||||
"oversize",
|
|
||||||
"trailing-ows",
|
|
||||||
"leading-ows",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_invalid_post_framing_is_bounded_and_never_calls_processor(
|
|
||||||
headers: bytes,
|
|
||||||
body: bytes,
|
|
||||||
capsys: pytest.CaptureFixture[str],
|
|
||||||
) -> None:
|
|
||||||
calls: list[bytes] = []
|
|
||||||
|
|
||||||
def processor(route: str, payload: bytes) -> tuple[int, dict[str, object]]:
|
|
||||||
calls.append(payload)
|
|
||||||
return 201, {"status": "MUTATED"}
|
|
||||||
|
|
||||||
with running_server(processor) as address:
|
|
||||||
response, timed_out = raw_exchange(
|
|
||||||
address,
|
|
||||||
post_request_bytes(headers, body),
|
|
||||||
)
|
|
||||||
|
|
||||||
status, response_headers, response_body = parse_raw_response(response)
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
assert not timed_out
|
|
||||||
assert 400 <= status <= 499
|
|
||||||
assert b"Content-Type: application/json" in response_headers
|
|
||||||
assert len(response_body) <= 4096
|
|
||||||
assert b"secret-framing-token" not in response
|
|
||||||
assert b"secret-body" not in response
|
|
||||||
assert b"invalid-secret-length" not in response
|
|
||||||
assert calls == []
|
|
||||||
assert "Traceback" not in captured.out
|
|
||||||
assert "Traceback" not in captured.err
|
|
||||||
|
|
||||||
|
|
||||||
def test_short_eof_body_is_rejected_before_processor() -> None:
|
|
||||||
calls: list[bytes] = []
|
|
||||||
|
|
||||||
def processor(route: str, payload: bytes) -> tuple[int, dict[str, object]]:
|
|
||||||
calls.append(payload)
|
|
||||||
return 201, {"status": "MUTATED"}
|
|
||||||
|
|
||||||
with running_server(processor) as address:
|
|
||||||
response, timed_out = raw_exchange(
|
|
||||||
address,
|
|
||||||
post_request_bytes(b"Content-Length: 10\r\n", b"abc"),
|
|
||||||
shutdown_write=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
status, _, body = parse_raw_response(response)
|
|
||||||
assert not timed_out
|
|
||||||
assert 400 <= status <= 499
|
|
||||||
assert len(body) <= 4096
|
|
||||||
assert calls == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_partial_body_timeout_is_bounded_and_rejected_before_processor() -> None:
|
|
||||||
processor_called = threading.Event()
|
|
||||||
|
|
||||||
def processor(route: str, payload: bytes) -> tuple[int, dict[str, object]]:
|
|
||||||
processor_called.set()
|
|
||||||
return 201, {"status": "MUTATED"}
|
|
||||||
|
|
||||||
with running_server(processor) as address:
|
|
||||||
started = time.monotonic()
|
|
||||||
response, timed_out = raw_exchange(
|
|
||||||
address,
|
|
||||||
post_request_bytes(b"Content-Length: 10\r\n", b"abc"),
|
|
||||||
receive_timeout=1.5,
|
|
||||||
)
|
|
||||||
elapsed = time.monotonic() - started
|
|
||||||
called_after_client_close = processor_called.wait(timeout=0.5)
|
|
||||||
|
|
||||||
status, _, body = parse_raw_response(response)
|
|
||||||
assert not timed_out
|
|
||||||
assert elapsed < 1.5
|
|
||||||
assert status == 408
|
|
||||||
assert len(body) <= 4096
|
|
||||||
assert not called_after_client_close
|
|
||||||
|
|
||||||
|
|
||||||
def test_server_close_drains_partial_body_handler_and_prevents_late_processor(
|
|
||||||
capsys: pytest.CaptureFixture[str],
|
|
||||||
) -> None:
|
|
||||||
processor_called = threading.Event()
|
|
||||||
|
|
||||||
def processor(route: str, payload: bytes) -> tuple[int, dict[str, object]]:
|
|
||||||
processor_called.set()
|
|
||||||
return 201, {"status": "MUTATED"}
|
|
||||||
|
|
||||||
server = build_server("127.0.0.1", 0, processor=processor)
|
|
||||||
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
||||||
server_thread.start()
|
|
||||||
host, port = server.server_address
|
|
||||||
client = socket.create_connection((str(host), int(port)), timeout=5)
|
|
||||||
try:
|
|
||||||
client.sendall(post_request_bytes(b"Content-Length: 10\r\n", b"abc"))
|
|
||||||
time.sleep(0.1)
|
|
||||||
started = time.monotonic()
|
|
||||||
server.shutdown()
|
|
||||||
server.server_close()
|
|
||||||
close_elapsed = time.monotonic() - started
|
|
||||||
finally:
|
|
||||||
client.close()
|
|
||||||
server_thread.join(timeout=5)
|
|
||||||
|
|
||||||
called_after_close = processor_called.wait(timeout=0.5)
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
assert server.daemon_threads is False
|
|
||||||
assert server.block_on_close is True
|
|
||||||
assert close_elapsed < 3
|
|
||||||
assert not server_thread.is_alive()
|
|
||||||
assert not called_after_close
|
|
||||||
assert "Traceback" not in captured.out
|
|
||||||
assert "Traceback" not in captured.err
|
|
||||||
assert "Fatal Python error" not in captured.err
|
|
||||||
restarted = build_server(str(host), int(port))
|
|
||||||
restarted.server_close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"target",
|
|
||||||
[
|
|
||||||
b"http://[::1/v1/ingress",
|
|
||||||
b"http://127.0.0.1:bad/v1/ingress",
|
|
||||||
b"relative-target",
|
|
||||||
b"/v1/ingress%ZZ",
|
|
||||||
],
|
|
||||||
ids=[
|
|
||||||
"invalid-ipv6-bracket",
|
|
||||||
"invalid-absolute-port",
|
|
||||||
"invalid-origin-form",
|
|
||||||
"invalid-percent-escape",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_malformed_request_targets_return_sanitized_400_without_processor(
|
|
||||||
target: bytes,
|
|
||||||
capsys: pytest.CaptureFixture[str],
|
|
||||||
) -> None:
|
|
||||||
calls: list[bytes] = []
|
|
||||||
|
|
||||||
def processor(route: str, payload: bytes) -> tuple[int, dict[str, object]]:
|
|
||||||
calls.append(payload)
|
|
||||||
return 201, {"status": "MUTATED"}
|
|
||||||
|
|
||||||
request_bytes = (
|
|
||||||
b"POST "
|
|
||||||
+ target
|
|
||||||
+ b" HTTP/1.1\r\nHost: localhost\r\n"
|
|
||||||
+ b"Content-Length: 0\r\nConnection: close\r\n\r\n"
|
|
||||||
)
|
|
||||||
with running_server(processor) as address:
|
|
||||||
response, timed_out = raw_exchange(address, request_bytes)
|
|
||||||
|
|
||||||
status, headers, body = parse_raw_response(response)
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
assert not timed_out
|
|
||||||
assert status == 400
|
|
||||||
assert b"Content-Type: application/json" in headers
|
|
||||||
assert len(body) <= 4096
|
|
||||||
assert target not in response
|
|
||||||
assert calls == []
|
|
||||||
assert "Traceback" not in captured.out
|
|
||||||
assert "Traceback" not in captured.err
|
|
||||||
|
|
||||||
|
|
||||||
def test_server_handle_error_never_prints_payload_traceback(
|
|
||||||
capsys: pytest.CaptureFixture[str],
|
|
||||||
) -> None:
|
|
||||||
secret = "unexpected-handler-secret"
|
|
||||||
|
|
||||||
class ExplodingHandler(IngressRequestHandler):
|
|
||||||
def do_GET(self) -> None:
|
|
||||||
raise RuntimeError(secret)
|
|
||||||
|
|
||||||
server = IngressHTTPServer(("127.0.0.1", 0), ExplodingHandler, None)
|
|
||||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
try:
|
|
||||||
host, port = server.server_address
|
|
||||||
raw_exchange(
|
|
||||||
(str(host), int(port)),
|
|
||||||
b"GET /health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
server.shutdown()
|
|
||||||
server.server_close()
|
|
||||||
thread.join(timeout=5)
|
|
||||||
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
assert secret not in captured.out
|
|
||||||
assert secret not in captured.err
|
|
||||||
assert "Traceback" not in captured.out
|
|
||||||
assert "Traceback" not in captured.err
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"malformed_header",
|
|
||||||
[
|
|
||||||
b"Bad Name: value\r\n",
|
|
||||||
b"Bad\tName: value\r\n",
|
|
||||||
b"Trailing-Name : value\r\n",
|
|
||||||
b" X-Leading: value\r\n",
|
|
||||||
b"\tX-Leading: value\r\n",
|
|
||||||
b":authority: localhost\r\n",
|
|
||||||
b"X-Obs-Fold: first\r\n continuation\r\n",
|
|
||||||
b"X-Obs-Fold: first\r\n\tcontinuation\r\n",
|
|
||||||
b"X-Control: value\x00secret\r\n",
|
|
||||||
b"X-Carriage: value\rsecret\r\n",
|
|
||||||
b"X-Bare-LF: value\n",
|
|
||||||
b"X-\xff: value\r\n",
|
|
||||||
],
|
|
||||||
ids=[
|
|
||||||
"space-in-name",
|
|
||||||
"tab-in-name",
|
|
||||||
"whitespace-before-colon",
|
|
||||||
"leading-space",
|
|
||||||
"leading-tab",
|
|
||||||
"pseudo-header",
|
|
||||||
"obs-fold-space",
|
|
||||||
"obs-fold-tab",
|
|
||||||
"nul-in-value",
|
|
||||||
"cr-in-value",
|
|
||||||
"bare-lf",
|
|
||||||
"non-ascii-name",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_malformed_raw_header_grammar_is_rejected_before_processor(
|
|
||||||
malformed_header: bytes,
|
|
||||||
capsys: pytest.CaptureFixture[str],
|
|
||||||
) -> None:
|
|
||||||
calls: list[bytes] = []
|
|
||||||
|
|
||||||
def processor(route: str, payload: bytes) -> tuple[int, dict[str, object]]:
|
|
||||||
calls.append(payload)
|
|
||||||
return 201, {"status": "MUTATED"}
|
|
||||||
|
|
||||||
request_bytes = (
|
|
||||||
b"POST /v1/ingress HTTP/1.1\r\n"
|
|
||||||
b"Host: localhost\r\n"
|
|
||||||
+ malformed_header
|
|
||||||
+ b"Content-Length: 0\r\nConnection: close\r\n\r\n"
|
|
||||||
)
|
|
||||||
with running_server(processor) as address:
|
|
||||||
response, timed_out = raw_exchange(address, request_bytes)
|
|
||||||
|
|
||||||
assert not timed_out
|
|
||||||
assert response
|
|
||||||
status, headers, body = parse_raw_response(response)
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
assert status == 400
|
|
||||||
assert b"Content-Type: application/json" in headers
|
|
||||||
assert json.loads(body) == {"status": "BAD_REQUEST"}
|
|
||||||
assert len(body) <= MAX_RESPONSE_BYTES
|
|
||||||
assert b"secret" not in response
|
|
||||||
assert calls == []
|
|
||||||
assert "Traceback" not in captured.out
|
|
||||||
assert "Traceback" not in captured.err
|
|
||||||
|
|
||||||
|
|
||||||
def trickle_request_until_response(
|
|
||||||
address: tuple[str, int],
|
|
||||||
prefix: bytes,
|
|
||||||
*,
|
|
||||||
receive_timeout: float = 1.8,
|
|
||||||
) -> tuple[bytes, bool, float]:
|
|
||||||
connection = socket.create_connection(address, timeout=5)
|
|
||||||
stop = threading.Event()
|
|
||||||
|
|
||||||
def sender() -> None:
|
|
||||||
while not stop.wait(0.1):
|
|
||||||
try:
|
|
||||||
connection.sendall(b"x")
|
|
||||||
except OSError:
|
|
||||||
return
|
|
||||||
|
|
||||||
connection.sendall(prefix)
|
|
||||||
sender_thread = threading.Thread(target=sender, daemon=True)
|
|
||||||
sender_thread.start()
|
|
||||||
connection.settimeout(receive_timeout)
|
|
||||||
chunks: list[bytes] = []
|
|
||||||
timed_out = False
|
|
||||||
started = time.monotonic()
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
chunk = connection.recv(8192)
|
|
||||||
except TimeoutError:
|
|
||||||
timed_out = True
|
|
||||||
break
|
|
||||||
except ConnectionResetError:
|
|
||||||
break
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
chunks.append(chunk)
|
|
||||||
finally:
|
|
||||||
elapsed = time.monotonic() - started
|
|
||||||
stop.set()
|
|
||||||
try:
|
|
||||||
connection.shutdown(socket.SHUT_RDWR)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
connection.close()
|
|
||||||
sender_thread.join(timeout=1)
|
|
||||||
return b"".join(chunks), timed_out, elapsed
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"prefix",
|
|
||||||
[
|
|
||||||
(
|
|
||||||
b"POST /v1/ingress HTTP/1.1\r\n"
|
|
||||||
b"Host: localhost\r\nX-Slow-Header: "
|
|
||||||
),
|
|
||||||
(
|
|
||||||
b"POST /v1/ingress HTTP/1.1\r\n"
|
|
||||||
b"Host: localhost\r\nContent-Length: 1000\r\n"
|
|
||||||
b"Connection: close\r\n\r\nx"
|
|
||||||
),
|
|
||||||
],
|
|
||||||
ids=["slow-header", "slow-body"],
|
|
||||||
)
|
|
||||||
def test_absolute_network_deadline_cannot_be_reset_by_slow_trickle(
|
|
||||||
prefix: bytes,
|
|
||||||
) -> None:
|
|
||||||
processor_called = threading.Event()
|
|
||||||
|
|
||||||
def processor(route: str, payload: bytes) -> tuple[int, dict[str, object]]:
|
|
||||||
processor_called.set()
|
|
||||||
return 201, {"status": "MUTATED"}
|
|
||||||
|
|
||||||
with running_server(processor) as address:
|
|
||||||
response, timed_out, elapsed = trickle_request_until_response(address, prefix)
|
|
||||||
called_after_cleanup = processor_called.wait(timeout=0.4)
|
|
||||||
|
|
||||||
assert not timed_out
|
|
||||||
assert elapsed < 1.8
|
|
||||||
assert response
|
|
||||||
status, _, body = parse_raw_response(response)
|
|
||||||
assert status == 408
|
|
||||||
assert json.loads(body) == {"status": "REQUEST_TIMEOUT"}
|
|
||||||
assert len(body) <= MAX_RESPONSE_BYTES
|
|
||||||
assert not called_after_cleanup
|
|
||||||
|
|
||||||
|
|
||||||
def test_shutdown_drains_several_slow_clients_without_late_work_or_threads(
|
|
||||||
capsys: pytest.CaptureFixture[str],
|
|
||||||
) -> None:
|
|
||||||
processor_called = threading.Event()
|
|
||||||
|
|
||||||
def processor(route: str, payload: bytes) -> tuple[int, dict[str, object]]:
|
|
||||||
processor_called.set()
|
|
||||||
return 201, {"status": "MUTATED"}
|
|
||||||
|
|
||||||
before_non_daemon = {id(thread) for thread in threading.enumerate() if not thread.daemon}
|
|
||||||
server = build_server("127.0.0.1", 0, processor=processor)
|
|
||||||
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
||||||
server_thread.start()
|
|
||||||
host, port = server.server_address
|
|
||||||
clients: list[socket.socket] = []
|
|
||||||
stops: list[threading.Event] = []
|
|
||||||
senders: list[threading.Thread] = []
|
|
||||||
|
|
||||||
def start_client(prefix: bytes) -> None:
|
|
||||||
client = socket.create_connection((str(host), int(port)), timeout=5)
|
|
||||||
client.sendall(prefix)
|
|
||||||
stop = threading.Event()
|
|
||||||
|
|
||||||
def sender() -> None:
|
|
||||||
while not stop.wait(0.1):
|
|
||||||
try:
|
|
||||||
client.sendall(b"x")
|
|
||||||
except OSError:
|
|
||||||
return
|
|
||||||
|
|
||||||
sender_thread = threading.Thread(target=sender, daemon=True)
|
|
||||||
sender_thread.start()
|
|
||||||
clients.append(client)
|
|
||||||
stops.append(stop)
|
|
||||||
senders.append(sender_thread)
|
|
||||||
|
|
||||||
header_prefix = (
|
|
||||||
b"POST /v1/ingress HTTP/1.1\r\nHost: localhost\r\nX-Slow: "
|
|
||||||
)
|
|
||||||
body_prefix = (
|
|
||||||
b"POST /v1/ingress HTTP/1.1\r\nHost: localhost\r\n"
|
|
||||||
b"Content-Length: 1000\r\nConnection: close\r\n\r\nx"
|
|
||||||
)
|
|
||||||
for index in range(6):
|
|
||||||
start_client(header_prefix if index % 2 == 0 else body_prefix)
|
|
||||||
time.sleep(0.15)
|
|
||||||
|
|
||||||
closed = threading.Event()
|
|
||||||
close_elapsed: list[float] = []
|
|
||||||
|
|
||||||
def close_server() -> None:
|
|
||||||
started = time.monotonic()
|
|
||||||
try:
|
|
||||||
server.shutdown()
|
|
||||||
server.server_close()
|
|
||||||
finally:
|
|
||||||
close_elapsed.append(time.monotonic() - started)
|
|
||||||
closed.set()
|
|
||||||
|
|
||||||
closer = threading.Thread(target=close_server)
|
|
||||||
closer.start()
|
|
||||||
closed_within_bound = closed.wait(timeout=2.5)
|
|
||||||
for stop in stops:
|
|
||||||
stop.set()
|
|
||||||
for client in clients:
|
|
||||||
try:
|
|
||||||
client.shutdown(socket.SHUT_RDWR)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
client.close()
|
|
||||||
for sender_thread in senders:
|
|
||||||
sender_thread.join(timeout=1)
|
|
||||||
closer.join(timeout=5)
|
|
||||||
server_thread.join(timeout=5)
|
|
||||||
|
|
||||||
called_after_close = processor_called.wait(timeout=0.4)
|
|
||||||
deadline = time.monotonic() + 1
|
|
||||||
leaked_non_daemon: list[threading.Thread] = []
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
leaked_non_daemon = [
|
|
||||||
thread
|
|
||||||
for thread in threading.enumerate()
|
|
||||||
if not thread.daemon
|
|
||||||
and id(thread) not in before_non_daemon
|
|
||||||
and thread.is_alive()
|
|
||||||
]
|
|
||||||
if not leaked_non_daemon:
|
|
||||||
break
|
|
||||||
time.sleep(0.02)
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
assert closed_within_bound
|
|
||||||
assert close_elapsed and close_elapsed[0] < 2.5
|
|
||||||
assert not server_thread.is_alive()
|
|
||||||
assert not closer.is_alive()
|
|
||||||
assert not leaked_non_daemon
|
|
||||||
assert not called_after_close
|
|
||||||
assert "Traceback" not in captured.out
|
|
||||||
assert "Traceback" not in captured.err
|
|
||||||
assert "Fatal Python error" not in captured.err
|
|
||||||
restarted = build_server(str(host), int(port))
|
|
||||||
restarted.server_close()
|
|
||||||
|
|
||||||
|
|
||||||
def test_completed_framing_cancels_deadline_before_slow_processor() -> None:
|
|
||||||
def processor(route: str, payload: bytes) -> tuple[int, dict[str, object]]:
|
|
||||||
time.sleep(1.2)
|
|
||||||
return 201, {"status": "MUTATED"}
|
|
||||||
|
|
||||||
with running_server(processor) as address:
|
|
||||||
status, body, _ = request(address, "POST", "/v1/ingress", b"")
|
|
||||||
|
|
||||||
assert status == 201
|
|
||||||
assert json.loads(body) == {"status": "MUTATED"}
|
|
||||||
assert not [
|
|
||||||
thread
|
|
||||||
for thread in threading.enumerate()
|
|
||||||
if thread.name.startswith("aios-ingress-read-deadline")
|
|
||||||
and thread.is_alive()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"payload_factory",
|
|
||||||
[
|
|
||||||
pytest.param(lambda: [], id="list"),
|
|
||||||
pytest.param(lambda: "not-an-object", id="string"),
|
|
||||||
pytest.param(lambda: None, id="null"),
|
|
||||||
pytest.param(lambda: {"value": float("nan")}, id="nan"),
|
|
||||||
pytest.param(lambda: {"value": float("inf")}, id="infinity"),
|
|
||||||
pytest.param(lambda: {"value": float("-inf")}, id="negative-infinity"),
|
|
||||||
pytest.param(lambda: {"value": object()}, id="nonserializable"),
|
|
||||||
pytest.param(
|
|
||||||
lambda: {"value": "secret-oversize-" + "x" * MAX_RESPONSE_BYTES},
|
|
||||||
id="oversize",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_invalid_processor_payload_is_fixed_bounded_json_500(
|
|
||||||
payload_factory: Callable[[], object],
|
|
||||||
capsys: pytest.CaptureFixture[str],
|
|
||||||
) -> None:
|
|
||||||
payload = payload_factory()
|
|
||||||
|
|
||||||
def processor(route: str, body: bytes) -> tuple[int, dict[str, object]]:
|
|
||||||
return 201, payload # type: ignore[return-value]
|
|
||||||
|
|
||||||
with running_server(processor) as address:
|
|
||||||
response = raw_request(address, b"POST", b"/v1/ingress")
|
|
||||||
|
|
||||||
status, headers, body = parse_raw_response(response)
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
assert status == 500
|
|
||||||
assert b"Content-Type: application/json" in headers
|
|
||||||
assert body == b'{"status":"INTERNAL_ERROR"}'
|
|
||||||
assert json.loads(body) == {"status": "INTERNAL_ERROR"}
|
|
||||||
assert len(body) <= MAX_RESPONSE_BYTES
|
|
||||||
assert b"secret-oversize" not in response
|
|
||||||
assert "Traceback" not in captured.out
|
|
||||||
assert "Traceback" not in captured.err
|
|
||||||
|
|
||||||
|
|
||||||
def test_recursive_processor_payload_uses_nonrecursive_fixed_500() -> None:
|
|
||||||
recursive: dict[str, object] = {}
|
|
||||||
recursive["self"] = recursive
|
|
||||||
|
|
||||||
def processor(route: str, body: bytes) -> tuple[int, dict[str, object]]:
|
|
||||||
return 201, recursive
|
|
||||||
|
|
||||||
with running_server(processor) as address:
|
|
||||||
status, body, _ = request(address, "POST", "/v1/ingress", b"")
|
|
||||||
|
|
||||||
assert status == 500
|
|
||||||
assert body == b'{"status":"INTERNAL_ERROR"}'
|
|
||||||
assert json.loads(body) == {"status": "INTERNAL_ERROR"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_server_rejects_non_loopback_bind() -> None:
|
def test_server_rejects_non_loopback_bind() -> None:
|
||||||
with pytest.raises(ValueError, match="loopback"):
|
with pytest.raises(ValueError, match="loopback"):
|
||||||
build_server("0.0.0.0", 0)
|
build_server("0.0.0.0", 0)
|
||||||
|
|
||||||
|
|
||||||
def test_af_inet_server_rejects_ipv6_loopback_explicitly() -> None:
|
|
||||||
with pytest.raises(ValueError, match="IPv4 loopback"):
|
|
||||||
build_server("::1", 0)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def run_pre_header_sigint_probe(
|
|
||||||
timeout: float = 1.2,
|
|
||||||
) -> tuple[int, str, str, float, bool, int]:
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as reservation:
|
|
||||||
reservation.bind(("127.0.0.1", 0))
|
|
||||||
port = int(reservation.getsockname()[1])
|
|
||||||
|
|
||||||
script = textwrap.dedent(
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
import socket
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
|
|
||||||
from aios_v2r1.ingress_service import (
|
|
||||||
IngressHTTPServer,
|
|
||||||
IngressRequestHandler,
|
|
||||||
)
|
|
||||||
|
|
||||||
accepted_before_setup = threading.Event()
|
|
||||||
|
|
||||||
class PreHeaderReadinessHandler(IngressRequestHandler):
|
|
||||||
def setup(self):
|
|
||||||
accepted_before_setup.set()
|
|
||||||
try:
|
|
||||||
self.request.recv(1)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
super().setup()
|
|
||||||
|
|
||||||
server = IngressHTTPServer(
|
|
||||||
("127.0.0.1", int(sys.argv[1])),
|
|
||||||
PreHeaderReadinessHandler,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
def connect_and_interrupt():
|
|
||||||
client = socket.create_connection(server.server_address, timeout=2)
|
|
||||||
if not accepted_before_setup.wait(timeout=2):
|
|
||||||
os._exit(71)
|
|
||||||
os.kill(os.getpid(), signal.SIGINT)
|
|
||||||
time.sleep(10)
|
|
||||||
client.close()
|
|
||||||
|
|
||||||
trigger = threading.Thread(target=connect_and_interrupt, daemon=True)
|
|
||||||
trigger.start()
|
|
||||||
try:
|
|
||||||
server.serve_forever(poll_interval=0.01)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
server.server_close()
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
env = os.environ.copy()
|
|
||||||
package_root = str(Path(__file__).resolve().parents[2] / "active-runtime" / "app")
|
|
||||||
env["PYTHONPATH"] = package_root + os.pathsep + env.get("PYTHONPATH", "")
|
|
||||||
process = subprocess.Popen(
|
|
||||||
[sys.executable, "-c", script, str(port)],
|
|
||||||
env=env,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
started = time.monotonic()
|
|
||||||
timed_out = False
|
|
||||||
try:
|
|
||||||
stdout, stderr = process.communicate(timeout=timeout)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
timed_out = True
|
|
||||||
process.kill()
|
|
||||||
stdout, stderr = process.communicate(timeout=2)
|
|
||||||
return (
|
|
||||||
int(process.returncode),
|
|
||||||
stdout,
|
|
||||||
stderr,
|
|
||||||
time.monotonic() - started,
|
|
||||||
timed_out,
|
|
||||||
port,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_sigint_closes_pre_header_readiness_socket_in_25_bounded_runs() -> None:
|
|
||||||
for iteration in range(25):
|
|
||||||
returncode, stdout, stderr, elapsed, timed_out, port = (
|
|
||||||
run_pre_header_sigint_probe()
|
|
||||||
)
|
|
||||||
assert not timed_out, f"pre-header SIGINT hung on iteration {iteration}"
|
|
||||||
assert elapsed < 1.2
|
|
||||||
assert returncode == 0
|
|
||||||
assert "Traceback" not in stdout
|
|
||||||
assert "Traceback" not in stderr
|
|
||||||
assert "Fatal Python error" not in stderr
|
|
||||||
restarted = build_server("127.0.0.1", port)
|
|
||||||
restarted.server_close()
|
|
||||||
|
|
||||||
|
|
||||||
def test_server_close_closes_socket_accepted_before_handler_setup() -> None:
|
|
||||||
server = build_server("127.0.0.1", 0)
|
|
||||||
client = socket.create_connection(server.server_address, timeout=2)
|
|
||||||
accepted, _ = server.get_request()
|
|
||||||
client.settimeout(0.4)
|
|
||||||
try:
|
|
||||||
server.server_close()
|
|
||||||
server.server_close()
|
|
||||||
try:
|
|
||||||
remaining = client.recv(1)
|
|
||||||
except ConnectionResetError:
|
|
||||||
remaining = b""
|
|
||||||
assert remaining == b""
|
|
||||||
finally:
|
|
||||||
accepted.close()
|
|
||||||
client.close()
|
|
||||||
server.server_close()
|
|
||||||
|
|
||||||
|
|
||||||
def test_process_request_rejects_socket_after_server_closing() -> None:
|
|
||||||
handler_started = threading.Event()
|
|
||||||
|
|
||||||
class RecordingHandler:
|
|
||||||
def __init__(self, *args: object) -> None:
|
|
||||||
handler_started.set()
|
|
||||||
|
|
||||||
server = IngressHTTPServer(
|
|
||||||
("127.0.0.1", 0),
|
|
||||||
RecordingHandler, # type: ignore[arg-type]
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
server.server_close()
|
|
||||||
request_socket, peer = socket.socketpair()
|
|
||||||
peer.settimeout(0.4)
|
|
||||||
try:
|
|
||||||
server.process_request(request_socket, ("127.0.0.1", 1))
|
|
||||||
try:
|
|
||||||
remaining = peer.recv(1)
|
|
||||||
except ConnectionResetError:
|
|
||||||
remaining = b""
|
|
||||||
assert remaining == b""
|
|
||||||
assert not handler_started.wait(timeout=0.1)
|
|
||||||
finally:
|
|
||||||
request_socket.close()
|
|
||||||
peer.close()
|
|
||||||
server.server_close()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def test_accept_and_server_close_race_is_bounded_in_25_runs() -> None:
|
|
||||||
errors: list[BaseException] = []
|
|
||||||
for _ in range(25):
|
|
||||||
class RecordingHandler:
|
|
||||||
def __init__(self, *args: object) -> None:
|
|
||||||
return
|
|
||||||
|
|
||||||
server = IngressHTTPServer(
|
|
||||||
("127.0.0.1", 0),
|
|
||||||
RecordingHandler, # type: ignore[arg-type]
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
client = socket.create_connection(server.server_address, timeout=2)
|
|
||||||
barrier = threading.Barrier(3)
|
|
||||||
|
|
||||||
def accept_and_process() -> None:
|
|
||||||
try:
|
|
||||||
barrier.wait(timeout=1)
|
|
||||||
request_socket, client_address = server.get_request()
|
|
||||||
server.process_request(request_socket, client_address)
|
|
||||||
except OSError:
|
|
||||||
return
|
|
||||||
except BaseException as exc:
|
|
||||||
errors.append(exc)
|
|
||||||
|
|
||||||
def close_during_accept() -> None:
|
|
||||||
try:
|
|
||||||
barrier.wait(timeout=1)
|
|
||||||
server.server_close()
|
|
||||||
except BaseException as exc:
|
|
||||||
errors.append(exc)
|
|
||||||
|
|
||||||
accepter = threading.Thread(target=accept_and_process)
|
|
||||||
closer = threading.Thread(target=close_during_accept)
|
|
||||||
accepter.start()
|
|
||||||
closer.start()
|
|
||||||
barrier.wait(timeout=1)
|
|
||||||
accepter.join(timeout=1)
|
|
||||||
closer.join(timeout=1)
|
|
||||||
client.close()
|
|
||||||
server.server_close()
|
|
||||||
assert not accepter.is_alive()
|
|
||||||
assert not closer.is_alive()
|
|
||||||
assert not errors
|
|
||||||
|
|
||||||
def test_server_close_is_idempotent_with_idle_and_partial_clients(
|
|
||||||
capsys: pytest.CaptureFixture[str],
|
|
||||||
) -> None:
|
|
||||||
server = build_server("127.0.0.1", 0)
|
|
||||||
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
||||||
server_thread.start()
|
|
||||||
clients: list[socket.socket] = []
|
|
||||||
payloads = [
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
b"GET /health/live HTTP/1.1\r\nHost: localhost\r\n",
|
|
||||||
b"POST /v1/ingress HTTP/1.1\r\nContent-Length: 10\r\n\r\nx",
|
|
||||||
]
|
|
||||||
for payload in payloads:
|
|
||||||
client = socket.create_connection(server.server_address, timeout=2)
|
|
||||||
if payload is not None:
|
|
||||||
client.sendall(payload)
|
|
||||||
clients.append(client)
|
|
||||||
time.sleep(0.05)
|
|
||||||
server.shutdown()
|
|
||||||
|
|
||||||
errors: list[BaseException] = []
|
|
||||||
|
|
||||||
def close_server() -> None:
|
|
||||||
try:
|
|
||||||
server.server_close()
|
|
||||||
except BaseException as exc:
|
|
||||||
errors.append(exc)
|
|
||||||
|
|
||||||
closers = [threading.Thread(target=close_server) for _ in range(4)]
|
|
||||||
for closer in closers:
|
|
||||||
closer.start()
|
|
||||||
for closer in closers:
|
|
||||||
closer.join(timeout=2)
|
|
||||||
server_thread.join(timeout=2)
|
|
||||||
for client in clients:
|
|
||||||
client.close()
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
assert not errors
|
|
||||||
assert not [closer for closer in closers if closer.is_alive()]
|
|
||||||
assert not server_thread.is_alive()
|
|
||||||
assert "Traceback" not in captured.out
|
|
||||||
assert "Traceback" not in captured.err
|
|
||||||
assert "Fatal Python error" not in captured.err
|
|
||||||
|
|
||||||
def test_sigint_shutdown_exits_zero_without_traceback_and_releases_port() -> None:
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as reservation:
|
|
||||||
reservation.bind(("127.0.0.1", 0))
|
|
||||||
port = int(reservation.getsockname()[1])
|
|
||||||
|
|
||||||
env = os.environ.copy()
|
|
||||||
package_root = str(Path(__file__).resolve().parents[2] / "active-runtime" / "app")
|
|
||||||
env["PYTHONPATH"] = package_root + os.pathsep + env.get("PYTHONPATH", "")
|
|
||||||
process = subprocess.Popen(
|
|
||||||
[
|
|
||||||
sys.executable,
|
|
||||||
"-m",
|
|
||||||
"aios_v2r1.cli",
|
|
||||||
"serve",
|
|
||||||
"--host",
|
|
||||||
"127.0.0.1",
|
|
||||||
"--port",
|
|
||||||
str(port),
|
|
||||||
],
|
|
||||||
env=env,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
wait_until_listening(process, ("127.0.0.1", port))
|
|
||||||
process.send_signal(signal.SIGINT)
|
|
||||||
stdout, stderr = process.communicate(timeout=5)
|
|
||||||
finally:
|
|
||||||
if process.poll() is None:
|
|
||||||
process.kill()
|
|
||||||
process.communicate()
|
|
||||||
|
|
||||||
assert process.returncode == 0
|
|
||||||
assert "Traceback" not in stdout
|
|
||||||
assert "Traceback" not in stderr
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as rebound:
|
|
||||||
rebound.bind(("127.0.0.1", port))
|
|
||||||
|
|
||||||
|
|
||||||
def test_cli_preserves_registry_commands_and_dispatches_top_level_serve(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_cli_preserves_registry_commands_and_dispatches_top_level_serve(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
parsed = cli.build_parser().parse_args(["registry.sqlite", "status", "tenant-a"])
|
parsed = cli.build_parser().parse_args(["registry.sqlite", "status", "tenant-a"])
|
||||||
assert parsed.registry == "registry.sqlite"
|
assert parsed.registry == "registry.sqlite"
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
"""Executable completeness contract for memory namespaces and access."""
|
|
||||||
from __future__ import annotations
|
|
||||||
import pytest
|
|
||||||
from aios_v2r1.memory_scope import MemoryScope, qualify_memory_key
|
|
||||||
from aios_v2r1.tenant_context import ScopeError, ValidationError
|
|
||||||
|
|
||||||
def scope(kind: str = "session", **changes: object) -> MemoryScope:
|
|
||||||
values: dict[str, object] = {
|
|
||||||
"tenant_id": "tenant-a", "cell_id": "cell-a", "profile_id": "profile-a",
|
|
||||||
"session_id": "session-a", "kind": kind}
|
|
||||||
values.update(changes)
|
|
||||||
return MemoryScope(**values) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(("kind", "session_id", "expected"), [
|
|
||||||
("session", "session-a", "tenant/tenant-a/cell/cell-a/profile/profile-a/session/session-a/memory/session"),
|
|
||||||
("profile", None, "tenant/tenant-a/cell/cell-a/profile/profile-a/memory/profile"),
|
|
||||||
("cell", None, "tenant/tenant-a/cell/cell-a/memory/cell"),
|
|
||||||
("tenant", None, "tenant/tenant-a/cell/cell-a/memory/tenant"),
|
|
||||||
("shared_artifact", None, "tenant/tenant-a/cell/cell-a/memory/shared_artifact")])
|
|
||||||
def test_every_namespace_is_explicit(kind: str, session_id: str | None, expected: str) -> None:
|
|
||||||
assert scope(kind, session_id=session_id).namespace == expected
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("requested", [
|
|
||||||
scope(tenant_id="tenant-b"), scope(cell_id="cell-b"),
|
|
||||||
scope(profile_id="profile-b"), scope(session_id="session-b")])
|
|
||||||
def test_cross_scope_access_is_denied(requested: MemoryScope) -> None:
|
|
||||||
with pytest.raises(ScopeError):
|
|
||||||
scope().assert_access(requested)
|
|
||||||
|
|
||||||
def test_cross_kind_access_is_denied() -> None:
|
|
||||||
with pytest.raises(ScopeError, match="cross-kind"):
|
|
||||||
scope("session").assert_access(scope("profile", session_id=None))
|
|
||||||
|
|
||||||
def test_shared_artifacts_are_read_only() -> None:
|
|
||||||
owner = scope()
|
|
||||||
shared = scope("shared_artifact", session_id=None)
|
|
||||||
owner.assert_access(shared)
|
|
||||||
with pytest.raises(ScopeError, match="immutable"):
|
|
||||||
owner.assert_access(shared, write=True)
|
|
||||||
|
|
||||||
def test_qualified_key_contains_the_full_namespace() -> None:
|
|
||||||
assert qualify_memory_key(scope(), "facts.v1") == (
|
|
||||||
"tenant/tenant-a/cell/cell-a/profile/profile-a/session/session-a/"
|
|
||||||
"memory/session/facts.v1")
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("key", ["../escape", "a/b", ".", "..", " space ", "x?query"])
|
|
||||||
def test_memory_key_traversal_and_malformed_keys_are_rejected(key: str) -> None:
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
qualify_memory_key(scope(), key)
|
|
||||||
|
|
||||||
def test_memory_search_surface_is_disabled() -> None:
|
|
||||||
assert not hasattr(scope(), "search")
|
|
||||||
assert not hasattr(scope(), "search_all")
|
|
||||||
@@ -1,120 +1,73 @@
|
|||||||
"""Executable completeness contract for the exact-ID message ledger."""
|
"""Contract tests for the runtime-facing direct MessageLedger append path."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from dataclasses import replace
|
|
||||||
from pathlib import Path
|
|
||||||
import sqlite3
|
|
||||||
import pytest
|
|
||||||
from aios_v2r1.message_ledger import MessageLedger
|
|
||||||
from aios_v2r1.tenant_context import CanonicalMessage, ConflictError, ScopeError
|
|
||||||
|
|
||||||
def message(**changes: object) -> CanonicalMessage:
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
value = CanonicalMessage(
|
from tempfile import TemporaryDirectory
|
||||||
tenant_id="tenant-a", cell_id="cell-a", principal_id="user-a",
|
import unittest
|
||||||
|
|
||||||
|
from aios_v2r1.message_ledger import MessageLedger
|
||||||
|
from aios_v2r1.tenant_context import CanonicalMessage, ConflictError
|
||||||
|
|
||||||
|
|
||||||
|
def message(*, tenant_id: str = "tenant-a", cell_id: str = "cell-a",
|
||||||
|
event_id: str = "event-a", message_id: str = "message-a",
|
||||||
|
body: str = "hello", reply_to_message_id: str | None = None) -> CanonicalMessage:
|
||||||
|
return CanonicalMessage(
|
||||||
|
tenant_id=tenant_id, cell_id=cell_id, principal_id="user-a",
|
||||||
principal_type="authenticated_user", origin_type="channel_user",
|
principal_type="authenticated_user", origin_type="channel_user",
|
||||||
channel="weixin", channel_account_id="account-a", peer_id="peer-a",
|
channel="weixin", channel_account_id="account-a", peer_id="peer-a",
|
||||||
conversation_id="conversation-a", session_id="session-a", task_id=None,
|
conversation_id="conversation-a", session_id="session-a", task_id=None,
|
||||||
event_id="event-a", message_id="message-a", reply_to_message_id=None,
|
event_id=event_id, message_id=message_id, reply_to_message_id=reply_to_message_id,
|
||||||
direction="inbound", command_capable=True,
|
direction="inbound", command_capable=True,
|
||||||
occurred_at="2026-07-13T00:00:00+00:00",
|
occurred_at="2026-07-13T00:00:00+00:00",
|
||||||
received_at="2026-07-13T00:00:01+00:00", body="hello",
|
received_at="2026-07-13T00:00:01+00:00", body=body,
|
||||||
quoted_body=None, adapter_name="weixin", adapter_version="v2")
|
quoted_body=None, adapter_name="weixin", adapter_version="v2",
|
||||||
return replace(value, **changes)
|
)
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def ledger(tmp_path: Path) -> MessageLedger:
|
|
||||||
return MessageLedger(tmp_path / "ledger.sqlite3")
|
|
||||||
|
|
||||||
def test_append_writes_directly_without_using_remember(ledger: MessageLedger) -> None:
|
class MessageLedgerAppendContractTests(unittest.TestCase):
|
||||||
ledger.remember = lambda _: (_ for _ in ()).throw(AssertionError("remember called")) # type: ignore[method-assign]
|
def setUp(self) -> None:
|
||||||
assert ledger.append(message()).message_id == "message-a"
|
self.directory = TemporaryDirectory()
|
||||||
assert ledger.count() == 1
|
self.addCleanup(self.directory.cleanup)
|
||||||
|
self.ledger = MessageLedger(f"{self.directory.name}/ledger.sqlite3")
|
||||||
|
|
||||||
def test_append_rejects_same_message_id_with_different_content(ledger: MessageLedger) -> None:
|
def test_append_writes_directly_without_using_remember(self) -> None:
|
||||||
ledger.append(message(body="first"))
|
# A compatibility wrapper may remain, but the runtime append path may not
|
||||||
with pytest.raises(ConflictError):
|
# route through it: this catches append() -> remember() regressions.
|
||||||
ledger.append(message(event_id="event-b", body="second"))
|
self.ledger.remember = lambda _: (_ for _ in ()).throw(AssertionError("remember called")) # type: ignore[method-assign]
|
||||||
assert ledger.count() == 1
|
record = self.ledger.append(message())
|
||||||
|
self.assertEqual("message-a", record.message_id)
|
||||||
|
self.assertEqual(1, self.ledger.count())
|
||||||
|
|
||||||
def test_append_is_idempotent_when_only_event_id_changes(ledger: MessageLedger) -> None:
|
def test_append_rejects_same_message_id_with_different_content(self) -> None:
|
||||||
stored = ledger.append(message(event_id="event-a", body="same body"))
|
self.ledger.append(message(body="first"))
|
||||||
replay = ledger.append(message(event_id="event-b", body="same body"))
|
with self.assertRaises(ConflictError):
|
||||||
assert replay == stored
|
self.ledger.append(message(event_id="event-b", body="second"))
|
||||||
assert replay.event_id == "event-a"
|
self.assertEqual(1, self.ledger.count())
|
||||||
assert ledger.count() == 1
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("candidate", ["message", "message-a-extra", "MESSAGE-A"])
|
def test_append_is_idempotent_when_only_event_id_changes(self) -> None:
|
||||||
def test_quote_lookup_has_no_prefix_similarity_or_case_fallback(
|
stored = self.ledger.append(message(event_id="event-a", body="same body"))
|
||||||
ledger: MessageLedger, candidate: str
|
replay = self.ledger.append(message(event_id="event-b", body="same body"))
|
||||||
) -> None:
|
|
||||||
ledger.append(message(message_id="message-a"))
|
|
||||||
with pytest.raises(ScopeError, match="quoted_text_unavailable"):
|
|
||||||
ledger.resolve_quote(message(event_id="reply-event", message_id="reply-message",
|
|
||||||
reply_to_message_id=candidate))
|
|
||||||
|
|
||||||
def test_explicit_quoted_body_never_falls_back_to_storage(ledger: MessageLedger) -> None:
|
self.assertEqual(stored, replay)
|
||||||
assert ledger.resolve_quote(message(quoted_body="exact transport quote")) == "exact transport quote"
|
self.assertEqual("event-a", replay.event_id)
|
||||||
assert ledger.count() == 0
|
self.assertEqual(1, self.ledger.count())
|
||||||
|
|
||||||
@pytest.mark.parametrize("scope_change", [{"tenant_id": "tenant-b"}, {"cell_id": "cell-b"}])
|
def test_append_keeps_tenants_isolated(self) -> None:
|
||||||
def test_quote_lookup_is_tenant_and_cell_isolated(
|
self.ledger.append(message())
|
||||||
ledger: MessageLedger, scope_change: dict[str, str]
|
self.ledger.append(message(tenant_id="tenant-b", cell_id="cell-b"))
|
||||||
) -> None:
|
self.assertEqual(2, self.ledger.count())
|
||||||
ledger.append(message())
|
self.assertEqual("hello", self.ledger.resolve_quote(
|
||||||
with pytest.raises(ScopeError, match="quoted_text_unavailable"):
|
message(tenant_id="tenant-b", cell_id="cell-b", event_id="event-c",
|
||||||
ledger.resolve_quote(message(**scope_change, event_id="reply-event",
|
message_id="message-c", body="reply", reply_to_message_id="message-a")
|
||||||
message_id="reply-message",
|
))
|
||||||
reply_to_message_id="message-a"))
|
|
||||||
|
|
||||||
def test_same_id_across_directions_is_ambiguous_not_guessed(ledger: MessageLedger) -> None:
|
def test_concurrent_identical_append_is_one_record(self) -> None:
|
||||||
ledger.append(message(direction="inbound", body="in"))
|
with ThreadPoolExecutor(max_workers=16) as executor:
|
||||||
ledger.append(message(direction="outbound", event_id="event-b", body="out"))
|
records = list(executor.map(lambda _: self.ledger.append(message()), range(16)))
|
||||||
with pytest.raises(ConflictError, match="ambiguous"):
|
self.assertEqual(1, self.ledger.count())
|
||||||
ledger.resolve_quote(message(event_id="event-c", message_id="message-c",
|
self.assertEqual({"message-a"}, {record.message_id for record in records})
|
||||||
reply_to_message_id="message-a"))
|
|
||||||
|
|
||||||
def test_expired_record_is_not_resolved(ledger: MessageLedger) -> None:
|
|
||||||
ledger.append(message())
|
|
||||||
with sqlite3.connect(ledger.database) as db:
|
|
||||||
db.execute("UPDATE message_ledger SET expires_at='2000-01-01T00:00:00+00:00'")
|
|
||||||
with pytest.raises(ScopeError, match="quoted_text_unavailable"):
|
|
||||||
ledger.resolve_quote(message(event_id="event-b", message_id="message-b",
|
|
||||||
reply_to_message_id="message-a"))
|
|
||||||
|
|
||||||
def test_purge_removes_only_expired_records(ledger: MessageLedger) -> None:
|
if __name__ == "__main__":
|
||||||
ledger.append(message())
|
unittest.main()
|
||||||
ledger.append(message(event_id="event-b", message_id="message-b"))
|
|
||||||
with sqlite3.connect(ledger.database) as db:
|
|
||||||
db.execute("UPDATE message_ledger SET expires_at='2000-01-01T00:00:00+00:00' "
|
|
||||||
"WHERE message_id='message-a'")
|
|
||||||
assert ledger.purge_expired() == 1
|
|
||||||
assert ledger.count() == 1
|
|
||||||
|
|
||||||
def test_restart_preserves_exact_resolution(tmp_path: Path) -> None:
|
|
||||||
database = tmp_path / "ledger.sqlite3"
|
|
||||||
MessageLedger(database).append(message())
|
|
||||||
reopened = MessageLedger(database)
|
|
||||||
assert reopened.resolve_quote(message(event_id="event-b", message_id="message-b",
|
|
||||||
reply_to_message_id="message-a")) == "hello"
|
|
||||||
|
|
||||||
def test_concurrent_identical_append_is_one_record(ledger: MessageLedger) -> None:
|
|
||||||
with ThreadPoolExecutor(max_workers=16) as executor:
|
|
||||||
records = list(executor.map(lambda _: ledger.append(message()), range(16)))
|
|
||||||
assert ledger.count() == 1
|
|
||||||
assert {record.message_id for record in records} == {"message-a"}
|
|
||||||
|
|
||||||
def test_concurrent_distinct_appends_are_not_lost(ledger: MessageLedger) -> None:
|
|
||||||
def append(index: int) -> None:
|
|
||||||
ledger.append(message(event_id=f"event-{index}", message_id=f"message-{index}"))
|
|
||||||
with ThreadPoolExecutor(max_workers=16) as executor:
|
|
||||||
list(executor.map(append, range(16)))
|
|
||||||
assert ledger.count() == 16
|
|
||||||
|
|
||||||
def test_sqlite_failures_are_sanitized(ledger: MessageLedger) -> None:
|
|
||||||
ledger.database.write_bytes(b"not-a-sqlite-database")
|
|
||||||
with pytest.raises(ConflictError, match="ledger storage unavailable") as raised:
|
|
||||||
ledger.count()
|
|
||||||
assert not isinstance(raised.value, sqlite3.Error)
|
|
||||||
assert "sqlite" not in str(raised.value).lower()
|
|
||||||
assert raised.value.__cause__ is None
|
|
||||||
assert raised.value.__context__ is None
|
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
"""Executable completeness contract for scoped secret references."""
|
|
||||||
from __future__ import annotations
|
|
||||||
from dataclasses import fields
|
|
||||||
import pytest
|
|
||||||
from aios_v2r1.secret_scope import SecretRef
|
|
||||||
from aios_v2r1.tenant_context import ScopeError, ValidationError
|
|
||||||
|
|
||||||
def secret(**changes: object) -> SecretRef:
|
|
||||||
values: dict[str, object] = {
|
|
||||||
"secret_id": "channel-key", "tenant_id": "tenant-a", "cell_id": "cell-a",
|
|
||||||
"kind": "channel", "version": "v1",
|
|
||||||
"broker_path": "secret://tenant-a/cell-a/channel-key/v1"}
|
|
||||||
values.update(changes)
|
|
||||||
return SecretRef(**values) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("path", [
|
|
||||||
"secret://tenant-a/cell-a/channel-key/v1",
|
|
||||||
"secret://models/provider/key-v2",
|
|
||||||
"secret://runtime/cell-a/signing_key"])
|
|
||||||
def test_valid_broker_paths_are_references(path: str) -> None:
|
|
||||||
assert secret(broker_path=path).broker_path == path
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("path", [
|
|
||||||
"", "vault://tenant-a/key", "secret://", "secret:///key",
|
|
||||||
"secret://tenant-a/../tenant-b/key", "secret://tenant-a//key",
|
|
||||||
"secret://tenant-a/key?version=1", "secret://tenant-a/key#fragment"])
|
|
||||||
def test_broker_path_traversal_and_malformed_paths_are_rejected(path: str) -> None:
|
|
||||||
with pytest.raises(ValidationError, match="broker path"):
|
|
||||||
secret(broker_path=path)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(("tenant_id", "cell_id", "allowed"), [
|
|
||||||
("tenant-a", "cell-a", True), ("tenant-b", "cell-a", False),
|
|
||||||
("tenant-a", "cell-b", False)])
|
|
||||||
def test_secret_scope_is_exact(tenant_id: str, cell_id: str, allowed: bool) -> None:
|
|
||||||
assert secret().can_be_used_by(tenant_id=tenant_id, cell_id=cell_id) is allowed
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("kind", ["channel", "model", "tool", "runtime"])
|
|
||||||
def test_secret_kind_allowlist(kind: str) -> None:
|
|
||||||
assert secret(kind=kind).kind == kind
|
|
||||||
|
|
||||||
def test_invalid_secret_kind_is_rejected() -> None:
|
|
||||||
with pytest.raises(ValidationError, match="kind"):
|
|
||||||
secret(kind="admin")
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("changes", [
|
|
||||||
{"secret_id": "../key"}, {"secret_id": " key "},
|
|
||||||
{"version": "../v2"}, {"version": ""}, {"tenant_id": "Tenant-A"},
|
|
||||||
{"cell_id": "cell/a"}])
|
|
||||||
def test_secret_identifiers_and_version_are_validated(changes: dict[str, str]) -> None:
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
secret(**changes)
|
|
||||||
|
|
||||||
def test_reference_never_contains_a_secret_value_field() -> None:
|
|
||||||
assert "value" not in {field.name for field in fields(SecretRef)}
|
|
||||||
assert "super-secret-value" not in repr(secret())
|
|
||||||
|
|
||||||
def test_broker_resolution_uses_only_the_validated_reference() -> None:
|
|
||||||
seen: list[SecretRef] = []
|
|
||||||
class Broker:
|
|
||||||
def read_secret(self, reference: SecretRef) -> str:
|
|
||||||
seen.append(reference)
|
|
||||||
return "resolved-value"
|
|
||||||
reference = secret()
|
|
||||||
assert reference.resolve(Broker()) == "resolved-value"
|
|
||||||
assert seen == [reference]
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("broker", [None, object()])
|
|
||||||
def test_missing_or_invalid_broker_fails_closed(broker: object | None) -> None:
|
|
||||||
with pytest.raises(ScopeError, match="secret broker unavailable"):
|
|
||||||
secret().resolve(broker)
|
|
||||||
|
|
||||||
def test_broker_error_is_sanitized_without_secret_leakage() -> None:
|
|
||||||
class BrokenBroker:
|
|
||||||
def read_secret(self, reference: SecretRef) -> str:
|
|
||||||
raise RuntimeError("provider leaked super-secret-value")
|
|
||||||
with pytest.raises(ScopeError, match="secret broker unavailable") as raised:
|
|
||||||
secret().resolve(BrokenBroker())
|
|
||||||
assert "super-secret-value" not in str(raised.value)
|
|
||||||
assert raised.value.__cause__ is None
|
|
||||||
assert raised.value.__context__ is None
|
|
||||||
Reference in New Issue
Block a user