Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd0507292e |
@@ -1,38 +0,0 @@
|
|||||||
status: PASS
|
|
||||||
integration_path: tests/integration/test_runtime_ingress_flow.py
|
|
||||||
tests: pytest tests/v2r1 = 29 passed in 0.98s; pytest tests/integration = 3 passed in 0.18s
|
|
||||||
runtime_flow: PASS - signed public ingress -> IngressAuthenticator -> RuntimeIdentityResolver -> RuntimeWiring.ingest -> ChannelAdapter -> MessageLedger.append -> SessionPool
|
|
||||||
commit: a6878d30b8690e55f1bf9819da5aac189dabf89e
|
|
||||||
gate1: BLOCKED
|
|
||||||
risk: high
|
|
||||||
|
|
||||||
# Result
|
|
||||||
|
|
||||||
## Changed Files
|
|
||||||
|
|
||||||
- `tests/integration/test_runtime_ingress_flow.py`
|
|
||||||
- `.agent/results/codex/RESULT-V2R1-202607132000-CODEX.md`
|
|
||||||
|
|
||||||
## RED Evidence
|
|
||||||
|
|
||||||
The baseline integration entrypoint from `f11513e9d7326caa827e59f4f10f07681633dfc0` was inadequate: its successful flow replaced `MessageLedger.remember()` and its alleged duplicate conflict changed the message ID. The quality checks failed before this update because neither test proved the required runtime contract.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- The success path sends a real signed public ingress request and uses a `sys.setprofile` observer against the real `MessageLedger.remember` code object. It preserves the production object and all behavior, then asserts zero legacy calls.
|
|
||||||
- The duplicate path sends two authenticated requests under the same principal, tenant, cell, channel, account, peer, direction, session, and `message_id`; only `event_id` and body differ. The second response must be `CONFLICT`, with exactly one ledger record and one session lease remaining.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```text
|
|
||||||
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=active-runtime/app /usr/bin/python3 -m pytest tests/v2r1 -q
|
|
||||||
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=active-runtime/app /usr/bin/python3 -m pytest tests/integration -q
|
|
||||||
```
|
|
||||||
|
|
||||||
## Gate 1
|
|
||||||
|
|
||||||
`BLOCKED`. This task makes the runtime integration entrypoint meaningful, but does not close the independent Gate 1 matrix, authenticated production OpenClaw registration, secret scanning, second clean-room review, or staging rollback rehearsal.
|
|
||||||
|
|
||||||
## Recommendation
|
|
||||||
|
|
||||||
Keep this isolated evidence commit for independent review. Do not merge, tag, or deploy until all Gate 1 requirements have independent evidence.
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
status: PASS
|
|
||||||
commit: 7b82ae11f685b77628308ba46b9cfdf5bcdf78d3
|
|
||||||
tests:
|
|
||||||
v2r1: "29 passed, exit code 0"
|
|
||||||
integration: "3 passed, exit code 0"
|
|
||||||
cleanup_scan:
|
|
||||||
status: PASS
|
|
||||||
findings: "No remaining conflict markers, unexplained worktree changes, unapproved temporary/cache/build artifacts, or scanner-detected secrets. Runtime SQLite was removed and its sidecars are ignored with exact root-anchored rules; tracked dist/ZIP artifacts are documented exceptions."
|
|
||||||
risk: MEDIUM
|
|
||||||
recommendation: "Push the controlled work branch, then request independent OpenClaw Gate Review; do not merge or release from this result alone."
|
|
||||||
|
|
||||||
# V2R1 Gate 1 Codex Cleanup Result
|
|
||||||
|
|
||||||
## Scope and Git Baseline
|
|
||||||
|
|
||||||
- Task: `V2R1-G1-CODEX-CLEANUP-20260713`
|
|
||||||
- Branch: `work/v2r1-g1-cleanup-remediation-20260713`
|
|
||||||
- Cleanup implementation commit: `7b82ae11f685b77628308ba46b9cfdf5bcdf78d3`
|
|
||||||
- Integration commit: `a6878d30b8690e55f1bf9819da5aac189dabf89e`
|
|
||||||
- Locked integration result baseline: `d6ba7e8fa859407ec55acf49b5468bba5eac96da`
|
|
||||||
- `git merge-base --is-ancestor a6878d30b8690e55f1bf9819da5aac189dabf89e HEAD`: exit code `0`
|
|
||||||
- `git merge-base --is-ancestor d6ba7e8fa859407ec55acf49b5468bba5eac96da HEAD`: exit code `0`
|
|
||||||
- Tree before this result-only commit: `94146e05f700921b12ef023eb4d8be771bae4047`
|
|
||||||
|
|
||||||
The locked integration artifacts were not modified by cleanup:
|
|
||||||
|
|
||||||
- `tests/integration/test_runtime_ingress_flow.py`: blob `6b6732a4c73bbb7fb5273a05b897adce9da39a9a`
|
|
||||||
- `.agent/results/codex/RESULT-V2R1-202607132000-CODEX.md`: blob `e2d6ce3b037339385831f51cb0db42c86b2ae827`
|
|
||||||
|
|
||||||
## Cleanup Changes Verified
|
|
||||||
|
|
||||||
Commit `7b82ae11f685b77628308ba46b9cfdf5bcdf78d3`:
|
|
||||||
|
|
||||||
- removed the tracked runtime database `data/webops.sqlite3`;
|
|
||||||
- added exact root-anchored ignore rules for `/data/webops.sqlite3`, `/data/webops.sqlite3-wal`, `/data/webops.sqlite3-shm`, and `/data/webops.sqlite3-journal`;
|
|
||||||
- resolved tracked merge-conflict residue in `agents/registry.yaml` and `docs/architecture/AIOS_AGENT_AUDIT_PLANE.md`;
|
|
||||||
- left the locked integration test and prior Codex result blobs unchanged.
|
|
||||||
|
|
||||||
`git grep -n -E '^(<<<<<<<|=======|>>>>>>>)' -- . ':!source-baseline/*.zip'` returned no matches (exit code `1`, the expected no-match result).
|
|
||||||
|
|
||||||
The repository scan found no unapproved `__pycache__`, `*.pyc`, `*.pyo`, `.pytest_cache`, `.mypy_cache`, `.ruff_cache`, `*.tmp`, `*.swp`, backup-suffix, or `.DS_Store` artifacts after verification cleanup. The following tracked artifacts are expected baseline exceptions, not unexplained generated files:
|
|
||||||
|
|
||||||
- `openclaw/plugins/aios-tenant-boundary/dist/` (2 tracked plugin distribution files);
|
|
||||||
- `source-baseline/current_actual_deployment_source.zip`;
|
|
||||||
- `source-baseline/previous_current_actual_deployment_source_from_webops_delta.zip`.
|
|
||||||
|
|
||||||
Before creating this result file, `git status --porcelain=v1 --untracked-files=all` was empty with exit code `0`.
|
|
||||||
|
|
||||||
## Secret Scan
|
|
||||||
|
|
||||||
Scanner: Gitleaks `8.30.1`, official Linux x64 release asset.
|
|
||||||
|
|
||||||
- Asset: `/tmp/gitleaks_8.30.1_linux_x64.tar.gz.codex`
|
|
||||||
- SHA-256: `551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb`
|
|
||||||
- `/tmp/gitleaks-codex-8.30.1/gitleaks git --no-banner --no-color --redact --report-format json --report-path /tmp/v2r1-cleanup-audit-git.json --log-opts=7b82ae11f685b77628308ba46b9cfdf5bcdf78d3 /data/aios-gitops/worktrees/v2r1-g1-cleanup-remediation-20260713`: exit code `0`; Gitleaks reported `312 commits scanned`, approximately `1,384,427 bytes (1.38 MB)` scanned, and `no leaks found` (`0` findings in the JSON report).
|
|
||||||
- `git rev-list --count 7b82ae11f685b77628308ba46b9cfdf5bcdf78d3`: exit code `0`, `327` reachable commits. This Git reachability count is recorded separately and is not the Gitleaks scanned-commit count.
|
|
||||||
- `/tmp/gitleaks-codex-8.30.1/gitleaks dir --no-banner --no-color --redact --report-format json --report-path /tmp/v2r1-cleanup-audit-tree.json /data/aios-gitops/worktrees/v2r1-g1-cleanup-remediation-20260713`: exit code `0`; Gitleaks reported approximately `1,214,737 bytes (1.21 MB)` scanned and `no leaks found` (`0` findings in the JSON report).
|
|
||||||
|
|
||||||
No secret content is reproduced in this report.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
Command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=active-runtime/app /usr/bin/python3 -m pytest tests/v2r1
|
|
||||||
```
|
|
||||||
|
|
||||||
Output summary:
|
|
||||||
|
|
||||||
```text
|
|
||||||
collected 29 items
|
|
||||||
29 passed in 0.97s
|
|
||||||
exit code: 0
|
|
||||||
```
|
|
||||||
|
|
||||||
Command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=active-runtime/app /usr/bin/python3 -m pytest tests/integration
|
|
||||||
```
|
|
||||||
|
|
||||||
Output summary:
|
|
||||||
|
|
||||||
```text
|
|
||||||
collected 3 items
|
|
||||||
3 passed in 0.18s
|
|
||||||
exit code: 0
|
|
||||||
```
|
|
||||||
|
|
||||||
Environment: Linux, Python `3.12.3`, pytest `9.1.1`, pluggy `1.6.0`.
|
|
||||||
|
|
||||||
## Disposition
|
|
||||||
|
|
||||||
The cleanup and requested test verification pass at the stated implementation commit. Risk remains `MEDIUM` until the controlled branch is pushed and independently fetched and reviewed by OpenClaw. This result does not authorize merge, tag creation, baseline update, staging, or production deployment.
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
status: BLOCKED
|
|
||||||
commit: 9d11bae21b1df9ebf8efad649414cf850c8c1aed
|
|
||||||
source_commit: 502ef30642597fb0b70b90ae2e8a0b6af217d80e
|
|
||||||
openclaw_result_commit: eac4d16bcc7925916bae603decf245a8070ec535
|
|
||||||
tests:
|
|
||||||
v2r1: "29 passed, exit code 0"
|
|
||||||
integration: "3 passed, exit code 0"
|
|
||||||
tree_equivalent: false
|
|
||||||
workspace_clean: true
|
|
||||||
risk: HIGH
|
|
||||||
recommendation: "Keep Gate 1, merge, tag, baseline, staging, and production deployment blocked. OpenClaw must rerun clean-room verification from exact source commit 502ef30642597fb0b70b90ae2e8a0b6af217d80e and publish a committed result that names that commit."
|
|
||||||
|
|
||||||
# Codex Final Verification
|
|
||||||
|
|
||||||
task_id: V2R1-G1-CODEX-FINAL-VERIFY-20260713
|
|
||||||
executor: CODEX
|
|
||||||
gate1: BLOCKED
|
|
||||||
|
|
||||||
## Authoritative Refs
|
|
||||||
|
|
||||||
source_ref: origin/work/v2r1-integration-quality-2000
|
|
||||||
source_ref_commit: 502ef30642597fb0b70b90ae2e8a0b6af217d80e
|
|
||||||
openclaw_ref: origin/patch/v2r1-gate1-evidence-consumption-20260713
|
|
||||||
openclaw_ref_commit: eac4d16bcc7925916bae603decf245a8070ec535
|
|
||||||
openclaw_result: .agent/results/openclaw/RESULT-V2R1-G1-OPENCLAW-CLEANROOM-ROLLBACK-20260713.md
|
|
||||||
openclaw_result_tracked: true
|
|
||||||
openclaw_result_mentions_source_commit: false
|
|
||||||
|
|
||||||
The authoritative OpenClaw result records a different base/tested lineage and does
|
|
||||||
not name or prove execution against the required source commit. It therefore
|
|
||||||
cannot change Gate 1 status.
|
|
||||||
|
|
||||||
## Tree Comparison
|
|
||||||
|
|
||||||
Command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git diff --quiet \
|
|
||||||
502ef30642597fb0b70b90ae2e8a0b6af217d80e \
|
|
||||||
eac4d16bcc7925916bae603decf245a8070ec535 -- . \
|
|
||||||
'':(exclude).agent/results/openclaw/RESULT-V2R1-G1-OPENCLAW-CLEANROOM-ROLLBACK-20260713.md''
|
|
||||||
```
|
|
||||||
|
|
||||||
exit_code: 1
|
|
||||||
|
|
||||||
Non-evidence differences:
|
|
||||||
|
|
||||||
```text
|
|
||||||
D .agent/results/codex/RESULT-V2R1-G1-CODEX-CLEANUP-20260713.md
|
|
||||||
M .gitignore
|
|
||||||
M agents/registry.yaml
|
|
||||||
A data/webops.sqlite3
|
|
||||||
M docs/architecture/AIOS_AGENT_AUDIT_PLANE.md
|
|
||||||
A patches/v2r1-gate1-evidence-consumption-20260713.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
Executed from an isolated clean worktree at the exact source commit.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PYTHONDONTWRITEBYTECODE=1 PYTEST_ADDOPTS=''-p no:cacheprovider'' \
|
|
||||||
PYTHONPATH=active-runtime/app \
|
|
||||||
/usr/bin/python3 -m pytest tests/v2r1 -q
|
|
||||||
```
|
|
||||||
|
|
||||||
result: 29 passed in 0.95s
|
|
||||||
exit_code: 0
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PYTHONDONTWRITEBYTECODE=1 PYTEST_ADDOPTS=''-p no:cacheprovider'' \
|
|
||||||
PYTHONPATH=active-runtime/app \
|
|
||||||
/usr/bin/python3 -m pytest tests/integration -q
|
|
||||||
```
|
|
||||||
|
|
||||||
result: 3 passed in 0.18s
|
|
||||||
exit_code: 0
|
|
||||||
|
|
||||||
## Repository Hygiene
|
|
||||||
|
|
||||||
source_conflict_markers: 0
|
|
||||||
source_runtime_sqlite_tracked: false
|
|
||||||
source_workspace_clean_after_tests: true
|
|
||||||
openclaw_conflict_markers: 6
|
|
||||||
openclaw_runtime_sqlite_tracked: true
|
|
||||||
openclaw_checkout_workspace_clean: true
|
|
||||||
|
|
||||||
The green tests do not override the failed source provenance, projected tree,
|
|
||||||
conflict-marker, or tracked runtime SQLite checks.
|
|
||||||
|
|
||||||
## Release Boundary
|
|
||||||
|
|
||||||
merge_performed: false
|
|
||||||
tag_created: false
|
|
||||||
baseline_updated: false
|
|
||||||
staging_started: false
|
|
||||||
production_deployed: false
|
|
||||||
force_push_used: false
|
|
||||||
|
|
||||||
The containing result commit is finalized by a follow-up metadata-only commit
|
|
||||||
because a Git commit cannot embed its own object ID.
|
|
||||||
@@ -11,7 +11,3 @@ runtime/tmp/
|
|||||||
runtime/cache/
|
runtime/cache/
|
||||||
logs/
|
logs/
|
||||||
config/test_write_perms
|
config/test_write_perms
|
||||||
/data/webops.sqlite3
|
|
||||||
/data/webops.sqlite3-wal
|
|
||||||
/data/webops.sqlite3-shm
|
|
||||||
/data/webops.sqlite3-journal
|
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
"""Append-only audit chain for AIOS V2R1."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
import sqlite3
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from .tenant_context import canonical_json, isoformat, require_ident
|
|
||||||
|
|
||||||
|
|
||||||
class AuditLog:
|
|
||||||
def __init__(self, database: str | Path) -> None:
|
|
||||||
self.database = str(database)
|
|
||||||
with sqlite3.connect(self.database) as db:
|
|
||||||
db.execute("PRAGMA journal_mode=WAL")
|
|
||||||
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
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
def append(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)
|
|
||||||
trace_id = require_ident("trace_id", trace_id)
|
|
||||||
event_type = require_ident("event_type", event_type)
|
|
||||||
with sqlite3.connect(self.database) as db:
|
|
||||||
previous = db.execute(
|
|
||||||
"SELECT event_hash FROM audit_events ORDER BY seq DESC LIMIT 1"
|
|
||||||
).fetchone()
|
|
||||||
previous_hash = previous[0] if previous else "GENESIS"
|
|
||||||
body = {
|
|
||||||
"tenant_id": tenant_id,
|
|
||||||
"trace_id": trace_id,
|
|
||||||
"event_type": event_type,
|
|
||||||
"payload": payload,
|
|
||||||
"previous_hash": previous_hash,
|
|
||||||
"created_at": isoformat(),
|
|
||||||
}
|
|
||||||
digest = hashlib.sha256(
|
|
||||||
canonical_json(body)
|
|
||||||
).hexdigest()
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO audit_events VALUES(NULL,?,?,?,?,?,?,?)",
|
|
||||||
(
|
|
||||||
tenant_id,
|
|
||||||
trace_id,
|
|
||||||
event_type,
|
|
||||||
json.dumps(payload, ensure_ascii=False),
|
|
||||||
previous_hash,
|
|
||||||
digest,
|
|
||||||
body["created_at"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return digest
|
|
||||||
|
|
||||||
def verify(self) -> bool:
|
|
||||||
previous = "GENESIS"
|
|
||||||
with sqlite3.connect(self.database) as db:
|
|
||||||
rows = db.execute(
|
|
||||||
"SELECT tenant_id,trace_id,event_type,payload,previous_hash,event_hash,created_at "
|
|
||||||
"FROM audit_events ORDER BY seq"
|
|
||||||
)
|
|
||||||
for row in rows:
|
|
||||||
body = {
|
|
||||||
"tenant_id": row[0],
|
|
||||||
"trace_id": row[1],
|
|
||||||
"event_type": row[2],
|
|
||||||
"payload": json.loads(row[3]),
|
|
||||||
"previous_hash": row[4],
|
|
||||||
"created_at": row[6],
|
|
||||||
}
|
|
||||||
if row[4] != previous:
|
|
||||||
return False
|
|
||||||
digest = hashlib.sha256(canonical_json(body)).hexdigest()
|
|
||||||
if digest != row[5]:
|
|
||||||
return False
|
|
||||||
previous = digest
|
|
||||||
return True
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
"""AIOS V2R1 Tenant Cell lifecycle CLI primitives."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .tenant_context import ValidationError, require_ident
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CellRecord:
|
|
||||||
tenant_id: str
|
|
||||||
cell_id: str
|
|
||||||
version: str
|
|
||||||
state: str
|
|
||||||
config_digest: str
|
|
||||||
created_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class CellManager:
|
|
||||||
def __init__(self, registry: str | Path) -> None:
|
|
||||||
self.registry = Path(registry)
|
|
||||||
self.registry.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
if not self.registry.exists():
|
|
||||||
self.registry.write_text("{\"cells\":{}}\n", encoding="utf-8")
|
|
||||||
|
|
||||||
def _load(self) -> dict:
|
|
||||||
return json.loads(self.registry.read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
def _save(self, value: dict) -> None:
|
|
||||||
self.registry.write_text(
|
|
||||||
json.dumps(value, ensure_ascii=False, indent=2) + "\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
def create(self, tenant_id: str, cell_id: str, version: str, digest: str) -> CellRecord:
|
|
||||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
|
||||||
cell_id = require_ident("cell_id", cell_id)
|
|
||||||
data = self._load()
|
|
||||||
if tenant_id in data["cells"]:
|
|
||||||
raise ValidationError("tenant already has a cell")
|
|
||||||
record = CellRecord(
|
|
||||||
tenant_id,
|
|
||||||
cell_id,
|
|
||||||
version,
|
|
||||||
"created",
|
|
||||||
digest,
|
|
||||||
datetime.now(timezone.utc).isoformat(),
|
|
||||||
)
|
|
||||||
data["cells"][tenant_id] = record.__dict__
|
|
||||||
self._save(data)
|
|
||||||
return record
|
|
||||||
|
|
||||||
def status(self, tenant_id: str) -> CellRecord:
|
|
||||||
data = self._load()
|
|
||||||
item = data["cells"].get(tenant_id)
|
|
||||||
if not item:
|
|
||||||
raise ValidationError("unknown tenant")
|
|
||||||
return CellRecord(**item)
|
|
||||||
|
|
||||||
def transition(self, tenant_id: str, state: str) -> CellRecord:
|
|
||||||
allowed = {"created", "starting", "healthy", "upgrading", "rollback", "failed"}
|
|
||||||
if state not in allowed:
|
|
||||||
raise ValidationError("invalid cell state")
|
|
||||||
data = self._load()
|
|
||||||
item = data["cells"].get(tenant_id)
|
|
||||||
if not item:
|
|
||||||
raise ValidationError("unknown tenant")
|
|
||||||
item["state"] = state
|
|
||||||
self._save(data)
|
|
||||||
return CellRecord(**item)
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
"""The only V2R1 ingress adapter: verified context to routed ledger record."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
|
||||||
from threading import RLock
|
|
||||||
from typing import Callable, Mapping, Any
|
|
||||||
|
|
||||||
from .message_ledger import LedgerRecord, MessageLedger
|
|
||||||
from .router import Route, TenantRouter
|
|
||||||
from .session_pool import SessionLease, SessionPool
|
|
||||||
from .tenant_context import CanonicalMessage, TenantContext, utc_now
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class IngressResult:
|
|
||||||
record: LedgerRecord
|
|
||||||
route: Route
|
|
||||||
session: SessionLease
|
|
||||||
|
|
||||||
|
|
||||||
class ChannelAdapter:
|
|
||||||
"""Fail-closed runtime ingress composition.
|
|
||||||
|
|
||||||
The adapter intentionally accepts no tenant or Cell authority outside the
|
|
||||||
signed context and TenantRouter. A process lock keeps rollback handling
|
|
||||||
deterministic for a single runtime while SQLite protects cross-process
|
|
||||||
ledger writes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
router: TenantRouter,
|
|
||||||
ledger: MessageLedger,
|
|
||||||
session_pool: SessionPool,
|
|
||||||
signing_key: bytes,
|
|
||||||
clock: Callable[[], datetime] = utc_now,
|
|
||||||
) -> None:
|
|
||||||
self._router = router
|
|
||||||
self._ledger = ledger
|
|
||||||
self._session_pool = session_pool
|
|
||||||
self._signing_key = signing_key
|
|
||||||
self._clock = clock
|
|
||||||
self._lock = RLock()
|
|
||||||
|
|
||||||
def ingest(self, *, token: str, message: Mapping[str, Any]) -> IngressResult:
|
|
||||||
with self._lock:
|
|
||||||
context = TenantContext.verify(token, self._signing_key, now=self._clock())
|
|
||||||
canonical = CanonicalMessage.from_mapping(message)
|
|
||||||
canonical.assert_context(context)
|
|
||||||
route = self._router.assert_context_route(context)
|
|
||||||
self._router.assert_message_route(canonical)
|
|
||||||
self._session_pool.assert_available(context, route)
|
|
||||||
|
|
||||||
existed = self._ledger.matches(canonical)
|
|
||||||
record = self._ledger.append(canonical)
|
|
||||||
try:
|
|
||||||
lease = self._session_pool.acquire(context, route)
|
|
||||||
except Exception:
|
|
||||||
if not existed:
|
|
||||||
self._ledger.remove(record)
|
|
||||||
raise
|
|
||||||
return IngressResult(record=record, route=route, session=lease)
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"""aios-cell command implementation for V2R1 Cell lifecycle."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from .cell_manager import CellManager
|
|
||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
|
||||||
parser = argparse.ArgumentParser(prog="aios-cell")
|
|
||||||
parser.add_argument("registry")
|
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
|
||||||
|
|
||||||
create = sub.add_parser("create")
|
|
||||||
create.add_argument("tenant_id")
|
|
||||||
create.add_argument("cell_id")
|
|
||||||
create.add_argument("version")
|
|
||||||
create.add_argument("digest")
|
|
||||||
|
|
||||||
status = sub.add_parser("status")
|
|
||||||
status.add_argument("tenant_id")
|
|
||||||
|
|
||||||
transition = sub.add_parser("transition")
|
|
||||||
transition.add_argument("tenant_id")
|
|
||||||
transition.add_argument("state")
|
|
||||||
|
|
||||||
return parser
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
args = build_parser().parse_args(argv)
|
|
||||||
manager = CellManager(args.registry)
|
|
||||||
if args.command == "create":
|
|
||||||
result = manager.create(args.tenant_id, args.cell_id, args.version, args.digest)
|
|
||||||
elif args.command == "status":
|
|
||||||
result = manager.status(args.tenant_id)
|
|
||||||
elif args.command == "transition":
|
|
||||||
result = manager.transition(args.tenant_id, args.state)
|
|
||||||
else:
|
|
||||||
return 2
|
|
||||||
print(json.dumps(result.__dict__, ensure_ascii=False, sort_keys=True))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
"""OpenClaw Cell lifecycle adapter for AIOS V2R1."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import asdict, dataclass, field
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
import subprocess
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
from .tenant_context import ValidationError, V2R1Error, require_ident
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CellSpec:
|
|
||||||
tenant_id: str
|
|
||||||
image: str
|
|
||||||
runtime: str = "docker"
|
|
||||||
port: int | None = None
|
|
||||||
memory: str = "2g"
|
|
||||||
cpus: str = "2"
|
|
||||||
pids_limit: int = 512
|
|
||||||
network: str = "bridge"
|
|
||||||
env: tuple[str, ...] = field(default_factory=tuple)
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
require_ident("tenant_id", self.tenant_id, tenant=True)
|
|
||||||
if self.runtime not in {"docker", "podman"}:
|
|
||||||
raise ValidationError("unsupported runtime")
|
|
||||||
if not self.image:
|
|
||||||
raise ValidationError("image required")
|
|
||||||
|
|
||||||
|
|
||||||
class OpenClawFleet:
|
|
||||||
def __init__(self, binary: str = "openclaw") -> None:
|
|
||||||
self.binary = binary
|
|
||||||
|
|
||||||
def _run(self, args: list[str]) -> dict[str, Any]:
|
|
||||||
proc = subprocess.run(
|
|
||||||
[self.binary, *args],
|
|
||||||
check=False,
|
|
||||||
text=True,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
)
|
|
||||||
if proc.returncode:
|
|
||||||
raise V2R1Error(proc.stderr.strip() or "openclaw command failed")
|
|
||||||
data = json.loads(proc.stdout or "{}")
|
|
||||||
if isinstance(data, dict):
|
|
||||||
return data
|
|
||||||
return {"result": data}
|
|
||||||
|
|
||||||
def create(self, spec: CellSpec) -> dict[str, Any]:
|
|
||||||
return self._run([
|
|
||||||
"fleet", "create", spec.tenant_id,
|
|
||||||
"--image", spec.image,
|
|
||||||
"--runtime", spec.runtime,
|
|
||||||
"--json",
|
|
||||||
])
|
|
||||||
|
|
||||||
def status(self, tenant_id: str) -> dict[str, Any]:
|
|
||||||
require_ident("tenant_id", tenant_id, tenant=True)
|
|
||||||
return self._run(["fleet", "status", tenant_id, "--json"])
|
|
||||||
|
|
||||||
def doctor(self, tenant_id: str | None = None) -> dict[str, Any]:
|
|
||||||
args = ["fleet", "doctor"]
|
|
||||||
if tenant_id:
|
|
||||||
require_ident("tenant_id", tenant_id, tenant=True)
|
|
||||||
args.append(tenant_id)
|
|
||||||
args.append("--json")
|
|
||||||
return self._run(args)
|
|
||||||
|
|
||||||
def backup(self, tenant_id: str, output: str | Path) -> dict[str, Any]:
|
|
||||||
return self._run(["fleet", "backup", tenant_id, "--out", str(output), "--json"])
|
|
||||||
|
|
||||||
def restore(self, tenant_id: str, archive: str | Path) -> dict[str, Any]:
|
|
||||||
return self._run(["fleet", "restore", tenant_id, "--from", str(archive), "--json"])
|
|
||||||
|
|
||||||
def upgrade(self, tenant_id: str, image: str) -> dict[str, Any]:
|
|
||||||
return self._run(["fleet", "upgrade", tenant_id, "--image", image, "--json"])
|
|
||||||
|
|
||||||
|
|
||||||
def load_inventory(path: str | Path) -> list[CellSpec]:
|
|
||||||
raw = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
||||||
if raw.get("schema_version") != "aios.cells.v1":
|
|
||||||
raise ValidationError("invalid inventory schema")
|
|
||||||
result: list[CellSpec] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for item in raw.get("cells", []):
|
|
||||||
spec = CellSpec(
|
|
||||||
tenant_id=item["tenant_id"],
|
|
||||||
image=item["image"],
|
|
||||||
runtime=item.get("runtime", "docker"),
|
|
||||||
port=item.get("port"),
|
|
||||||
env=tuple(item.get("env", [])),
|
|
||||||
)
|
|
||||||
if spec.tenant_id in seen:
|
|
||||||
raise ValidationError("duplicate tenant")
|
|
||||||
seen.add(spec.tenant_id)
|
|
||||||
result.append(spec)
|
|
||||||
if not result:
|
|
||||||
raise ValidationError("no cells")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def inventory_as_dict(items: list[CellSpec]) -> dict[str, Any]:
|
|
||||||
return {"schema_version": "aios.cells.v1", "cells": [asdict(x) for x in items]}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
"""Tenant-partitioned Hermes shared agent fabric primitives."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from contextlib import closing
|
|
||||||
from pathlib import Path
|
|
||||||
import sqlite3
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from .tenant_context import (
|
|
||||||
CanonicalMessage,
|
|
||||||
ConflictError,
|
|
||||||
ScopeError,
|
|
||||||
ValidationError,
|
|
||||||
isoformat,
|
|
||||||
require_ident,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class FabricJob:
|
|
||||||
job_id: str
|
|
||||||
tenant_id: str
|
|
||||||
cell_id: str
|
|
||||||
profile_id: str
|
|
||||||
session_id: str
|
|
||||||
event_id: str
|
|
||||||
status: str = "queued"
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
for name in (
|
|
||||||
"job_id", "cell_id", "profile_id", "session_id", "event_id"
|
|
||||||
):
|
|
||||||
require_ident(name, getattr(self, name))
|
|
||||||
require_ident("tenant_id", self.tenant_id, tenant=True)
|
|
||||||
if self.status not in {"queued", "running", "completed", "failed", "cancelled"}:
|
|
||||||
raise ValidationError("invalid job status")
|
|
||||||
|
|
||||||
|
|
||||||
class HermesFabric:
|
|
||||||
"""Shared scheduler storage with mandatory tenant partition keys."""
|
|
||||||
|
|
||||||
def __init__(self, database: str | Path) -> None:
|
|
||||||
self.database = str(database)
|
|
||||||
with closing(sqlite3.connect(self.database)) as connection, connection as db:
|
|
||||||
db.execute("PRAGMA journal_mode=WAL")
|
|
||||||
db.execute("PRAGMA synchronous=FULL")
|
|
||||||
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)"
|
|
||||||
)
|
|
||||||
|
|
||||||
def enqueue(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
message: CanonicalMessage,
|
|
||||||
profile_id: str,
|
|
||||||
) -> FabricJob:
|
|
||||||
profile_id = require_ident("profile_id", profile_id)
|
|
||||||
if not message.command_capable:
|
|
||||||
raise ScopeError("non-command events cannot create execution jobs")
|
|
||||||
if not message.is_authenticated_human_command:
|
|
||||||
raise ScopeError("only authenticated human commands can create jobs")
|
|
||||||
job = FabricJob(
|
|
||||||
job_id=f"job-{message.event_id}",
|
|
||||||
tenant_id=message.tenant_id,
|
|
||||||
cell_id=message.cell_id,
|
|
||||||
profile_id=profile_id,
|
|
||||||
session_id=message.session_id,
|
|
||||||
event_id=message.event_id,
|
|
||||||
)
|
|
||||||
now = isoformat()
|
|
||||||
conflict: ConflictError | None = None
|
|
||||||
try:
|
|
||||||
with closing(sqlite3.connect(self.database)) as connection, connection as db:
|
|
||||||
db.execute("PRAGMA journal_mode=WAL")
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO fabric_jobs VALUES(?,?,?,?,?,?,?,?,?)",
|
|
||||||
(
|
|
||||||
job.job_id,
|
|
||||||
job.tenant_id,
|
|
||||||
job.cell_id,
|
|
||||||
job.profile_id,
|
|
||||||
job.session_id,
|
|
||||||
job.event_id,
|
|
||||||
job.status,
|
|
||||||
now,
|
|
||||||
now,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except sqlite3.IntegrityError:
|
|
||||||
conflict = ConflictError(
|
|
||||||
"fabric event already exists or violates queue uniqueness"
|
|
||||||
)
|
|
||||||
if conflict is not None:
|
|
||||||
raise conflict
|
|
||||||
return job
|
|
||||||
|
|
||||||
def claim(self, tenant_id: str, cell_id: str, profile_id: str) -> FabricJob | None:
|
|
||||||
require_ident("tenant_id", tenant_id, tenant=True)
|
|
||||||
require_ident("cell_id", cell_id)
|
|
||||||
require_ident("profile_id", profile_id)
|
|
||||||
with closing(sqlite3.connect(self.database)) as connection, connection as db:
|
|
||||||
db.execute("BEGIN IMMEDIATE")
|
|
||||||
row = db.execute(
|
|
||||||
"SELECT * FROM fabric_jobs WHERE tenant_id=? AND cell_id=? "
|
|
||||||
"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
|
|
||||||
db.execute(
|
|
||||||
"UPDATE fabric_jobs SET status='running', updated_at=? WHERE job_id=?",
|
|
||||||
(isoformat(), row[0]),
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
return FabricJob(*row[:7])
|
|
||||||
|
|
||||||
def health(self) -> dict[str, Any]:
|
|
||||||
with closing(sqlite3.connect(self.database)) as connection, connection as db:
|
|
||||||
count = db.execute("SELECT count(*) FROM fabric_jobs").fetchone()[0]
|
|
||||||
return {
|
|
||||||
"component": "hermes_fabric",
|
|
||||||
"jobs": count,
|
|
||||||
"checked_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
"""Hermes shared fabric profile isolation primitives."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
import os
|
|
||||||
|
|
||||||
from .tenant_context import ValidationError, require_ident
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class HermesProfile:
|
|
||||||
tenant_id: str
|
|
||||||
cell_id: str
|
|
||||||
profile_id: str
|
|
||||||
hermes_home: Path
|
|
||||||
workspace: Path
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
require_ident("tenant_id", self.tenant_id, tenant=True)
|
|
||||||
for name in ("cell_id", "profile_id"):
|
|
||||||
require_ident(name, getattr(self, name))
|
|
||||||
if not self.hermes_home.is_absolute() or not self.workspace.is_absolute():
|
|
||||||
raise ValidationError("profile paths must be absolute")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def environment(self) -> dict[str, str]:
|
|
||||||
return {
|
|
||||||
"HERMES_HOME": str(self.hermes_home),
|
|
||||||
"HOME": str(self.hermes_home),
|
|
||||||
"AIOS_TENANT_ID": self.tenant_id,
|
|
||||||
"AIOS_CELL_ID": self.cell_id,
|
|
||||||
"AIOS_PROFILE_ID": self.profile_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
def prepare(self) -> None:
|
|
||||||
self.hermes_home.mkdir(parents=True, exist_ok=True)
|
|
||||||
self.workspace.mkdir(parents=True, exist_ok=True)
|
|
||||||
os.chmod(self.hermes_home, 0o700)
|
|
||||||
os.chmod(self.workspace, 0o700)
|
|
||||||
|
|
||||||
def assert_path_allowed(self, path: str | Path) -> None:
|
|
||||||
target = Path(path).resolve()
|
|
||||||
for root in (self.hermes_home.resolve(), self.workspace.resolve()):
|
|
||||||
if target == root or root in target.parents:
|
|
||||||
return
|
|
||||||
raise ValidationError("path escapes profile isolation boundary")
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
"""Fail-closed identity binding for authenticated external ingress.
|
|
||||||
|
|
||||||
External payload claims are not authority. An HMAC-authenticated envelope is
|
|
||||||
the sole factory for :class:`AuthenticatedPrincipal`; the resolver then checks
|
|
||||||
every claim against the authoritative ``TenantRouter`` before the runtime signs
|
|
||||||
an internal ``TenantContext``.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import re
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
from .router import Route, TenantRouter
|
|
||||||
from .tenant_context import ScopeError, ValidationError, canonical_json, require_ident
|
|
||||||
|
|
||||||
|
|
||||||
_PROVENANCE = object()
|
|
||||||
_SIGNATURE_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
||||||
_SCOPE_FIELDS = (
|
|
||||||
"principal_id", "tenant_id", "cell_id", "channel",
|
|
||||||
"channel_account_id", "session_id", "message",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AuthenticationError(ValidationError):
|
|
||||||
"""Authentication or identity resolution failed without disclosing scope."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, init=False)
|
|
||||||
class AuthenticatedPrincipal:
|
|
||||||
"""A principal which only the verified envelope factory can create."""
|
|
||||||
|
|
||||||
principal_id: str
|
|
||||||
|
|
||||||
def __init__(self, principal_id: str, *, _provenance: object) -> None:
|
|
||||||
if _provenance is not _PROVENANCE:
|
|
||||||
raise TypeError("AuthenticatedPrincipal is created by authenticated ingress only")
|
|
||||||
object.__setattr__(self, "principal_id", require_ident("principal_id", principal_id))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _verified(cls, principal_id: str) -> "AuthenticatedPrincipal":
|
|
||||||
return cls(principal_id, _provenance=_PROVENANCE)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class IdentityClaims:
|
|
||||||
tenant_id: str
|
|
||||||
cell_id: str
|
|
||||||
channel: str
|
|
||||||
channel_account_id: str
|
|
||||||
session_id: str
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
require_ident("tenant_id", self.tenant_id, tenant=True)
|
|
||||||
for field in ("cell_id", "channel", "channel_account_id", "session_id"):
|
|
||||||
require_ident(field, getattr(self, field))
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AuthenticatedIngress:
|
|
||||||
principal: AuthenticatedPrincipal
|
|
||||||
claims: IdentityClaims
|
|
||||||
message: Mapping[str, Any]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RuntimeIdentity:
|
|
||||||
tenant_id: str
|
|
||||||
cell_id: str
|
|
||||||
principal_id: str
|
|
||||||
channel: str
|
|
||||||
channel_account_id: str
|
|
||||||
session_id: str
|
|
||||||
|
|
||||||
|
|
||||||
class IngressAuthenticator:
|
|
||||||
"""Verify a canonical HMAC envelope before any identity lookup occurs."""
|
|
||||||
|
|
||||||
def __init__(self, hmac_key: bytes) -> None:
|
|
||||||
if not isinstance(hmac_key, bytes) or len(hmac_key) < 32:
|
|
||||||
raise ValidationError("ingress HMAC key must be at least 32 bytes")
|
|
||||||
self._hmac_key = hmac_key
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _signed_payload(request: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
if not isinstance(request, Mapping):
|
|
||||||
raise ValidationError("ingress request must be an object")
|
|
||||||
if set(request) != {*_SCOPE_FIELDS, "signature"}:
|
|
||||||
raise ValidationError("ingress request fields are invalid")
|
|
||||||
values = {field: request[field] for field in _SCOPE_FIELDS}
|
|
||||||
if not isinstance(values["message"], Mapping):
|
|
||||||
raise ValidationError("ingress message must be an object")
|
|
||||||
return values
|
|
||||||
|
|
||||||
def authenticate(self, request: Mapping[str, Any]) -> AuthenticatedIngress:
|
|
||||||
values = self._signed_payload(request)
|
|
||||||
signature = request["signature"]
|
|
||||||
if not isinstance(signature, str) or not _SIGNATURE_RE.fullmatch(signature):
|
|
||||||
raise AuthenticationError("invalid ingress signature")
|
|
||||||
expected = hmac.new(
|
|
||||||
self._hmac_key, canonical_json(values), hashlib.sha256
|
|
||||||
).hexdigest()
|
|
||||||
if not hmac.compare_digest(expected, signature):
|
|
||||||
raise AuthenticationError("invalid ingress signature")
|
|
||||||
return AuthenticatedIngress(
|
|
||||||
principal=AuthenticatedPrincipal._verified(values["principal_id"]),
|
|
||||||
claims=IdentityClaims(
|
|
||||||
tenant_id=values["tenant_id"],
|
|
||||||
cell_id=values["cell_id"],
|
|
||||||
channel=values["channel"],
|
|
||||||
channel_account_id=values["channel_account_id"],
|
|
||||||
session_id=values["session_id"],
|
|
||||||
),
|
|
||||||
message=dict(values["message"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RuntimeIdentityResolver:
|
|
||||||
"""Resolve authenticated ingress identity through ``TenantRouter`` only."""
|
|
||||||
|
|
||||||
def __init__(self, router: TenantRouter) -> None:
|
|
||||||
self._router = router
|
|
||||||
|
|
||||||
def resolve(
|
|
||||||
self, principal: AuthenticatedPrincipal, claims: IdentityClaims
|
|
||||||
) -> RuntimeIdentity:
|
|
||||||
if not isinstance(principal, AuthenticatedPrincipal):
|
|
||||||
raise AuthenticationError("untrusted principal type")
|
|
||||||
try:
|
|
||||||
route: Route = self._router.resolve(
|
|
||||||
channel=claims.channel,
|
|
||||||
channel_account_id=claims.channel_account_id,
|
|
||||||
principal_id=principal.principal_id,
|
|
||||||
session_id=claims.session_id,
|
|
||||||
)
|
|
||||||
except (ScopeError, ValidationError) as exc:
|
|
||||||
raise AuthenticationError("identity is not registered") from None
|
|
||||||
expected = (
|
|
||||||
claims.tenant_id, claims.cell_id, claims.channel,
|
|
||||||
claims.channel_account_id, principal.principal_id, claims.session_id,
|
|
||||||
)
|
|
||||||
actual = (
|
|
||||||
route.tenant_id, route.cell_id, route.channel,
|
|
||||||
route.channel_account_id, route.principal_id, route.session_id,
|
|
||||||
)
|
|
||||||
if actual != expected:
|
|
||||||
raise AuthenticationError("identity claims do not match authoritative route")
|
|
||||||
return RuntimeIdentity(
|
|
||||||
tenant_id=route.tenant_id,
|
|
||||||
cell_id=route.cell_id,
|
|
||||||
principal_id=route.principal_id,
|
|
||||||
channel=route.channel,
|
|
||||||
channel_account_id=route.channel_account_id,
|
|
||||||
session_id=route.session_id or "",
|
|
||||||
)
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
"""Tenant-scoped memory namespace and access policy for AIOS V2R1."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import PurePosixPath
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from .tenant_context import ScopeError, ValidationError, require_ident
|
|
||||||
|
|
||||||
|
|
||||||
MemoryKind = Literal["session", "profile", "cell", "tenant", "shared_artifact"]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class MemoryScope:
|
|
||||||
tenant_id: str
|
|
||||||
cell_id: str
|
|
||||||
profile_id: str
|
|
||||||
session_id: str | None
|
|
||||||
kind: MemoryKind
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
require_ident("tenant_id", self.tenant_id, tenant=True)
|
|
||||||
for name in ("cell_id", "profile_id"):
|
|
||||||
require_ident(name, getattr(self, name))
|
|
||||||
if self.session_id is not None:
|
|
||||||
require_ident("session_id", self.session_id)
|
|
||||||
if self.kind not in {"session", "profile", "cell", "tenant", "shared_artifact"}:
|
|
||||||
raise ValidationError("invalid memory kind")
|
|
||||||
if self.kind == "session" and self.session_id is None:
|
|
||||||
raise ValidationError("session memory requires session_id")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def namespace(self) -> str:
|
|
||||||
parts = ["tenant", self.tenant_id, "cell", self.cell_id]
|
|
||||||
if self.kind in {"profile", "session"}:
|
|
||||||
parts.extend(["profile", self.profile_id])
|
|
||||||
if self.kind == "session":
|
|
||||||
parts.extend(["session", str(self.session_id)])
|
|
||||||
parts.extend(["memory", self.kind])
|
|
||||||
return str(PurePosixPath(*parts))
|
|
||||||
|
|
||||||
def assert_access(self, requested: "MemoryScope", *, write: bool = False) -> None:
|
|
||||||
if self.tenant_id != requested.tenant_id:
|
|
||||||
raise ScopeError("cross-tenant memory access denied")
|
|
||||||
if self.cell_id != requested.cell_id:
|
|
||||||
raise ScopeError("cross-cell memory access denied")
|
|
||||||
if requested.kind == "shared_artifact":
|
|
||||||
if write:
|
|
||||||
raise ScopeError("shared artifacts are immutable")
|
|
||||||
return
|
|
||||||
if self.profile_id != requested.profile_id and requested.kind in {"profile", "session"}:
|
|
||||||
raise ScopeError("cross-profile memory access denied")
|
|
||||||
if requested.kind == "session" and self.session_id != requested.session_id:
|
|
||||||
raise ScopeError("cross-session memory access denied")
|
|
||||||
|
|
||||||
|
|
||||||
def qualify_memory_key(scope: MemoryScope, key: str) -> str:
|
|
||||||
key = require_ident("memory_key", key)
|
|
||||||
return f"{scope.namespace}/{key}"
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
"""Tenant-scoped append-only message ledger and exact quote resolver."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import timedelta
|
|
||||||
from contextlib import closing
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import sqlite3
|
|
||||||
from typing import Iterable
|
|
||||||
|
|
||||||
from .tenant_context import (
|
|
||||||
CanonicalMessage,
|
|
||||||
ConflictError,
|
|
||||||
ScopeError,
|
|
||||||
ValidationError,
|
|
||||||
isoformat,
|
|
||||||
normalize_message_id,
|
|
||||||
parse_time,
|
|
||||||
utc_now,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class LedgerRecord:
|
|
||||||
tenant_id: str
|
|
||||||
cell_id: str
|
|
||||||
channel: str
|
|
||||||
channel_account_id: str
|
|
||||||
peer_id: str
|
|
||||||
direction: str
|
|
||||||
message_id: str
|
|
||||||
event_id: str
|
|
||||||
body: str
|
|
||||||
occurred_at: str
|
|
||||||
expires_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class MessageLedger:
|
|
||||||
"""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:
|
|
||||||
if not 1 <= ttl_days <= 365:
|
|
||||||
raise ValidationError("ttl_days must be between 1 and 365")
|
|
||||||
self.database = Path(database)
|
|
||||||
self.ttl_days = ttl_days
|
|
||||||
self.database.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
self._initialize()
|
|
||||||
try:
|
|
||||||
os.chmod(self.database, 0o600)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
|
||||||
db = sqlite3.connect(str(self.database), timeout=30)
|
|
||||||
db.row_factory = sqlite3.Row
|
|
||||||
db.execute("PRAGMA journal_mode=WAL")
|
|
||||||
db.execute("PRAGMA synchronous=FULL")
|
|
||||||
db.execute("PRAGMA busy_timeout=30000")
|
|
||||||
return db
|
|
||||||
|
|
||||||
def _initialize(self) -> None:
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
db.executescript(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS message_ledger (
|
|
||||||
tenant_id TEXT NOT NULL,
|
|
||||||
cell_id TEXT NOT NULL,
|
|
||||||
channel TEXT NOT NULL,
|
|
||||||
channel_account_id TEXT NOT NULL,
|
|
||||||
peer_id TEXT NOT NULL,
|
|
||||||
direction TEXT NOT NULL CHECK(direction IN ('inbound','outbound','internal')),
|
|
||||||
message_id TEXT NOT NULL,
|
|
||||||
event_id TEXT NOT NULL,
|
|
||||||
body TEXT NOT NULL,
|
|
||||||
occurred_at TEXT NOT NULL,
|
|
||||||
expires_at TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY(
|
|
||||||
tenant_id, cell_id, channel, channel_account_id,
|
|
||||||
peer_id, direction, message_id
|
|
||||||
),
|
|
||||||
UNIQUE(tenant_id, cell_id, event_id)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_reply_lookup ON message_ledger(
|
|
||||||
tenant_id, cell_id, channel, channel_account_id,
|
|
||||||
peer_id, message_id, expires_at
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
now = utc_now()
|
|
||||||
expires = isoformat(now + timedelta(days=self.ttl_days))
|
|
||||||
key = (
|
|
||||||
message.tenant_id,
|
|
||||||
message.cell_id,
|
|
||||||
message.channel,
|
|
||||||
message.channel_account_id,
|
|
||||||
message.peer_id,
|
|
||||||
message.direction,
|
|
||||||
message_id,
|
|
||||||
)
|
|
||||||
storage_error: ConflictError | None = None
|
|
||||||
try:
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
db.execute("BEGIN IMMEDIATE")
|
|
||||||
existing = db.execute(
|
|
||||||
"SELECT event_id, body, occurred_at, expires_at FROM message_ledger "
|
|
||||||
"WHERE tenant_id=? AND cell_id=? AND channel=? AND channel_account_id=? "
|
|
||||||
"AND peer_id=? AND direction=? AND message_id=?",
|
|
||||||
key,
|
|
||||||
).fetchone()
|
|
||||||
if existing:
|
|
||||||
if existing["body"] != message.body:
|
|
||||||
db.rollback()
|
|
||||||
raise ConflictError("message ID collision with different content")
|
|
||||||
db.commit()
|
|
||||||
return LedgerRecord(
|
|
||||||
*key, existing["event_id"], existing["body"],
|
|
||||||
existing["occurred_at"], existing["expires_at"],
|
|
||||||
)
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO message_ledger VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
||||||
(*key, message.event_id, message.body, message.occurred_at, expires, isoformat(now)),
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
except sqlite3.Error:
|
|
||||||
storage_error = ConflictError("ledger storage unavailable")
|
|
||||||
if storage_error is not None:
|
|
||||||
# Raise outside the sqlite exception handler so callers cannot inspect
|
|
||||||
# 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:
|
|
||||||
"""Backward-compatible API wrapper for callers outside the runtime path."""
|
|
||||||
return self.append(message)
|
|
||||||
|
|
||||||
def matches(self, message: CanonicalMessage) -> bool:
|
|
||||||
"""Whether this exact message is already durably represented."""
|
|
||||||
message_id = normalize_message_id(message.message_id)
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
row = db.execute(
|
|
||||||
"SELECT event_id, body FROM message_ledger WHERE tenant_id=? AND "
|
|
||||||
"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_id,
|
|
||||||
),
|
|
||||||
).fetchone()
|
|
||||||
return bool(row and row["event_id"] == message.event_id and row["body"] == message.body)
|
|
||||||
|
|
||||||
def remove(self, record: LedgerRecord) -> None:
|
|
||||||
"""Compensate a newly appended record if a later ingress step fails."""
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
db.execute(
|
|
||||||
"DELETE FROM message_ledger WHERE tenant_id=? AND cell_id=? AND "
|
|
||||||
"channel=? AND channel_account_id=? AND peer_id=? AND direction=? "
|
|
||||||
"AND message_id=? AND event_id=?",
|
|
||||||
(
|
|
||||||
record.tenant_id, record.cell_id, record.channel,
|
|
||||||
record.channel_account_id, record.peer_id, record.direction,
|
|
||||||
record.message_id, record.event_id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def resolve_quote(self, message: CanonicalMessage) -> str:
|
|
||||||
if message.quoted_body is not None:
|
|
||||||
return message.quoted_body
|
|
||||||
if message.reply_to_message_id is None:
|
|
||||||
raise ScopeError("quoted_text_unavailable")
|
|
||||||
reply_id = normalize_message_id(
|
|
||||||
message.reply_to_message_id,
|
|
||||||
field_name="reply_to_message_id",
|
|
||||||
)
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
rows = db.execute(
|
|
||||||
"SELECT direction, body, event_id FROM message_ledger "
|
|
||||||
"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.channel,
|
|
||||||
message.channel_account_id,
|
|
||||||
message.peer_id,
|
|
||||||
reply_id,
|
|
||||||
isoformat(),
|
|
||||||
),
|
|
||||||
).fetchall()
|
|
||||||
if len(rows) == 1:
|
|
||||||
return str(rows[0]["body"])
|
|
||||||
if len(rows) > 1:
|
|
||||||
raise ConflictError("ambiguous reply ID across message directions")
|
|
||||||
raise ScopeError("quoted_text_unavailable")
|
|
||||||
|
|
||||||
def purge_expired(self) -> int:
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
cursor = db.execute(
|
|
||||||
"DELETE FROM message_ledger WHERE expires_at<=?", (isoformat(),)
|
|
||||||
)
|
|
||||||
return cursor.rowcount
|
|
||||||
|
|
||||||
def integrity_check(self) -> str:
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
return str(db.execute("PRAGMA integrity_check").fetchone()[0])
|
|
||||||
|
|
||||||
def count(self) -> int:
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
return int(db.execute("SELECT count(*) FROM message_ledger").fetchone()[0])
|
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
"""Authoritative tenant registry and fail-closed message router."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from contextlib import closing
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
import sqlite3
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Callable, Iterator
|
|
||||||
|
|
||||||
from .tenant_context import (
|
|
||||||
CanonicalMessage,
|
|
||||||
ConflictError,
|
|
||||||
ScopeError,
|
|
||||||
TenantContext,
|
|
||||||
ValidationError,
|
|
||||||
isoformat,
|
|
||||||
parse_time,
|
|
||||||
require_ident,
|
|
||||||
utc_now,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Route:
|
|
||||||
tenant_id: str
|
|
||||||
cell_id: str
|
|
||||||
channel: str
|
|
||||||
channel_account_id: str
|
|
||||||
principal_id: str
|
|
||||||
session_id: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class TenantRouter:
|
|
||||||
"""SQLite-backed routing registry.
|
|
||||||
|
|
||||||
Channel accounts are authoritative tenant ownership boundaries. Principal and
|
|
||||||
session bindings may narrow a route, but can never move traffic to another
|
|
||||||
tenant or Cell.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, database: str | Path, *, clock: Callable[[], datetime] = utc_now
|
|
||||||
) -> None:
|
|
||||||
self.database = str(database)
|
|
||||||
self._clock = clock
|
|
||||||
self._initialize()
|
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
|
||||||
connection = sqlite3.connect(self.database, timeout=30, isolation_level=None)
|
|
||||||
connection.row_factory = sqlite3.Row
|
|
||||||
connection.execute("PRAGMA foreign_keys=ON")
|
|
||||||
connection.execute("PRAGMA journal_mode=WAL")
|
|
||||||
connection.execute("PRAGMA synchronous=FULL")
|
|
||||||
connection.execute("PRAGMA busy_timeout=30000")
|
|
||||||
return connection
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def _transaction(self) -> Iterator[sqlite3.Connection]:
|
|
||||||
connection = self._connect()
|
|
||||||
try:
|
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
|
||||||
yield connection
|
|
||||||
connection.execute("COMMIT")
|
|
||||||
except Exception:
|
|
||||||
connection.execute("ROLLBACK")
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
connection.close()
|
|
||||||
|
|
||||||
def _initialize(self) -> None:
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
db.executescript(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS tenants (
|
|
||||||
tenant_id TEXT PRIMARY KEY,
|
|
||||||
cell_id TEXT NOT NULL UNIQUE,
|
|
||||||
status TEXT NOT NULL CHECK(status IN ('active','suspended','retired')),
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS channel_routes (
|
|
||||||
channel TEXT NOT NULL,
|
|
||||||
channel_account_id TEXT NOT NULL,
|
|
||||||
tenant_id TEXT NOT NULL,
|
|
||||||
cell_id TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY(channel, channel_account_id),
|
|
||||||
FOREIGN KEY(tenant_id) REFERENCES tenants(tenant_id)
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS principal_routes (
|
|
||||||
channel TEXT NOT NULL,
|
|
||||||
channel_account_id TEXT NOT NULL,
|
|
||||||
principal_id TEXT NOT NULL,
|
|
||||||
tenant_id TEXT NOT NULL,
|
|
||||||
cell_id TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY(channel, channel_account_id, principal_id),
|
|
||||||
FOREIGN KEY(channel, channel_account_id)
|
|
||||||
REFERENCES channel_routes(channel, channel_account_id)
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS session_bindings (
|
|
||||||
tenant_id TEXT NOT NULL,
|
|
||||||
session_id TEXT NOT NULL,
|
|
||||||
cell_id TEXT NOT NULL,
|
|
||||||
principal_id TEXT NOT NULL,
|
|
||||||
expires_at TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY(tenant_id, session_id),
|
|
||||||
FOREIGN KEY(tenant_id) REFERENCES tenants(tenant_id)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_session_expiry
|
|
||||||
ON session_bindings(expires_at);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
def register_tenant(self, tenant_id: str, cell_id: str) -> None:
|
|
||||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
|
||||||
cell_id = require_ident("cell_id", cell_id)
|
|
||||||
now = isoformat(self._clock())
|
|
||||||
with self._transaction() as db:
|
|
||||||
row = db.execute(
|
|
||||||
"SELECT cell_id, status FROM tenants WHERE tenant_id=?", (tenant_id,)
|
|
||||||
).fetchone()
|
|
||||||
if row:
|
|
||||||
if row["cell_id"] != cell_id:
|
|
||||||
raise ConflictError("tenant already bound to another cell")
|
|
||||||
db.execute(
|
|
||||||
"UPDATE tenants SET status='active', updated_at=? WHERE tenant_id=?",
|
|
||||||
(now, tenant_id),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
conflict = db.execute(
|
|
||||||
"SELECT tenant_id FROM tenants WHERE cell_id=?", (cell_id,)
|
|
||||||
).fetchone()
|
|
||||||
if conflict:
|
|
||||||
raise ConflictError("cell already belongs to another tenant")
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO tenants VALUES(?,?,?,?,?)",
|
|
||||||
(tenant_id, cell_id, "active", now, now),
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_tenant_status(self, tenant_id: str, status: str) -> None:
|
|
||||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
|
||||||
if status not in {"active", "suspended", "retired"}:
|
|
||||||
raise ValidationError("invalid tenant status")
|
|
||||||
with self._transaction() as db:
|
|
||||||
cursor = db.execute(
|
|
||||||
"UPDATE tenants SET status=?, updated_at=? WHERE tenant_id=?",
|
|
||||||
(status, isoformat(self._clock()), tenant_id),
|
|
||||||
)
|
|
||||||
if cursor.rowcount != 1:
|
|
||||||
raise ValidationError("unknown tenant")
|
|
||||||
|
|
||||||
def bind_channel(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
tenant_id: str,
|
|
||||||
channel: str,
|
|
||||||
channel_account_id: str,
|
|
||||||
) -> None:
|
|
||||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
|
||||||
channel = require_ident("channel", channel)
|
|
||||||
channel_account_id = require_ident("channel_account_id", channel_account_id)
|
|
||||||
with self._transaction() as db:
|
|
||||||
tenant = db.execute(
|
|
||||||
"SELECT cell_id, status FROM tenants WHERE tenant_id=?", (tenant_id,)
|
|
||||||
).fetchone()
|
|
||||||
if not tenant or tenant["status"] != "active":
|
|
||||||
raise ValidationError("tenant is not active")
|
|
||||||
existing = db.execute(
|
|
||||||
"SELECT tenant_id, cell_id FROM channel_routes "
|
|
||||||
"WHERE channel=? AND channel_account_id=?",
|
|
||||||
(channel, channel_account_id),
|
|
||||||
).fetchone()
|
|
||||||
if existing:
|
|
||||||
if existing["tenant_id"] != tenant_id or existing["cell_id"] != tenant["cell_id"]:
|
|
||||||
raise ConflictError("channel account already belongs to another tenant")
|
|
||||||
return
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO channel_routes VALUES(?,?,?,?,?)",
|
|
||||||
(channel, channel_account_id, tenant_id, tenant["cell_id"], isoformat(self._clock())),
|
|
||||||
)
|
|
||||||
|
|
||||||
def bind_principal(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
tenant_id: str,
|
|
||||||
channel: str,
|
|
||||||
channel_account_id: str,
|
|
||||||
principal_id: str,
|
|
||||||
) -> None:
|
|
||||||
principal_id = require_ident("principal_id", principal_id)
|
|
||||||
route = self.resolve_channel(channel, channel_account_id)
|
|
||||||
if route.tenant_id != tenant_id:
|
|
||||||
raise ScopeError("principal cannot cross tenant boundary")
|
|
||||||
with self._transaction() as db:
|
|
||||||
existing = db.execute(
|
|
||||||
"SELECT tenant_id, cell_id FROM principal_routes WHERE "
|
|
||||||
"channel=? AND channel_account_id=? AND principal_id=?",
|
|
||||||
(channel, channel_account_id, principal_id),
|
|
||||||
).fetchone()
|
|
||||||
if existing:
|
|
||||||
if existing["tenant_id"] != tenant_id or existing["cell_id"] != route.cell_id:
|
|
||||||
raise ConflictError("principal route conflict")
|
|
||||||
return
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO principal_routes VALUES(?,?,?,?,?,?)",
|
|
||||||
(
|
|
||||||
channel,
|
|
||||||
channel_account_id,
|
|
||||||
principal_id,
|
|
||||||
tenant_id,
|
|
||||||
route.cell_id,
|
|
||||||
isoformat(self._clock()),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def bind_session(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
tenant_id: str,
|
|
||||||
session_id: str,
|
|
||||||
cell_id: str,
|
|
||||||
principal_id: str,
|
|
||||||
ttl_seconds: int = 86400,
|
|
||||||
) -> None:
|
|
||||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
|
||||||
session_id = require_ident("session_id", session_id)
|
|
||||||
cell_id = require_ident("cell_id", cell_id)
|
|
||||||
principal_id = require_ident("principal_id", principal_id)
|
|
||||||
if not 60 <= ttl_seconds <= 31 * 86400:
|
|
||||||
raise ValidationError("invalid session TTL")
|
|
||||||
now = self._clock()
|
|
||||||
expires = isoformat(now + timedelta(seconds=ttl_seconds))
|
|
||||||
with self._transaction() as db:
|
|
||||||
tenant = db.execute(
|
|
||||||
"SELECT cell_id, status FROM tenants WHERE tenant_id=?", (tenant_id,)
|
|
||||||
).fetchone()
|
|
||||||
if not tenant or tenant["status"] != "active" or tenant["cell_id"] != cell_id:
|
|
||||||
raise ScopeError("session cell does not match tenant")
|
|
||||||
existing = db.execute(
|
|
||||||
"SELECT cell_id, principal_id FROM session_bindings "
|
|
||||||
"WHERE tenant_id=? AND session_id=?",
|
|
||||||
(tenant_id, session_id),
|
|
||||||
).fetchone()
|
|
||||||
if existing and (
|
|
||||||
existing["cell_id"] != cell_id or existing["principal_id"] != principal_id
|
|
||||||
):
|
|
||||||
raise ConflictError("session is already bound to another principal or cell")
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO session_bindings VALUES(?,?,?,?,?,?) "
|
|
||||||
"ON CONFLICT(tenant_id,session_id) DO UPDATE SET expires_at=excluded.expires_at",
|
|
||||||
(tenant_id, session_id, cell_id, principal_id, expires, isoformat(now)),
|
|
||||||
)
|
|
||||||
|
|
||||||
def resolve_channel(self, channel: str, channel_account_id: str) -> Route:
|
|
||||||
channel = require_ident("channel", channel)
|
|
||||||
channel_account_id = require_ident("channel_account_id", channel_account_id)
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
row = db.execute(
|
|
||||||
"SELECT r.tenant_id, r.cell_id, t.status FROM channel_routes r "
|
|
||||||
"JOIN tenants t ON t.tenant_id=r.tenant_id "
|
|
||||||
"WHERE r.channel=? AND r.channel_account_id=?",
|
|
||||||
(channel, channel_account_id),
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
|
||||||
raise ScopeError("unknown channel account")
|
|
||||||
if row["status"] != "active":
|
|
||||||
raise ScopeError("tenant is not active")
|
|
||||||
return Route(row["tenant_id"], row["cell_id"], channel, channel_account_id, "", None)
|
|
||||||
|
|
||||||
def resolve(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
channel: str,
|
|
||||||
channel_account_id: str,
|
|
||||||
principal_id: str,
|
|
||||||
session_id: str | None = None,
|
|
||||||
) -> Route:
|
|
||||||
principal_id = require_ident("principal_id", principal_id)
|
|
||||||
base = self.resolve_channel(channel, channel_account_id)
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
principal = db.execute(
|
|
||||||
"SELECT tenant_id, cell_id FROM principal_routes WHERE "
|
|
||||||
"channel=? AND channel_account_id=? AND principal_id=?",
|
|
||||||
(channel, channel_account_id, principal_id),
|
|
||||||
).fetchone()
|
|
||||||
if principal and (
|
|
||||||
principal["tenant_id"] != base.tenant_id or principal["cell_id"] != base.cell_id
|
|
||||||
):
|
|
||||||
raise ScopeError("principal route violates channel ownership")
|
|
||||||
if session_id is not None:
|
|
||||||
session_id = require_ident("session_id", session_id)
|
|
||||||
session = db.execute(
|
|
||||||
"SELECT cell_id, principal_id, expires_at FROM session_bindings "
|
|
||||||
"WHERE tenant_id=? AND session_id=?",
|
|
||||||
(base.tenant_id, session_id),
|
|
||||||
).fetchone()
|
|
||||||
if not session:
|
|
||||||
raise ScopeError("unknown session")
|
|
||||||
if parse_time(session["expires_at"]) <= self._clock():
|
|
||||||
raise ScopeError("session expired")
|
|
||||||
if session["cell_id"] != base.cell_id or session["principal_id"] != principal_id:
|
|
||||||
raise ScopeError("session scope mismatch")
|
|
||||||
return Route(
|
|
||||||
base.tenant_id,
|
|
||||||
base.cell_id,
|
|
||||||
channel,
|
|
||||||
channel_account_id,
|
|
||||||
principal_id,
|
|
||||||
session_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
def assert_message_route(self, message: CanonicalMessage) -> Route:
|
|
||||||
route = self.resolve(
|
|
||||||
channel=message.channel,
|
|
||||||
channel_account_id=message.channel_account_id,
|
|
||||||
principal_id=message.principal_id,
|
|
||||||
session_id=message.session_id,
|
|
||||||
)
|
|
||||||
if message.tenant_id != route.tenant_id or message.cell_id != route.cell_id:
|
|
||||||
raise ScopeError("message claims do not match authoritative route")
|
|
||||||
return route
|
|
||||||
|
|
||||||
def assert_context_route(self, context: TenantContext) -> Route:
|
|
||||||
route = self.resolve(
|
|
||||||
channel=context.channel,
|
|
||||||
channel_account_id=context.channel_account_id,
|
|
||||||
principal_id=context.principal_id,
|
|
||||||
session_id=context.session_id,
|
|
||||||
)
|
|
||||||
if context.tenant_id != route.tenant_id or context.cell_id != route.cell_id:
|
|
||||||
raise ScopeError("context claims do not match authoritative route")
|
|
||||||
return route
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
"""V2R1 runtime composition root for authenticated channel ingress."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Callable, Mapping
|
|
||||||
|
|
||||||
from .channel_adapter import ChannelAdapter, IngressResult
|
|
||||||
from .identity import (
|
|
||||||
AuthenticationError,
|
|
||||||
IngressAuthenticator,
|
|
||||||
RuntimeIdentity,
|
|
||||||
RuntimeIdentityResolver,
|
|
||||||
)
|
|
||||||
from .message_ledger import MessageLedger
|
|
||||||
from .router import TenantRouter
|
|
||||||
from .session_pool import SessionPool
|
|
||||||
from .tenant_context import ConflictError, ScopeError, TenantContext, ValidationError, utc_now
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RuntimeIngressResponse:
|
|
||||||
"""Sanitised boundary response; no exception text crosses external ingress."""
|
|
||||||
|
|
||||||
status: str
|
|
||||||
result: IngressResult | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class RuntimeWiring:
|
|
||||||
"""Own the only external-to-runtime identity binding and message path.
|
|
||||||
|
|
||||||
The request accepted by :meth:`ingest` is an HMAC-authenticated envelope;
|
|
||||||
tenant, cell, channel, account, session, principal, and message claims are
|
|
||||||
signed together. The envelope is then resolved against ``TenantRouter``.
|
|
||||||
Only that resolved identity can mint the short-lived internal context passed
|
|
||||||
to ``ChannelAdapter``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
router: TenantRouter,
|
|
||||||
ledger: MessageLedger,
|
|
||||||
session_pool: SessionPool,
|
|
||||||
channel_adapter: ChannelAdapter,
|
|
||||||
identity_resolver: RuntimeIdentityResolver,
|
|
||||||
ingress_authenticator: IngressAuthenticator,
|
|
||||||
internal_signing_key: bytes,
|
|
||||||
clock: Callable[[], datetime],
|
|
||||||
) -> None:
|
|
||||||
self.router = router
|
|
||||||
self.ledger = ledger
|
|
||||||
self.session_pool = session_pool
|
|
||||||
self.channel_adapter = channel_adapter
|
|
||||||
self.identity_resolver = identity_resolver
|
|
||||||
self._ingress_authenticator = ingress_authenticator
|
|
||||||
self._internal_signing_key = internal_signing_key
|
|
||||||
self._clock = clock
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create(
|
|
||||||
cls,
|
|
||||||
root: str | Path,
|
|
||||||
*,
|
|
||||||
signing_key: bytes,
|
|
||||||
ingress_hmac_key: bytes | None = None,
|
|
||||||
clock: Callable[[], datetime] = utc_now,
|
|
||||||
) -> "RuntimeWiring":
|
|
||||||
if not isinstance(signing_key, bytes) or len(signing_key) < 32:
|
|
||||||
raise ValidationError("internal signing key must be at least 32 bytes")
|
|
||||||
# Kept optional for one controlled migration release; the external
|
|
||||||
# request path still cannot supply a TenantContext token.
|
|
||||||
hmac_key = ingress_hmac_key if ingress_hmac_key is not None else signing_key
|
|
||||||
root_path = Path(root)
|
|
||||||
root_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
router = TenantRouter(root_path / "router.sqlite3", clock=clock)
|
|
||||||
ledger = MessageLedger(root_path / "message_ledger.sqlite3")
|
|
||||||
session_pool = SessionPool(router)
|
|
||||||
adapter = ChannelAdapter(
|
|
||||||
router=router,
|
|
||||||
ledger=ledger,
|
|
||||||
session_pool=session_pool,
|
|
||||||
signing_key=signing_key,
|
|
||||||
clock=clock,
|
|
||||||
)
|
|
||||||
return cls(
|
|
||||||
router=router,
|
|
||||||
ledger=ledger,
|
|
||||||
session_pool=session_pool,
|
|
||||||
channel_adapter=adapter,
|
|
||||||
identity_resolver=RuntimeIdentityResolver(router),
|
|
||||||
ingress_authenticator=IngressAuthenticator(hmac_key),
|
|
||||||
internal_signing_key=signing_key,
|
|
||||||
clock=clock,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _issue_internal_context(self, identity: RuntimeIdentity) -> TenantContext:
|
|
||||||
"""Mint internal scope only after all claims matched the router route."""
|
|
||||||
return TenantContext.issue(
|
|
||||||
lifetime_seconds=300,
|
|
||||||
now=self._clock(),
|
|
||||||
tenant_id=identity.tenant_id,
|
|
||||||
cell_id=identity.cell_id,
|
|
||||||
principal_id=identity.principal_id,
|
|
||||||
principal_type="authenticated_user",
|
|
||||||
session_id=identity.session_id,
|
|
||||||
task_id=None,
|
|
||||||
channel=identity.channel,
|
|
||||||
channel_account_id=identity.channel_account_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
def ingest(self, *, request: Mapping[str, Any]) -> RuntimeIngressResponse:
|
|
||||||
"""Authenticate, resolve, issue internal context, then append and lease.
|
|
||||||
|
|
||||||
The only successful route is ``IngressAuthenticator`` ->
|
|
||||||
``RuntimeIdentityResolver`` -> ``ChannelAdapter`` ->
|
|
||||||
``MessageLedger.append`` + ``SessionPool``. The legacy caller-supplied
|
|
||||||
TenantContext token interface is intentionally not accepted.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
ingress = self._ingress_authenticator.authenticate(request)
|
|
||||||
identity = self.identity_resolver.resolve(ingress.principal, ingress.claims)
|
|
||||||
context = self._issue_internal_context(identity)
|
|
||||||
result = self.channel_adapter.ingest(
|
|
||||||
token=context.sign(self._internal_signing_key), message=ingress.message
|
|
||||||
)
|
|
||||||
return RuntimeIngressResponse(status="ACCEPTED", result=result)
|
|
||||||
except (AuthenticationError, ScopeError):
|
|
||||||
return RuntimeIngressResponse(status="AUTH_FAILED")
|
|
||||||
except ValidationError:
|
|
||||||
return RuntimeIngressResponse(status="VALIDATION_FAILED")
|
|
||||||
except ConflictError:
|
|
||||||
return RuntimeIngressResponse(status="CONFLICT")
|
|
||||||
except Exception:
|
|
||||||
return RuntimeIngressResponse(status="INTERNAL_ERROR")
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"""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 dataclasses import dataclass
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from .tenant_context import ValidationError, require_ident
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SecretRef:
|
|
||||||
secret_id: str
|
|
||||||
tenant_id: str
|
|
||||||
cell_id: str
|
|
||||||
kind: Literal["channel", "model", "tool", "runtime"]
|
|
||||||
version: str
|
|
||||||
broker_path: str
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
for name in ("secret_id", "cell_id", "version"):
|
|
||||||
require_ident(name, getattr(self, name))
|
|
||||||
require_ident("tenant_id", self.tenant_id, tenant=True)
|
|
||||||
if self.kind not in {"channel", "model", "tool", "runtime"}:
|
|
||||||
raise ValidationError("invalid secret kind")
|
|
||||||
if not self.broker_path.startswith("secret://"):
|
|
||||||
raise ValidationError("secret broker path required")
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
"""Process-local session lease pool backed by TenantRouter authority."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from threading import RLock
|
|
||||||
|
|
||||||
from .router import Route, TenantRouter
|
|
||||||
from .tenant_context import ScopeError, TenantContext
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SessionLease:
|
|
||||||
tenant_id: str
|
|
||||||
cell_id: str
|
|
||||||
principal_id: str
|
|
||||||
session_id: str
|
|
||||||
|
|
||||||
|
|
||||||
class SessionPool:
|
|
||||||
"""Tracks active leases without creating a second session authority store."""
|
|
||||||
|
|
||||||
def __init__(self, router: TenantRouter) -> None:
|
|
||||||
self._router = router
|
|
||||||
self._leases: dict[tuple[str, str], SessionLease] = {}
|
|
||||||
self._lock = RLock()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _assert_route(context: TenantContext, route: Route) -> None:
|
|
||||||
if (
|
|
||||||
route.tenant_id != context.tenant_id
|
|
||||||
or route.cell_id != context.cell_id
|
|
||||||
or route.principal_id != context.principal_id
|
|
||||||
or route.session_id != context.session_id
|
|
||||||
):
|
|
||||||
raise ScopeError("session lease does not match authoritative route")
|
|
||||||
|
|
||||||
def assert_available(self, context: TenantContext, route: Route) -> None:
|
|
||||||
"""Re-resolve the context from the router before a ledger mutation."""
|
|
||||||
self._assert_route(context, route)
|
|
||||||
self._assert_route(context, self._router.assert_context_route(context))
|
|
||||||
|
|
||||||
def acquire(self, context: TenantContext, route: Route) -> SessionLease:
|
|
||||||
self._assert_route(context, route)
|
|
||||||
lease = SessionLease(
|
|
||||||
tenant_id=context.tenant_id,
|
|
||||||
cell_id=context.cell_id,
|
|
||||||
principal_id=context.principal_id,
|
|
||||||
session_id=context.session_id,
|
|
||||||
)
|
|
||||||
with self._lock:
|
|
||||||
return self._leases.setdefault((lease.tenant_id, lease.session_id), lease)
|
|
||||||
|
|
||||||
def release(self, lease: SessionLease) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._leases.pop((lease.tenant_id, lease.session_id), None)
|
|
||||||
|
|
||||||
def active_count(self) -> int:
|
|
||||||
with self._lock:
|
|
||||||
return len(self._leases)
|
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
"""Signed tenant context and canonical message contract for AIOS V2R1."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
from dataclasses import asdict, dataclass
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import secrets
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
VERSION = "V2R1C0"
|
|
||||||
MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
|
||||||
TENANT_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$")
|
|
||||||
IDENT_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9._:@-]{0,126}[A-Za-z0-9])?$")
|
|
||||||
|
|
||||||
|
|
||||||
class V2R1Error(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ValidationError(V2R1Error):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ScopeError(V2R1Error):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ConflictError(V2R1Error):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> datetime:
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def isoformat(value: datetime | None = None) -> str:
|
|
||||||
return (value or utc_now()).astimezone(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def parse_time(value: str) -> datetime:
|
|
||||||
if not isinstance(value, str) or not value.strip():
|
|
||||||
raise ValidationError("timestamp must be a non-empty string")
|
|
||||||
text = value.strip()
|
|
||||||
if text.endswith("Z"):
|
|
||||||
text = text[:-1] + "+00:00"
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(text)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ValidationError(f"invalid timestamp: {value!r}") from exc
|
|
||||||
if parsed.tzinfo is None:
|
|
||||||
raise ValidationError("timestamp must include timezone")
|
|
||||||
return parsed.astimezone(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def require_ident(name: str, value: Any, *, tenant: bool = False) -> str:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
raise ValidationError(f"{name} must be a string")
|
|
||||||
value = value.strip()
|
|
||||||
pattern = TENANT_RE if tenant else IDENT_RE
|
|
||||||
if not pattern.fullmatch(value):
|
|
||||||
raise ValidationError(f"invalid {name}: {value!r}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_message_id(value: Any, *, field_name: str = "message_id") -> str:
|
|
||||||
if isinstance(value, bool):
|
|
||||||
raise ValidationError(f"{field_name} cannot be boolean")
|
|
||||||
if isinstance(value, str):
|
|
||||||
value = value.strip()
|
|
||||||
if not value or len(value) > 256 or any(ord(ch) < 32 for ch in value):
|
|
||||||
raise ValidationError(f"invalid {field_name}")
|
|
||||||
return value
|
|
||||||
if isinstance(value, int):
|
|
||||||
if abs(value) > MAX_SAFE_INTEGER:
|
|
||||||
raise ValidationError(f"{field_name} is an unsafe numeric ID")
|
|
||||||
return str(value)
|
|
||||||
raise ValidationError(f"{field_name} must be a lossless string or safe integer")
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_json(value: Any) -> bytes:
|
|
||||||
return json.dumps(
|
|
||||||
value, ensure_ascii=False, separators=(",", ":"), sort_keys=True
|
|
||||||
).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _b64(data: bytes) -> str:
|
|
||||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
||||||
|
|
||||||
|
|
||||||
def _unb64(value: str) -> bytes:
|
|
||||||
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TenantContext:
|
|
||||||
tenant_id: str
|
|
||||||
cell_id: str
|
|
||||||
principal_id: str
|
|
||||||
principal_type: str
|
|
||||||
session_id: str
|
|
||||||
task_id: str | None
|
|
||||||
channel: str
|
|
||||||
channel_account_id: str
|
|
||||||
issued_at: str
|
|
||||||
expires_at: str
|
|
||||||
nonce: str
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
require_ident("tenant_id", self.tenant_id, tenant=True)
|
|
||||||
for name in (
|
|
||||||
"cell_id", "principal_id", "principal_type", "session_id",
|
|
||||||
"channel", "channel_account_id", "nonce",
|
|
||||||
):
|
|
||||||
require_ident(name, getattr(self, name))
|
|
||||||
if self.task_id is not None:
|
|
||||||
require_ident("task_id", self.task_id)
|
|
||||||
issued = parse_time(self.issued_at)
|
|
||||||
expires = parse_time(self.expires_at)
|
|
||||||
if expires <= issued or expires - issued > timedelta(hours=1):
|
|
||||||
raise ValidationError("invalid tenant context lifetime")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def issue(cls, *, lifetime_seconds: int = 300, now: datetime | None = None, **scope: Any) -> "TenantContext":
|
|
||||||
if not 1 <= lifetime_seconds <= 3600:
|
|
||||||
raise ValidationError("lifetime_seconds must be between 1 and 3600")
|
|
||||||
current = (now or utc_now()).astimezone(timezone.utc)
|
|
||||||
return cls(
|
|
||||||
**scope,
|
|
||||||
issued_at=isoformat(current),
|
|
||||||
expires_at=isoformat(current + timedelta(seconds=lifetime_seconds)),
|
|
||||||
nonce=secrets.token_hex(16),
|
|
||||||
)
|
|
||||||
|
|
||||||
def sign(self, key: bytes) -> str:
|
|
||||||
if len(key) < 32:
|
|
||||||
raise ValidationError("signing key must be at least 32 bytes")
|
|
||||||
payload = _b64(canonical_json(asdict(self)))
|
|
||||||
signature = _b64(hmac.new(key, payload.encode("ascii"), hashlib.sha256).digest())
|
|
||||||
return f"{payload}.{signature}"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def verify(
|
|
||||||
cls,
|
|
||||||
token: str,
|
|
||||||
key: bytes,
|
|
||||||
*,
|
|
||||||
now: datetime | None = None,
|
|
||||||
expected_tenant_id: str | None = None,
|
|
||||||
expected_cell_id: str | None = None,
|
|
||||||
) -> "TenantContext":
|
|
||||||
if len(key) < 32:
|
|
||||||
raise ValidationError("signing key must be at least 32 bytes")
|
|
||||||
try:
|
|
||||||
payload, encoded_signature = token.split(".", 1)
|
|
||||||
actual = _unb64(encoded_signature)
|
|
||||||
if encoded_signature != _b64(actual):
|
|
||||||
raise ValidationError("non-canonical tenant context signature")
|
|
||||||
data = json.loads(_unb64(payload))
|
|
||||||
except Exception as exc:
|
|
||||||
raise ValidationError("malformed tenant context token") from exc
|
|
||||||
expected = hmac.new(key, payload.encode("ascii"), hashlib.sha256).digest()
|
|
||||||
if not hmac.compare_digest(actual, expected):
|
|
||||||
raise ValidationError("tenant context signature mismatch")
|
|
||||||
context = cls(**data)
|
|
||||||
current = (now or utc_now()).astimezone(timezone.utc)
|
|
||||||
if current < parse_time(context.issued_at) - timedelta(seconds=60):
|
|
||||||
raise ValidationError("tenant context issued in future")
|
|
||||||
if current >= parse_time(context.expires_at):
|
|
||||||
raise ValidationError("tenant context expired")
|
|
||||||
if expected_tenant_id and context.tenant_id != expected_tenant_id:
|
|
||||||
raise ScopeError("tenant mismatch")
|
|
||||||
if expected_cell_id and context.cell_id != expected_cell_id:
|
|
||||||
raise ScopeError("cell mismatch")
|
|
||||||
return context
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CanonicalMessage:
|
|
||||||
tenant_id: str
|
|
||||||
cell_id: str
|
|
||||||
principal_id: str
|
|
||||||
principal_type: str
|
|
||||||
origin_type: str
|
|
||||||
channel: str
|
|
||||||
channel_account_id: str
|
|
||||||
peer_id: str
|
|
||||||
conversation_id: str
|
|
||||||
session_id: str
|
|
||||||
task_id: str | None
|
|
||||||
event_id: str
|
|
||||||
message_id: str
|
|
||||||
reply_to_message_id: str | None
|
|
||||||
direction: str
|
|
||||||
command_capable: bool
|
|
||||||
occurred_at: str
|
|
||||||
received_at: str
|
|
||||||
body: str
|
|
||||||
quoted_body: str | None
|
|
||||||
adapter_name: str
|
|
||||||
adapter_version: str
|
|
||||||
schema_version: str = "aios.message.v1"
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
if self.schema_version != "aios.message.v1":
|
|
||||||
raise ValidationError("unsupported message schema version")
|
|
||||||
require_ident("tenant_id", self.tenant_id, tenant=True)
|
|
||||||
for name in (
|
|
||||||
"cell_id", "principal_id", "principal_type", "origin_type", "channel",
|
|
||||||
"channel_account_id", "peer_id", "conversation_id", "session_id",
|
|
||||||
"event_id", "adapter_name", "adapter_version",
|
|
||||||
):
|
|
||||||
require_ident(name, getattr(self, name))
|
|
||||||
if self.task_id is not None:
|
|
||||||
require_ident("task_id", self.task_id)
|
|
||||||
normalize_message_id(self.message_id)
|
|
||||||
if self.reply_to_message_id is not None:
|
|
||||||
normalize_message_id(self.reply_to_message_id, field_name="reply_to_message_id")
|
|
||||||
if self.direction not in {"inbound", "outbound", "internal"}:
|
|
||||||
raise ValidationError("invalid direction")
|
|
||||||
if not isinstance(self.command_capable, bool):
|
|
||||||
raise ValidationError("command_capable must be boolean")
|
|
||||||
parse_time(self.occurred_at)
|
|
||||||
parse_time(self.received_at)
|
|
||||||
if not isinstance(self.body, str):
|
|
||||||
raise ValidationError("body must be a string")
|
|
||||||
if self.quoted_body is not None and not isinstance(self.quoted_body, str):
|
|
||||||
raise ValidationError("quoted_body must be a string or null")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_mapping(cls, data: Mapping[str, Any]) -> "CanonicalMessage":
|
|
||||||
content = data.get("content") or {}
|
|
||||||
adapter = data.get("adapter") or {}
|
|
||||||
if not isinstance(content, Mapping) or not isinstance(adapter, Mapping):
|
|
||||||
raise ValidationError("content and adapter must be objects")
|
|
||||||
values = dict(data)
|
|
||||||
return cls(
|
|
||||||
schema_version=values.get("schema_version", "aios.message.v1"),
|
|
||||||
tenant_id=values["tenant_id"],
|
|
||||||
cell_id=values["cell_id"],
|
|
||||||
principal_id=values["principal_id"],
|
|
||||||
principal_type=values["principal_type"],
|
|
||||||
origin_type=values["origin_type"],
|
|
||||||
channel=values["channel"],
|
|
||||||
channel_account_id=values["channel_account_id"],
|
|
||||||
peer_id=values["peer_id"],
|
|
||||||
conversation_id=values["conversation_id"],
|
|
||||||
session_id=values["session_id"],
|
|
||||||
task_id=values.get("task_id"),
|
|
||||||
event_id=values["event_id"],
|
|
||||||
message_id=normalize_message_id(values["message_id"]),
|
|
||||||
reply_to_message_id=(
|
|
||||||
normalize_message_id(values["reply_to_message_id"], field_name="reply_to_message_id")
|
|
||||||
if values.get("reply_to_message_id") is not None else None
|
|
||||||
),
|
|
||||||
direction=values["direction"],
|
|
||||||
command_capable=values["command_capable"],
|
|
||||||
occurred_at=values["occurred_at"],
|
|
||||||
received_at=values["received_at"],
|
|
||||||
body=content.get("body", ""),
|
|
||||||
quoted_body=content.get("quoted_body"),
|
|
||||||
adapter_name=adapter["name"],
|
|
||||||
adapter_version=adapter["version"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def assert_context(self, context: TenantContext) -> None:
|
|
||||||
fields = (
|
|
||||||
"tenant_id", "cell_id", "principal_id", "session_id", "task_id",
|
|
||||||
"channel", "channel_account_id",
|
|
||||||
)
|
|
||||||
mismatches = [name for name in fields if getattr(self, name) != getattr(context, name)]
|
|
||||||
if mismatches:
|
|
||||||
raise ScopeError("message/context mismatch: " + ", ".join(mismatches))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_authenticated_human_command(self) -> bool:
|
|
||||||
return (
|
|
||||||
self.direction == "inbound"
|
|
||||||
and self.command_capable
|
|
||||||
and self.principal_type == "authenticated_user"
|
|
||||||
and self.origin_type == "channel_user"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PolicyDecision:
|
|
||||||
allowed: bool
|
|
||||||
approval_required: bool
|
|
||||||
code: str
|
|
||||||
reason: str
|
|
||||||
|
|
||||||
|
|
||||||
READ_ONLY_TOOLS = frozenset({
|
|
||||||
"read", "grep", "glob", "find", "ls", "web_search", "web_fetch",
|
|
||||||
"memory_search", "sessions_list", "sessions_history", "session_status",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def evaluate_tool_call(tool_name: str, message: CanonicalMessage) -> PolicyDecision:
|
|
||||||
normalized = tool_name.strip().lower() if isinstance(tool_name, str) else ""
|
|
||||||
if not normalized:
|
|
||||||
return PolicyDecision(False, False, "invalid_tool", "tool name missing")
|
|
||||||
if normalized in READ_ONLY_TOOLS:
|
|
||||||
return PolicyDecision(True, False, "read_only", "read-only operation")
|
|
||||||
if not message.is_authenticated_human_command:
|
|
||||||
return PolicyDecision(
|
|
||||||
False, False, "origin_denied",
|
|
||||||
"side effects require an authenticated current human command",
|
|
||||||
)
|
|
||||||
return PolicyDecision(
|
|
||||||
True, True, "approval_required",
|
|
||||||
"authenticated human side effect requires explicit approval",
|
|
||||||
)
|
|
||||||
+29
-18
@@ -1,7 +1,8 @@
|
|||||||
|
<<<<<<< HEAD
|
||||||
# AIOS Agent Command Plane Registry
|
# AIOS Agent Command Plane Registry
|
||||||
# Git is the single source of truth for all agent commands.
|
# Git is the single source of truth for all agent commands.
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
created_at: "2026-07-08T15:20:00+08:00"
|
created_at: 2026-07-08T15:20:00+08:00
|
||||||
baseline: V1R3C1
|
baseline: V1R3C1
|
||||||
|
|
||||||
commands:
|
commands:
|
||||||
@@ -11,22 +12,6 @@ commands:
|
|||||||
completed_path: "agents/commands/completed/"
|
completed_path: "agents/commands/completed/"
|
||||||
failed_path: "agents/commands/failed/"
|
failed_path: "agents/commands/failed/"
|
||||||
|
|
||||||
directories:
|
|
||||||
commands/active: "New commands pending execution — Git is the only entry point"
|
|
||||||
commands/running: "Commands currently being executed by an agent"
|
|
||||||
commands/completed: "Commands that finished successfully — moved here on success"
|
|
||||||
commands/failed: "Commands that finished with errors — moved here on failure"
|
|
||||||
|
|
||||||
lifecycle:
|
|
||||||
new: active/
|
|
||||||
running: running/
|
|
||||||
success: completed/
|
|
||||||
failure: failed/
|
|
||||||
|
|
||||||
# Command lifecycle state machine:
|
|
||||||
# active/ → locked & moved to running/ → execute → success → completed/
|
|
||||||
# → failure → failed/
|
|
||||||
|
|
||||||
policies:
|
policies:
|
||||||
path: "agents/policies/"
|
path: "agents/policies/"
|
||||||
source: "git"
|
source: "git"
|
||||||
@@ -53,4 +38,30 @@ audit:
|
|||||||
agent_runs: "audit/agent-runs/"
|
agent_runs: "audit/agent-runs/"
|
||||||
version_history: "audit/version-history/"
|
version_history: "audit/version-history/"
|
||||||
command_history: "audit/command-history/"
|
command_history: "audit/command-history/"
|
||||||
interaction_history: "audit/interaction-history/"
|
=======
|
||||||
|
# AIOS Agent Command Registry
|
||||||
|
# Git is the single source of truth for all agent commands.
|
||||||
|
|
||||||
|
registry_version: 1.0.0
|
||||||
|
created_at: "2026-07-08T15:44:00+08:00"
|
||||||
|
baseline: V1R3C1
|
||||||
|
|
||||||
|
directories:
|
||||||
|
commands/active: "New commands pending execution — Git is the only entry point"
|
||||||
|
commands/running: "Commands currently being executed by an agent"
|
||||||
|
commands/completed: "Commands that finished successfully — moved here on success"
|
||||||
|
commands/failed: "Commands that finished with errors — moved here on failure"
|
||||||
|
|
||||||
|
state: agents/state/
|
||||||
|
policies: agents/policies/
|
||||||
|
|
||||||
|
lifecycle:
|
||||||
|
new: active/
|
||||||
|
running: running/
|
||||||
|
success: completed/
|
||||||
|
failure: failed/
|
||||||
|
|
||||||
|
# Command lifecycle state machine:
|
||||||
|
# active/ → locked & moved to running/ → execute → success → completed/
|
||||||
|
# → failure → failed/
|
||||||
|
>>>>>>> 4d18ee0 (feat(cmd): enable Agent Command Plane, audit plane, and Web UI dashboard)
|
||||||
|
|||||||
+7
-12
@@ -1,13 +1,8 @@
|
|||||||
{
|
{
|
||||||
"current_baseline": "V1R4C6",
|
"current_baseline": "V1R4C3",
|
||||||
"previous_baseline": "V1R4C5",
|
"previous_baseline": "V1R3C1",
|
||||||
"promoted_at": "2026-07-11T21:00:00+08:00",
|
"promoted_at": "2026-07-09T17:44:57+08:00",
|
||||||
"git": "e204a90398bf646d46245b3088226749fa056edd",
|
"git": "f5435e308794c737f3e39de3f6b79807486943c3",
|
||||||
"promoted_by": "openclaw/admin",
|
"promoted_by": "openclaw",
|
||||||
"promotion_reason": "V1R4C6 admin search agent integration, search gateway, agent platform, VERSION bump, merge tests deploy pipeline — not yet deployed to production",
|
"promotion_reason": "V1R4C3 autonomous operations, search gateway, task center, owner gate, and blocker self-optimization baseline promotion"
|
||||||
"constraints": {
|
}
|
||||||
"memory_search_enabled": false,
|
|
||||||
"force_push_allowed": false,
|
|
||||||
"gitea_db_mutation_requires_owner_approval": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Binary file not shown.
@@ -1,20 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
printf 'AIOS V2R1C0 RC1 health check\n'
|
|
||||||
|
|
||||||
python3 - <<'PY'
|
|
||||||
from pathlib import Path
|
|
||||||
required = [
|
|
||||||
'active-runtime/app/aios_v2r1/tenant_context.py',
|
|
||||||
'active-runtime/app/aios_v2r1/router.py',
|
|
||||||
'active-runtime/app/aios_v2r1/hermes_fabric.py',
|
|
||||||
'openclaw/plugins/aios-tenant-boundary/openclaw.plugin.json',
|
|
||||||
]
|
|
||||||
missing=[p for p in required if not Path(p).exists()]
|
|
||||||
if missing:
|
|
||||||
raise SystemExit('missing: '+','.join(missing))
|
|
||||||
print('artifact presence PASS')
|
|
||||||
PY
|
|
||||||
|
|
||||||
echo 'runtime health requires OpenClaw environment probe'
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
|
|
||||||
EXPECTED_BRANCH="release/v2r1c0-tenant-cell-fabric-20260712"
|
|
||||||
|
|
||||||
if [[ "$(git -C "$ROOT" branch --show-current)" != "$EXPECTED_BRANCH" ]]; then
|
|
||||||
echo "wrong release branch"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
COMMIT=$(git -C "$ROOT" rev-parse HEAD)
|
|
||||||
echo "Installing AIOS V2R1C0 RC1 commit=$COMMIT"
|
|
||||||
|
|
||||||
python3 - <<'PY'
|
|
||||||
import compileall
|
|
||||||
if not compileall.compile_dir('active-runtime/app/aios_v2r1', quiet=1):
|
|
||||||
raise SystemExit(1)
|
|
||||||
print('python compile PASS')
|
|
||||||
PY
|
|
||||||
|
|
||||||
if [[ -f openclaw/plugins/aios-tenant-boundary/package.json ]]; then
|
|
||||||
echo "plugin package PASS"
|
|
||||||
else
|
|
||||||
echo "plugin package missing"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Installation preflight PASS"
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
MODE=${1:---check}
|
|
||||||
|
|
||||||
case "$MODE" in
|
|
||||||
--check)
|
|
||||||
echo "V2R1C0 RC1 validation mode"
|
|
||||||
;;
|
|
||||||
--install)
|
|
||||||
echo "Install requires approved OpenClaw Cell target"
|
|
||||||
;;
|
|
||||||
--rollback)
|
|
||||||
echo "Rollback requires recorded Cell backup"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Usage: $0 [--check|--install|--rollback]"
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
python3 - <<'PY'
|
|
||||||
import compileall
|
|
||||||
raise SystemExit(0 if compileall.compile_dir('active-runtime/app/aios_v2r1', quiet=1) else 1)
|
|
||||||
PY
|
|
||||||
|
|
||||||
echo "python runtime syntax: PASS"
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
<<<<<<< HEAD
|
||||||
# AIOS Agent Audit Plane Architecture
|
# AIOS Agent Audit Plane Architecture
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
@@ -24,10 +25,9 @@ Agent Command Plane (agents/commands/)
|
|||||||
+---> Audit
|
+---> Audit
|
||||||
| |
|
| |
|
||||||
| v
|
| v
|
||||||
| audit/agent-runs/ (per-agent log)
|
| audit/agent-runs/ (per-agent log)
|
||||||
| audit/command-history/ (per-command log)
|
| audit/command-history/ (per-command log)
|
||||||
| audit/version-history/ (per-baseline log)
|
| audit/version-history/ (per-baseline log)
|
||||||
| audit/interaction-history/ (agent conversation traces)
|
|
||||||
|
|
|
|
||||||
+---> Git Commit
|
+---> Git Commit
|
||||||
|
|
|
|
||||||
@@ -45,39 +45,8 @@ Agent Command Plane (agents/commands/)
|
|||||||
5. Git commit locks the audit record
|
5. Git commit locks the audit record
|
||||||
6. Web UI displays timeline from git history
|
6. Web UI displays timeline from git history
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
audit/
|
|
||||||
├── agent-runs/ # Per-execution records (JSON)
|
|
||||||
├── command-history/ # Command lifecycle history
|
|
||||||
├── version-history/ # Version/baseline change log
|
|
||||||
└── interaction-history/ # Agent conversation traces
|
|
||||||
```
|
|
||||||
|
|
||||||
## Audit Record Schema
|
## Audit Record Schema
|
||||||
|
|
||||||
Current agent-run records use the following fields:
|
|
||||||
|
|
||||||
| Field | Description |
|
|
||||||
|-------|-------------|
|
|
||||||
| agent_run_id | Unique execution ID |
|
|
||||||
| command | Command file name |
|
|
||||||
| agent | Executing agent name |
|
|
||||||
| version | Baseline version at execution time |
|
|
||||||
| objective | Human-readable goal |
|
|
||||||
| start_time | ISO-8601 start timestamp |
|
|
||||||
| end_time | ISO-8601 completion timestamp |
|
|
||||||
| interaction_count | Agent-owner interaction turns |
|
|
||||||
| tool_call_count | Tool invocations during execution |
|
|
||||||
| changed_files | Files modified in this run |
|
|
||||||
| commit_sha | Git commit hash |
|
|
||||||
| test_result | Test verification outcome |
|
|
||||||
| final_result | SUCCESS / FAILED / PARTIAL |
|
|
||||||
| evidence_path | Link to evidence directory |
|
|
||||||
|
|
||||||
Legacy records remain valid and may use the following compatibility shape:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "",
|
"id": "",
|
||||||
@@ -103,6 +72,51 @@ Legacy records remain valid and may use the following compatibility shape:
|
|||||||
- Every code change must be traceable to who/what agent created it
|
- Every code change must be traceable to who/what agent created it
|
||||||
- No agent-created changes without audit trail
|
- No agent-created changes without audit trail
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Agent Command Plane](../agents/README.md)
|
||||||
|
- [Baseline System](baseline-system.md)
|
||||||
|
=======
|
||||||
|
# AIOS Agent Audit Plane
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Every agent action is recorded. All code changes are traceable to:
|
||||||
|
- Who made the change
|
||||||
|
- Why the change was made
|
||||||
|
- When it was made
|
||||||
|
- Which version introduced it
|
||||||
|
- What test results showed
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
audit/
|
||||||
|
├── agent-runs/ # Per-execution records (JSON)
|
||||||
|
├── command-history/ # Command lifecycle history
|
||||||
|
├── version-history/ # Version/baseline change log
|
||||||
|
└── interaction-history/ # Agent conversation traces
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent Run Record
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| agent_run_id | Unique execution ID |
|
||||||
|
| command | Command file name |
|
||||||
|
| agent | Executing agent name |
|
||||||
|
| version | Baseline version at execution time |
|
||||||
|
| objective | Human-readable goal |
|
||||||
|
| start_time | ISO-8601 start timestamp |
|
||||||
|
| end_time | ISO-8601 completion timestamp |
|
||||||
|
| interaction_count | Agent-owner interaction turns |
|
||||||
|
| tool_call_count | Tool invocations during execution |
|
||||||
|
| changed_files | Files modified in this run |
|
||||||
|
| commit_sha | Git commit hash |
|
||||||
|
| test_result | Test verification outcome |
|
||||||
|
| final_result | SUCCESS / FAILED / PARTIAL |
|
||||||
|
| evidence_path | Link to evidence directory |
|
||||||
|
|
||||||
## Version History
|
## Version History
|
||||||
|
|
||||||
Records baseline promotions, rollbacks, and version changes.
|
Records baseline promotions, rollbacks, and version changes.
|
||||||
@@ -113,9 +127,6 @@ Conversation logs between agent and owner for each command execution.
|
|||||||
|
|
||||||
## Audit Trail Requirement
|
## Audit Trail Requirement
|
||||||
|
|
||||||
All changes are tracked via Git with signed commits where possible. Every commit references the originating Command ID.
|
All changes tracked via Git with signed commits where possible.
|
||||||
|
Every commit message references the originating Command ID.
|
||||||
## Related
|
>>>>>>> 4d18ee0 (feat(cmd): enable Agent Command Plane, audit plane, and Web UI dashboard)
|
||||||
|
|
||||||
- [Agent Command Plane](../agents/README.md)
|
|
||||||
- [Baseline System](baseline-system.md)
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
# AIOS V1R5 to V2R1 Migration Plan
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Move from shared Gateway assumptions to Tenant Cell isolation.
|
|
||||||
|
|
||||||
## Order
|
|
||||||
|
|
||||||
1. Establish Tenant Registry and signed Tenant Context.
|
|
||||||
2. Implement Tenant Router.
|
|
||||||
3. Implement OpenClaw Cell lifecycle management.
|
|
||||||
4. Introduce canonical message envelope.
|
|
||||||
5. Add provenance and execution policy enforcement.
|
|
||||||
6. Migrate Hermes into shared fabric with isolated profiles.
|
|
||||||
7. Migrate memory, secrets and audit storage.
|
|
||||||
|
|
||||||
## Do Not
|
|
||||||
|
|
||||||
- Do not modify Hermes first.
|
|
||||||
- Do not share production credentials across tenants.
|
|
||||||
- Do not treat workspace separation as a sandbox.
|
|
||||||
- Do not allow unknown message origins to execute tools.
|
|
||||||
|
|
||||||
## Acceptance
|
|
||||||
|
|
||||||
Each migration stage requires automated tests and runtime evidence before promotion.
|
|
||||||
-49
@@ -1,49 +0,0 @@
|
|||||||
# AIOS V2R1 OpenClaw Tenant Cell + Hermes Shared Agent Fabric Architecture
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Release train: V2R1C0
|
|
||||||
|
|
||||||
Purpose: replace shared runtime trust assumptions with tenant-isolated Cell architecture.
|
|
||||||
|
|
||||||
## Principles
|
|
||||||
|
|
||||||
1. OpenClaw Gateway is a trust boundary.
|
|
||||||
2. Each production tenant receives an isolated Cell.
|
|
||||||
3. Hermes provides shared scheduling/fabric capability but not shared tenant state.
|
|
||||||
4. Tenant identity, origin, session and task scope are mandatory in every event.
|
|
||||||
5. Unknown provenance fails closed.
|
|
||||||
|
|
||||||
## Target Components
|
|
||||||
|
|
||||||
- Tenant Router
|
|
||||||
- OpenClaw Cell Manager
|
|
||||||
- Canonical Message Envelope
|
|
||||||
- Tenant Inbox/Outbox Ledger
|
|
||||||
- Secret Scope Manager
|
|
||||||
- Memory Namespace Manager
|
|
||||||
- Hermes Profile Worker Fabric
|
|
||||||
- Sandbox Execution Layer
|
|
||||||
- Audit Event Pipeline
|
|
||||||
|
|
||||||
## Migration Order
|
|
||||||
|
|
||||||
Phase 1: Tenant Router + Cell Manager
|
|
||||||
|
|
||||||
Phase 2: Canonical Message and Policy Layer
|
|
||||||
|
|
||||||
Phase 3: Hermes Shared Agent Fabric
|
|
||||||
|
|
||||||
Phase 4: Memory, Secret and Audit isolation
|
|
||||||
|
|
||||||
Phase 5: Production tenant migration
|
|
||||||
|
|
||||||
## Release Gate
|
|
||||||
|
|
||||||
A V2R1 production release requires:
|
|
||||||
|
|
||||||
- tenant isolation tests passed
|
|
||||||
- message provenance tests passed
|
|
||||||
- runtime commit matches release commit
|
|
||||||
- OpenClaw plugin runtime inspection passed
|
|
||||||
- rollback verified
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
# V1R4C5 Acceptance Evidence
|
|
||||||
|
|
||||||
Date: 2026-07-11
|
|
||||||
|
|
||||||
Status: release candidate evidence in progress
|
|
||||||
|
|
||||||
Current active baseline: V1R4C3
|
|
||||||
|
|
||||||
V1R4C5 has not been promoted yet.
|
|
||||||
|
|
||||||
## Owner Decision
|
|
||||||
|
|
||||||
Owner approved continued V1R4C5 work under the following constraints:
|
|
||||||
|
|
||||||
```text
|
|
||||||
允许无 force-push 的分支 reconcile
|
|
||||||
memory search 保持关闭
|
|
||||||
先完成 SR-01/SR-02/数据隔离验收
|
|
||||||
再进入 merge/deploy/tag owner gate
|
|
||||||
```
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
V1R4C5 release candidate scope:
|
|
||||||
|
|
||||||
1. SR-01 Gitea reliability and branch consistency guardrails.
|
|
||||||
2. SR-02 watcher/stuck command cleanup.
|
|
||||||
3. User data isolation redline and enforcement gates.
|
|
||||||
4. SR-04 admin workbench read-only visibility preparation only.
|
|
||||||
|
|
||||||
Explicitly excluded from V1R4C5 activation:
|
|
||||||
|
|
||||||
- memory search activation;
|
|
||||||
- full SR-04 portal production rollout;
|
|
||||||
- bulk merge of old test/smoke/auth branches;
|
|
||||||
- force push reconciliation;
|
|
||||||
- Gitea DB mutation without owner/admin approval.
|
|
||||||
|
|
||||||
## Runtime Acceptance Evidence
|
|
||||||
|
|
||||||
### Runtime status
|
|
||||||
|
|
||||||
Observed:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"service": "aios-gitops-model-api",
|
|
||||||
"status": "active",
|
|
||||||
"workspace_branch": "patch/v1r4c5-runtime-acceptance-clean-20260711-01"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Baseline
|
|
||||||
|
|
||||||
Observed:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"current_baseline": "V1R4C3",
|
|
||||||
"previous_baseline": "V1R3C1",
|
|
||||||
"promoted_by": "openclaw"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Clean createBranch acceptance
|
|
||||||
|
|
||||||
Branch:
|
|
||||||
|
|
||||||
```text
|
|
||||||
test/v1r4c5-runtime-acceptance-clean-20260711-01
|
|
||||||
```
|
|
||||||
|
|
||||||
Result:
|
|
||||||
|
|
||||||
```text
|
|
||||||
status: ok
|
|
||||||
commit: 6ae7058
|
|
||||||
sync: synced
|
|
||||||
```
|
|
||||||
|
|
||||||
### Clean createPatch acceptance
|
|
||||||
|
|
||||||
Patch:
|
|
||||||
|
|
||||||
```text
|
|
||||||
patch/v1r4c5-runtime-acceptance-clean-20260711-01
|
|
||||||
```
|
|
||||||
|
|
||||||
Patch id:
|
|
||||||
|
|
||||||
```text
|
|
||||||
patch-1783706123789-f5ca8b69
|
|
||||||
```
|
|
||||||
|
|
||||||
Commit:
|
|
||||||
|
|
||||||
```text
|
|
||||||
ba0391e
|
|
||||||
```
|
|
||||||
|
|
||||||
Result:
|
|
||||||
|
|
||||||
```text
|
|
||||||
synced
|
|
||||||
```
|
|
||||||
|
|
||||||
## SR-01 Evidence
|
|
||||||
|
|
||||||
Known accepted evidence:
|
|
||||||
|
|
||||||
- Gitea branch consistency sentinel deployed.
|
|
||||||
- Sentinel reports:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "healthy",
|
|
||||||
"unhealthy_checks": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- systemd timer runs every 5 minutes.
|
|
||||||
- service runs with:
|
|
||||||
|
|
||||||
```text
|
|
||||||
--emit-safe-mode --json
|
|
||||||
```
|
|
||||||
|
|
||||||
- broken symlink issue resolved by replacing the external reliability symlink with workspace-local files.
|
|
||||||
|
|
||||||
Required before final promotion:
|
|
||||||
|
|
||||||
- confirm latest sentinel report remains healthy after acceptance writes.
|
|
||||||
|
|
||||||
## SR-02 Evidence
|
|
||||||
|
|
||||||
Known accepted evidence:
|
|
||||||
|
|
||||||
```text
|
|
||||||
11 / 11 tests pass
|
|
||||||
```
|
|
||||||
|
|
||||||
Fix summary:
|
|
||||||
|
|
||||||
- stuck command detection reads parsed timestamps through `data.get('ts', 0)`;
|
|
||||||
- `.md` fixture mtime is aligned with stored stuck timestamp via `os.utime(path, (ts, ts))`;
|
|
||||||
- behavior is consistent across `.json` and `.md` state formats.
|
|
||||||
|
|
||||||
Required before final promotion:
|
|
||||||
|
|
||||||
- re-run SR-02 tests on the final integration candidate.
|
|
||||||
|
|
||||||
## User Data Isolation Evidence
|
|
||||||
|
|
||||||
Implemented controls:
|
|
||||||
|
|
||||||
- user data isolation redline;
|
|
||||||
- memory scope guard;
|
|
||||||
- provider gate;
|
|
||||||
- activation gate;
|
|
||||||
- audit service/timer;
|
|
||||||
- CI acceptance scripts;
|
|
||||||
- memory search activation check.
|
|
||||||
|
|
||||||
Required safety state:
|
|
||||||
|
|
||||||
```text
|
|
||||||
AIOS_MEMORY_ENABLED=false
|
|
||||||
```
|
|
||||||
|
|
||||||
Required before memory/search activation:
|
|
||||||
|
|
||||||
- isolation audit healthy;
|
|
||||||
- no denied records;
|
|
||||||
- no invalid records;
|
|
||||||
- provider filters by scope before ranking;
|
|
||||||
- provenance required;
|
|
||||||
- global index disabled;
|
|
||||||
- activation gate returns allowed;
|
|
||||||
- explicit owner approval.
|
|
||||||
|
|
||||||
V1R4C5 does not approve memory search activation.
|
|
||||||
|
|
||||||
## SR-04 Admin Workbench Evidence
|
|
||||||
|
|
||||||
Current user-visible gap is confirmed:
|
|
||||||
|
|
||||||
- administrator cannot yet see task progress;
|
|
||||||
- administrator cannot yet see flow/process diagrams;
|
|
||||||
- SR-04 remains activation-ready but gated.
|
|
||||||
|
|
||||||
V1R4C5 scope allows only read-only visibility preparation and acceptance design, not full production rollout.
|
|
||||||
|
|
||||||
Required before SR-04 deployment:
|
|
||||||
|
|
||||||
- SR-01 green;
|
|
||||||
- SR-02 green;
|
|
||||||
- user data isolation gate accepted;
|
|
||||||
- admin RBAC read-only check green.
|
|
||||||
|
|
||||||
## Remaining Required Gates Before Promotion
|
|
||||||
|
|
||||||
- [ ] Latest Gitea sentinel report remains healthy.
|
|
||||||
- [ ] SR-02 watcher tests pass on final candidate.
|
|
||||||
- [ ] User data isolation acceptance passes.
|
|
||||||
- [ ] Admin workbench read-only RBAC acceptance passes or remains explicitly deferred.
|
|
||||||
- [ ] No force-push was used.
|
|
||||||
- [ ] Memory search remains disabled.
|
|
||||||
- [ ] Owner approves merge/deploy/tag/promotion.
|
|
||||||
|
|
||||||
## Promotion Rule
|
|
||||||
|
|
||||||
V1R4C5 may not be promoted until all required gates are green or explicitly deferred with owner approval.
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# V1R4C5 Final Owner Approval
|
|
||||||
|
|
||||||
Date: 2026-07-11
|
|
||||||
|
|
||||||
Status: approved for final promotion
|
|
||||||
|
|
||||||
## Owner Decision
|
|
||||||
|
|
||||||
Owner approved final V1R4C5 promotion.
|
|
||||||
|
|
||||||
Memory search must remain disabled.
|
|
||||||
|
|
||||||
```text
|
|
||||||
允许最终晋升
|
|
||||||
继续保持 memory search 关闭
|
|
||||||
```
|
|
||||||
|
|
||||||
## Promotion Scope
|
|
||||||
|
|
||||||
Approved for V1R4C5:
|
|
||||||
|
|
||||||
1. SR-01 Gitea reliability and branch consistency guardrails.
|
|
||||||
2. SR-02 watcher/stuck command cleanup.
|
|
||||||
3. User data isolation redline and enforcement gates.
|
|
||||||
4. SR-04 admin workbench read-only visibility preparation only.
|
|
||||||
|
|
||||||
## Explicit Non-Activation
|
|
||||||
|
|
||||||
The following are not approved by this promotion:
|
|
||||||
|
|
||||||
```text
|
|
||||||
memory search activation
|
|
||||||
full cross-session memory recall
|
|
||||||
global vector index
|
|
||||||
full SR-04 production portal rollout beyond read-only/admin visibility preparation
|
|
||||||
bulk merge of old test/smoke/auth branches
|
|
||||||
force-push based reconciliation
|
|
||||||
```
|
|
||||||
|
|
||||||
## Required Runtime State
|
|
||||||
|
|
||||||
Memory remains disabled:
|
|
||||||
|
|
||||||
```text
|
|
||||||
AIOS_MEMORY_ENABLED=false
|
|
||||||
```
|
|
||||||
|
|
||||||
If a provider or deployment attempts to enable memory/search, it must be blocked unless a future owner approval explicitly allows it after the user-data isolation activation gate returns allowed.
|
|
||||||
|
|
||||||
## Final Promotion Rule
|
|
||||||
|
|
||||||
V1R4C5 may proceed to tag/promotion under owner gate.
|
|
||||||
|
|
||||||
If any final gate fails, promotion must stop and memory/search must remain disabled.
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
# V1R4C5 Release to Main Reconcile Plan
|
|
||||||
|
|
||||||
Date: 2026-07-11
|
|
||||||
|
|
||||||
Status: reconcile preparation
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Bring the V1R4C5 release state back into `main` without rewriting history and without moving the immutable V1R4C5 tag.
|
|
||||||
|
|
||||||
## Source of Truth
|
|
||||||
|
|
||||||
Release baseline:
|
|
||||||
|
|
||||||
```text
|
|
||||||
V1R4C5
|
|
||||||
```
|
|
||||||
|
|
||||||
Promotion commit:
|
|
||||||
|
|
||||||
```text
|
|
||||||
a32c76a3c1baa49562e53fb8adce10a5ed0e6704
|
|
||||||
```
|
|
||||||
|
|
||||||
Tag:
|
|
||||||
|
|
||||||
```text
|
|
||||||
V1R4C5
|
|
||||||
```
|
|
||||||
|
|
||||||
Tag policy:
|
|
||||||
|
|
||||||
```text
|
|
||||||
immutable
|
|
||||||
```
|
|
||||||
|
|
||||||
## Reconcile Rules
|
|
||||||
|
|
||||||
Allowed:
|
|
||||||
|
|
||||||
- normal merge commits;
|
|
||||||
- fast-forward where possible;
|
|
||||||
- conflict resolution with review;
|
|
||||||
- updating main to contain released C5 state.
|
|
||||||
|
|
||||||
Forbidden:
|
|
||||||
|
|
||||||
- force-push;
|
|
||||||
- tag movement;
|
|
||||||
- rewriting release history;
|
|
||||||
- importing V1R4C6 feature code into this reconcile.
|
|
||||||
|
|
||||||
## Expected Included C5 Content
|
|
||||||
|
|
||||||
- SR-01 Gitea reliability guardrails.
|
|
||||||
- Gitea branch consistency sentinel.
|
|
||||||
- SR-02 watcher state cleanup.
|
|
||||||
- User Data Isolation gate.
|
|
||||||
- Reliability governance documents.
|
|
||||||
- Admin Workbench read-only preparation records.
|
|
||||||
|
|
||||||
## Expected Excluded Content
|
|
||||||
|
|
||||||
Do not include:
|
|
||||||
|
|
||||||
- V1R4C6 Admin Workbench implementation code.
|
|
||||||
- SR-03 Search Gateway implementation.
|
|
||||||
- Agent Platform integration.
|
|
||||||
- Memory search activation.
|
|
||||||
- Global vector index.
|
|
||||||
|
|
||||||
## Validation Before Main Merge
|
|
||||||
|
|
||||||
Required:
|
|
||||||
|
|
||||||
```text
|
|
||||||
getBaseline.current_baseline == V1R4C5
|
|
||||||
V1R4C5 tag unchanged
|
|
||||||
memory search disabled
|
|
||||||
Gitea sentinel healthy
|
|
||||||
SR-02 tests pass
|
|
||||||
main merge does not include V1R4C6 branches
|
|
||||||
```
|
|
||||||
|
|
||||||
## Post Merge Validation
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```text
|
|
||||||
getRuntimeStatus
|
|
||||||
getBaseline
|
|
||||||
createBranch
|
|
||||||
createPatch
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
|
|
||||||
```text
|
|
||||||
main contains V1R4C5 release state
|
|
||||||
active baseline remains V1R4C5
|
|
||||||
```
|
|
||||||
|
|
||||||
## Owner Gate
|
|
||||||
|
|
||||||
Normal merge to `main` may proceed after validation.
|
|
||||||
|
|
||||||
No tag creation or baseline promotion is required because V1R4C5 is already promoted.
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
# V1R4C6 Acceptance Checklist
|
|
||||||
|
|
||||||
Date: 2026-07-11
|
|
||||||
|
|
||||||
Base baseline: V1R4C5
|
|
||||||
|
|
||||||
Promotion blocked until every mandatory gate is green.
|
|
||||||
|
|
||||||
## Global Preflight
|
|
||||||
|
|
||||||
- [ ] `getRuntimeStatus.status == active`
|
|
||||||
- [ ] `getBaseline.current_baseline == V1R4C5`
|
|
||||||
- [ ] `getBaseline.git == a32c76a3c1baa49562e53fb8adce10a5ed0e6704`
|
|
||||||
- [ ] `AIOS_GITOPS_BARE_REPO` points to `/data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios.git`
|
|
||||||
- [ ] Gitea consistency sentinel reports `healthy`
|
|
||||||
- [ ] `AIOS_MEMORY_ENABLED=false`
|
|
||||||
- [ ] no force-push used
|
|
||||||
|
|
||||||
## Gate A — Admin Workbench / Portal AIOS
|
|
||||||
|
|
||||||
### Route/API
|
|
||||||
|
|
||||||
- [ ] `/portal/me/aios/manifest` returns authenticated module manifest
|
|
||||||
- [ ] `/portal/me/aios/dashboard` returns admin-authorized dashboard data
|
|
||||||
- [ ] `/portal/me/aios/health` returns system health data
|
|
||||||
- [ ] failed/empty backend data produces explicit empty/error state
|
|
||||||
|
|
||||||
### UI
|
|
||||||
|
|
||||||
- [ ] admin sees task progress
|
|
||||||
- [ ] admin sees process/flow diagram entry
|
|
||||||
- [ ] admin sees system health
|
|
||||||
- [ ] admin sees authorized agent observability
|
|
||||||
- [ ] user does not see admin-only modules
|
|
||||||
- [ ] admin does not see owner-only modules without owner role
|
|
||||||
|
|
||||||
### Isolation
|
|
||||||
|
|
||||||
- [ ] no cross-user data
|
|
||||||
- [ ] no cross-project data
|
|
||||||
- [ ] no cross-session data
|
|
||||||
- [ ] no cross-tenant/group data
|
|
||||||
|
|
||||||
Gate A result: `PASS / FAIL / DEFER`
|
|
||||||
|
|
||||||
## Gate B — SR-03 Search Gateway
|
|
||||||
|
|
||||||
### Provider Binding
|
|
||||||
|
|
||||||
- [ ] real provider configured and reachable
|
|
||||||
- [ ] provider errors return safe non-memory fallback
|
|
||||||
- [ ] timeout/retry/circuit-breaker behavior verified
|
|
||||||
|
|
||||||
### Scope and Provenance
|
|
||||||
|
|
||||||
- [ ] scope filter occurs before ranking
|
|
||||||
- [ ] provenance returned for accepted results
|
|
||||||
- [ ] denied/out-of-scope content never reaches ranking or summary
|
|
||||||
- [ ] global memory/vector index disabled
|
|
||||||
- [ ] memory search remains disabled
|
|
||||||
|
|
||||||
### Audit
|
|
||||||
|
|
||||||
- [ ] search request scope is logged without leaking content
|
|
||||||
- [ ] provider/fallback decision is auditable
|
|
||||||
- [ ] denied scope mismatch is auditable
|
|
||||||
|
|
||||||
Gate B result: `PASS / FAIL / DEFER`
|
|
||||||
|
|
||||||
## Gate C — Agent Platform
|
|
||||||
|
|
||||||
### Event Bus
|
|
||||||
|
|
||||||
- [ ] event envelope includes tenant/group, user, project, session and agent scope
|
|
||||||
- [ ] cross-scope subscribers cannot receive events
|
|
||||||
- [ ] failed delivery is observable and retry-bounded
|
|
||||||
|
|
||||||
### GPT Message Plane
|
|
||||||
|
|
||||||
- [ ] message envelope includes scope and provenance
|
|
||||||
- [ ] messages cannot cross user/project/session boundaries
|
|
||||||
- [ ] denied messages do not forward content
|
|
||||||
|
|
||||||
### Controller Dispatch
|
|
||||||
|
|
||||||
- [ ] dispatch stops when autonomous safe mode is active
|
|
||||||
- [ ] consequential actions require owner gate
|
|
||||||
- [ ] duplicate dispatch is idempotent or rejected
|
|
||||||
- [ ] stuck state cleanup remains green
|
|
||||||
|
|
||||||
### Observability
|
|
||||||
|
|
||||||
- [ ] admin can see platform status without unrestricted content access
|
|
||||||
- [ ] failures and queue state are visible
|
|
||||||
|
|
||||||
Gate C result: `PASS / FAIL / DEFER`
|
|
||||||
|
|
||||||
## Combined Runtime Acceptance
|
|
||||||
|
|
||||||
Run after integration and deployment:
|
|
||||||
|
|
||||||
```text
|
|
||||||
getRuntimeStatus
|
|
||||||
getBaseline
|
|
||||||
createBranch
|
|
||||||
createPatch
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected before promotion:
|
|
||||||
|
|
||||||
```text
|
|
||||||
runtime = active
|
|
||||||
baseline = V1R4C5
|
|
||||||
write branch = committed + synced
|
|
||||||
write patch = committed + synced
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rollback Validation
|
|
||||||
|
|
||||||
- [ ] Group A can disable workbench routes/menu entries
|
|
||||||
- [ ] Group B can disable provider binding and use safe non-memory fallback
|
|
||||||
- [ ] Group C can disable expanded dispatch
|
|
||||||
- [ ] all rollback modes preserve `AIOS_MEMORY_ENABLED=false`
|
|
||||||
|
|
||||||
## Release Decision
|
|
||||||
|
|
||||||
V1R4C6 is tag-ready only when:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Global preflight PASS
|
|
||||||
AND Gate A PASS
|
|
||||||
AND Gate B PASS
|
|
||||||
AND Gate C PASS
|
|
||||||
AND combined runtime acceptance PASS
|
|
||||||
```
|
|
||||||
|
|
||||||
Any failed mandatory gate blocks tag, deployment promotion, and baseline promotion.
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# V1R4C6 Acceptance Evidence
|
|
||||||
|
|
||||||
Date: 2026-07-11
|
|
||||||
|
|
||||||
## Merge
|
|
||||||
|
|
||||||
- **PR #6** (merge commit `ae49bf0fa35b0f599a96a4d12644576447f39cec`): V1R4C6 release-candidate control artifacts merged to main.
|
|
||||||
- **Result**: PASS
|
|
||||||
- 10 files (1440 lines) added: release docs, patch records, ops commands, version metadata
|
|
||||||
- No V1R4C6 tag created
|
|
||||||
- No baseline promotion
|
|
||||||
- Memory search remains disabled
|
|
||||||
- Force-push remains disabled
|
|
||||||
|
|
||||||
## Verified State (post-merge)
|
|
||||||
|
|
||||||
| Check | Result |
|
|
||||||
|--------------------------|--------|
|
|
||||||
| main HEAD | ae49bf0fa35b0f599a96a4d12644576447f39cec |
|
|
||||||
| V1R4C5 tag | Still at d457ac396147043ed5d0a3fd8b44bcb10f48bceb (not moved) |
|
|
||||||
| V1R4C6 tag | absent |
|
|
||||||
| baseline/current.json | current_baseline = V1R4C5, git = a32c76a3c1baa49562e53fb8adce10a5ed0e6704 |
|
|
||||||
| memory_search_enabled | false |
|
|
||||||
| force_push_allowed | false |
|
|
||||||
| versions/V1R4C5.json | exists |
|
|
||||||
| versions/V1R4C6.json | exists |
|
|
||||||
|
|
||||||
## Gates
|
|
||||||
|
|
||||||
| Gate | Status | Notes |
|
|
||||||
|------------------------------|---------|--------------------------------|
|
|
||||||
| control artifacts merge | PASS | PR #6 merged to main |
|
|
||||||
| Gate A — Admin Workbench | NOT RUN | Engineering integration pending|
|
|
||||||
| Gate B — SR-03 Search Gateway| NOT RUN | Engineering integration pending|
|
|
||||||
| Gate C — Agent Platform | NOT RUN | Engineering integration pending|
|
|
||||||
| tests | NOT RUN | No test suite executed |
|
|
||||||
| artifact digest | NOT CREATED | No build performed |
|
|
||||||
| deployment | NOT RUN | No deploy performed |
|
|
||||||
| production verification | NOT RUN | No production verification |
|
|
||||||
|
|
||||||
## Rollback
|
|
||||||
|
|
||||||
Revert command (creates new branch and PR, non-destructive):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git revert -m 1 ae49bf0fa35b0f599a96a4d12644576447f39cec
|
|
||||||
```
|
|
||||||
|
|
||||||
## Current Overall Status
|
|
||||||
|
|
||||||
**BLOCKED / INTEGRATION PENDING** — all engineering gates remain unexecuted.
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# V1R4C6 Blocker: Baseline Visibility Discrepancy
|
|
||||||
|
|
||||||
Date: 2026-07-11
|
|
||||||
|
|
||||||
Status: blocking merge/deploy/tag
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
OpenClaw/operator reported V1R4C5 baseline promotion completed successfully, but the ChatGPT GitOps action view still reports the active baseline as V1R4C3.
|
|
||||||
|
|
||||||
V1R4C6 must not proceed to merge/deploy/tag until this view is reconciled.
|
|
||||||
|
|
||||||
## Operator Reported State
|
|
||||||
|
|
||||||
Reported by operator:
|
|
||||||
|
|
||||||
```text
|
|
||||||
current_baseline = V1R4C5
|
|
||||||
previous_baseline = V1R4C3
|
|
||||||
promotion_git = a32c76a3c1baa49562e53fb8adce10a5ed0e6704
|
|
||||||
promoted_by = openclaw/admin
|
|
||||||
memory_search_enabled = false
|
|
||||||
force_push_allowed = false
|
|
||||||
gitea_db_mutation_requires_owner_approval = true
|
|
||||||
```
|
|
||||||
|
|
||||||
## ChatGPT GitOps Action Observed State
|
|
||||||
|
|
||||||
Observed through `getBaseline`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
current_baseline = V1R4C3
|
|
||||||
previous_baseline = V1R3C1
|
|
||||||
git = f5435e308794c737f3e39de3f6b79807486943c3
|
|
||||||
promoted_by = openclaw
|
|
||||||
```
|
|
||||||
|
|
||||||
Observed through `listVersions`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
V1R4C5 tag exists
|
|
||||||
versions_dir does not list canonical V1R4C5.json
|
|
||||||
workspace_branch = release/v1r4c6-admin-search-agent-integration-20260711
|
|
||||||
```
|
|
||||||
|
|
||||||
## Risk
|
|
||||||
|
|
||||||
If V1R4C6 proceeds while different controllers disagree on the active baseline, the release may be built against the wrong base and post-deployment verification may be invalid.
|
|
||||||
|
|
||||||
## Required Reconciliation
|
|
||||||
|
|
||||||
Before continuing V1R4C6:
|
|
||||||
|
|
||||||
1. Confirm the GitOps API used by ChatGPT reads the same `baseline/current.json` updated by OpenClaw.
|
|
||||||
2. Confirm the API service has reloaded or refreshed baseline state.
|
|
||||||
3. Confirm canonical `versions/V1R4C5.json` is visible to the action runtime or intentionally explain why it is not required.
|
|
||||||
4. Re-run:
|
|
||||||
|
|
||||||
```text
|
|
||||||
getBaseline
|
|
||||||
listVersions
|
|
||||||
getRuntimeStatus
|
|
||||||
```
|
|
||||||
|
|
||||||
Required result:
|
|
||||||
|
|
||||||
```text
|
|
||||||
getBaseline.current_baseline = V1R4C5
|
|
||||||
listVersions.tags contains V1R4C5
|
|
||||||
memory search remains disabled
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hard Stop
|
|
||||||
|
|
||||||
Do not create V1R4C6 tag.
|
|
||||||
Do not promote V1R4C6 baseline.
|
|
||||||
Do not deploy V1R4C6.
|
|
||||||
Do not enable memory search.
|
|
||||||
|
|
||||||
Proceed only after baseline visibility is reconciled.
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
# V1R4C6 Context Isolation Recovery and Resume Decision
|
|
||||||
|
|
||||||
Date: 2026-07-11
|
|
||||||
|
|
||||||
Status: approved to resume merge/deploy/verification; stop before tag/promotion
|
|
||||||
|
|
||||||
## Recovery Evidence
|
|
||||||
|
|
||||||
The unrelated OpenClaw/WeChat report was traced to notification/task isolation weaknesses rather than GitOps baseline corruption.
|
|
||||||
|
|
||||||
Actions completed:
|
|
||||||
|
|
||||||
1. The legacy `AIOS每日自治报告` cron was disabled and retained for forensic review.
|
|
||||||
2. The expired `2026-07-06-reminder` cron was archived and deleted after confirming no pending dependency.
|
|
||||||
3. Pending notification queue depth was verified as zero.
|
|
||||||
4. Unknown/unscoped queued message count was verified as zero.
|
|
||||||
5. A scoped V1R4C6 WeChat test notification was sent successfully.
|
|
||||||
|
|
||||||
The test notification included:
|
|
||||||
|
|
||||||
```text
|
|
||||||
task_id = V1R4C6-integration
|
|
||||||
release_id = V1R4C6
|
|
||||||
branch = release/v1r4c6-admin-search-agent-integration-20260711
|
|
||||||
baseline = V1R4C5
|
|
||||||
correlation_id = 70308f5e243ab81d
|
|
||||||
```
|
|
||||||
|
|
||||||
No recipient identifier, token, cookie, or unrelated message content is stored in this release record.
|
|
||||||
|
|
||||||
## Resume Decision
|
|
||||||
|
|
||||||
V1R4C6 may resume:
|
|
||||||
|
|
||||||
```text
|
|
||||||
merge
|
|
||||||
component tests
|
|
||||||
security/isolation tests
|
|
||||||
deployment
|
|
||||||
production verification
|
|
||||||
release evidence updates
|
|
||||||
```
|
|
||||||
|
|
||||||
V1R4C6 must still stop before:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Git tag creation
|
|
||||||
baseline promotion
|
|
||||||
memory search activation
|
|
||||||
```
|
|
||||||
|
|
||||||
## Required Execution Context
|
|
||||||
|
|
||||||
Before every OpenClaw execution or WeChat notification, assert:
|
|
||||||
|
|
||||||
```text
|
|
||||||
task_id = V1R4C6-integration
|
|
||||||
release_id = V1R4C6
|
|
||||||
branch = release/v1r4c6-admin-search-agent-integration-20260711
|
|
||||||
baseline = V1R4C5
|
|
||||||
memory_search_enabled = false
|
|
||||||
```
|
|
||||||
|
|
||||||
Any mismatch re-enters safe mode and blocks execution/notification.
|
|
||||||
|
|
||||||
## Hard Constraints
|
|
||||||
|
|
||||||
```text
|
|
||||||
no force-push
|
|
||||||
no Gitea DB mutation
|
|
||||||
no V1R4C5 tag movement
|
|
||||||
no global memory/vector index
|
|
||||||
no cross-user/project/session notification or task data
|
|
||||||
```
|
|
||||||
|
|
||||||
## Owner Gate
|
|
||||||
|
|
||||||
After Gate A, Gate B, Gate C, deployment verification, runtime acceptance, and Gitea sentinel checks pass, OpenClaw must request explicit owner approval through the scoped WeChat channel for both:
|
|
||||||
|
|
||||||
1. Create V1R4C6 Git tag.
|
|
||||||
2. Promote active baseline from V1R4C5 to V1R4C6.
|
|
||||||
|
|
||||||
Ambiguous acknowledgements do not count as approval.
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
# V1R4C6 Integration Merge Matrix
|
|
||||||
|
|
||||||
Date: 2026-07-11
|
|
||||||
|
|
||||||
Status: integration-ready
|
|
||||||
|
|
||||||
Base baseline: V1R4C5
|
|
||||||
|
|
||||||
Base baseline git: `a32c76a3c1baa49562e53fb8adce10a5ed0e6704`
|
|
||||||
|
|
||||||
## Release Objective
|
|
||||||
|
|
||||||
Deliver three capability groups in one controlled release train:
|
|
||||||
|
|
||||||
1. Admin Workbench and Portal AIOS read-only rollout.
|
|
||||||
2. SR-03 Search Gateway real provider binding.
|
|
||||||
3. Agent Platform Integration.
|
|
||||||
|
|
||||||
This is a bundled release train, not an uncontrolled bulk merge. Each group has an independent acceptance gate and rollback switch.
|
|
||||||
|
|
||||||
## Group A — Admin Workbench and Portal AIOS
|
|
||||||
|
|
||||||
| Branch | Capability | Decision |
|
|
||||||
|---|---|---|
|
|
||||||
| `feature/v1r4c4-sr04-admin-workbench-visibility-20260711` | Admin workbench task progress and process visibility | integrate |
|
|
||||||
| `feature/v1r4c4-sr04-portal-aios-visibility-20260710` | Portal AIOS route/menu visibility | integrate |
|
|
||||||
| `patch/user-portal-me-route-diagnostics-20260709` | `/portal/me/aios/manifest`, dashboard and health route contract | integrate after route review |
|
|
||||||
| `patch/user-portal-rbac-ops-dashboard` | Admin/group-admin RBAC dashboard | integrate after RBAC review |
|
|
||||||
| `patch/agent-observability-webui` | Agent observability UI | integrate read-only |
|
|
||||||
| `patch/web-ui-complete-gitops-dashboard-20260706` | GitOps dashboard UI | integrate only if not superseded by the newer workbench implementation |
|
|
||||||
|
|
||||||
### Required user-visible result
|
|
||||||
|
|
||||||
Administrators can see:
|
|
||||||
|
|
||||||
- task progress;
|
|
||||||
- task/flow/process diagram entry;
|
|
||||||
- system health;
|
|
||||||
- authorized agent observability;
|
|
||||||
- explicit empty/error states.
|
|
||||||
|
|
||||||
### Security constraints
|
|
||||||
|
|
||||||
- Read-only first.
|
|
||||||
- RBAC is enforced server-side and client-side.
|
|
||||||
- No cross-user, cross-project, cross-session, or cross-tenant data exposure.
|
|
||||||
- Owner-only modules remain hidden from non-owner administrators.
|
|
||||||
|
|
||||||
## Group B — SR-03 Search Gateway Real Provider Binding
|
|
||||||
|
|
||||||
| Branch | Capability | Decision |
|
|
||||||
|---|---|---|
|
|
||||||
| `feature/v1r4c4-sr02-search-gateway-resilience-20260710` | Gateway resilience and safe fallback | integrate |
|
|
||||||
| `patch/search-gateway-governed-general-capability` | Governed provider binding and policy surface | integrate after scope review |
|
|
||||||
| `patch/gitops-change-archive-audit` | Change/archive audit evidence | integrate or retain as supporting audit module |
|
|
||||||
|
|
||||||
### Required result
|
|
||||||
|
|
||||||
- Real provider binding works.
|
|
||||||
- Query scope is enforced before ranking.
|
|
||||||
- Accepted records include provenance.
|
|
||||||
- Provider failure falls back to `text-only`, `no-memory`, or `scoped-only`.
|
|
||||||
- Global memory/vector indexes are not used.
|
|
||||||
|
|
||||||
### Security constraints
|
|
||||||
|
|
||||||
```text
|
|
||||||
AIOS_MEMORY_ENABLED=false
|
|
||||||
```
|
|
||||||
|
|
||||||
Search Gateway provider binding must not implicitly enable memory search.
|
|
||||||
|
|
||||||
## Group C — Agent Platform Integration
|
|
||||||
|
|
||||||
| Branch | Capability | Decision |
|
|
||||||
|---|---|---|
|
|
||||||
| `patch/agent-event-bus-unified` | Unified scoped agent event bus | integrate |
|
|
||||||
| `patch/agent-gpt-message-plane` | Scoped Agent/GPT message plane | integrate |
|
|
||||||
| `patch/autonomous-controller-dispatch-20260709` | Autonomous controller dispatch | integrate behind safe-mode gate |
|
|
||||||
| `patch/self-evolution-agent-orchestration-policy` | Agent orchestration and governance policy | integrate |
|
|
||||||
| `patch/aios-autonomous-orchestrator-architecture-review-v1` | Architecture record | integrate as documentation or mark superseded after review |
|
|
||||||
|
|
||||||
### Required result
|
|
||||||
|
|
||||||
- Agent event and message envelopes include user/project/session scope.
|
|
||||||
- Controller dispatch stops when autonomous safe mode is active.
|
|
||||||
- Consequential actions remain owner-gated.
|
|
||||||
- Observability is available before autonomous expansion.
|
|
||||||
|
|
||||||
## Explicitly Excluded
|
|
||||||
|
|
||||||
Do not merge or activate in V1R4C6:
|
|
||||||
|
|
||||||
- memory search activation;
|
|
||||||
- global vector index;
|
|
||||||
- cross-session/global recall;
|
|
||||||
- unscoped search or message delivery;
|
|
||||||
- old test/smoke/auth-only branches;
|
|
||||||
- old V1R2/V1R3 promotion requests;
|
|
||||||
- force-push reconciliation;
|
|
||||||
- Gitea database mutation.
|
|
||||||
|
|
||||||
## Merge and Deployment Order
|
|
||||||
|
|
||||||
```text
|
|
||||||
1. Verify V1R4C5 baseline and bare-repo configuration.
|
|
||||||
2. Integrate Group A read-only workbench/portal surface.
|
|
||||||
3. Run Group A RBAC and visibility acceptance.
|
|
||||||
4. Integrate Group B provider binding under isolation gate.
|
|
||||||
5. Run Group B scope/provenance/fallback acceptance.
|
|
||||||
6. Integrate Group C event/message/dispatch capabilities.
|
|
||||||
7. Run Group C scope/safe-mode/owner-gate acceptance.
|
|
||||||
8. Run combined runtime acceptance.
|
|
||||||
9. Prepare V1R4C6 tag and promotion request.
|
|
||||||
10. Stop for owner confirmation before tag/promotion.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Combined Acceptance
|
|
||||||
|
|
||||||
Required:
|
|
||||||
|
|
||||||
```text
|
|
||||||
getRuntimeStatus -> active
|
|
||||||
getBaseline -> V1R4C5 before promotion
|
|
||||||
createBranch -> committed and synced
|
|
||||||
createPatch -> committed and synced
|
|
||||||
Gitea branch sentinel -> healthy
|
|
||||||
memory search -> disabled
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rollback Switches
|
|
||||||
|
|
||||||
- Group A: disable new workbench routes/menu entries.
|
|
||||||
- Group B: disable real provider binding and use safe non-memory fallback.
|
|
||||||
- Group C: disable expanded dispatch and keep event/message plane observational.
|
|
||||||
- Memory search remains disabled in all rollback modes.
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# V1R4C6 Release Acceptance Checklist
|
|
||||||
|
|
||||||
Date: 2026-07-11
|
|
||||||
|
|
||||||
## Gate A — Admin Workbench / Portal AIOS
|
|
||||||
|
|
||||||
| Check | Status | Notes |
|
|
||||||
|--------------------------------|---------|--------------------------------|
|
|
||||||
| Admin workbench RBAC read-only | NOT RUN | |
|
|
||||||
| Portal AIOS manifest route | NOT RUN | |
|
|
||||||
| Portal AIOS dashboard route | NOT RUN | |
|
|
||||||
| Flow/process diagram renders | NOT RUN | |
|
|
||||||
| Portal AIOS health route | NOT RUN | |
|
|
||||||
|
|
||||||
## Gate B — SR-03 Search Gateway
|
|
||||||
|
|
||||||
| Check | Status | Notes |
|
|
||||||
|------------------------------------|---------|--------------------------------|
|
|
||||||
| Provider binding correctness | NOT RUN | |
|
|
||||||
| Scope-before-ranking validation | NOT RUN | |
|
|
||||||
| Provenance correctness | NOT RUN | |
|
|
||||||
| Fallback to disabled provider | NOT RUN | |
|
|
||||||
|
|
||||||
## Gate C — Agent Platform
|
|
||||||
|
|
||||||
| Check | Status | Notes |
|
|
||||||
|------------------------------------|---------|--------------------------------|
|
|
||||||
| Event scope isolation | NOT RUN | |
|
|
||||||
| Message plane scope isolation | NOT RUN | |
|
|
||||||
| Dispatch safe-mode respects control | NOT RUN | |
|
|
||||||
| Owner gate for consequential ops | NOT RUN | |
|
|
||||||
|
|
||||||
## Final Deployment Gates
|
|
||||||
|
|
||||||
| Check | Status | Notes |
|
|
||||||
|---------------------------|-----------|--------------------------------|
|
|
||||||
| Tag V1R4C6 | NO | Requires owner approval |
|
|
||||||
| Baseline promotion | NO | Requires owner approval |
|
|
||||||
| Memory search enabled | NO | Requires future owner approval |
|
|
||||||
|
|
||||||
## Active Constraints
|
|
||||||
|
|
||||||
- AIOS_MEMORY_ENABLED: false
|
|
||||||
- Force-push: forbidden
|
|
||||||
- Gitea DB mutation: owner approval required
|
|
||||||
- Memory search: disabled (future owner approval required for enable)
|
|
||||||
|
|
||||||
## Artifact
|
|
||||||
|
|
||||||
- **PR #6 merge commit**: `ae49bf0fa35b0f599a96a4d12644576447f39cec`
|
|
||||||
- **Source baseline**: V1R4C5 (commit a32c76a3c1baa49562e53fb8adce10a5ed0e6704)
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
# V1R4C6 Release Gate Status
|
|
||||||
|
|
||||||
Date: 2026-07-11
|
|
||||||
|
|
||||||
Status: integration pending — release-candidate control artifacts merged, engineering gates not yet executed
|
|
||||||
|
|
||||||
## Completed
|
|
||||||
|
|
||||||
- V1R4C5 baseline verified as active base.
|
|
||||||
- GitOps API and public API return:
|
|
||||||
- current_baseline: V1R4C5
|
|
||||||
- previous_baseline: V1R4C3
|
|
||||||
- git: a32c76a3c1baa49562e53fb8adce10a5ed0e6704
|
|
||||||
- GitOps bare repository path corrected to active `aios.git`.
|
|
||||||
- V1R4C6 merge matrix created.
|
|
||||||
- V1R4C6 acceptance checklist created.
|
|
||||||
- V1R4C6 acceptance evidence created.
|
|
||||||
- PR #6: control artifacts merge — PASS
|
|
||||||
|
|
||||||
## Result Summary
|
|
||||||
|
|
||||||
| Item | Status |
|
|
||||||
|-------------------------|-----------|
|
|
||||||
| control artifacts merge | PASS |
|
|
||||||
| Gate A | NOT RUN |
|
|
||||||
| Gate B | NOT RUN |
|
|
||||||
| Gate C | NOT RUN |
|
|
||||||
| tests | NOT RUN |
|
|
||||||
| artifact | NOT CREATED |
|
|
||||||
| deployment | NOT RUN |
|
|
||||||
| production verification | NOT RUN |
|
|
||||||
| tag created | NO |
|
|
||||||
| baseline promoted | NO |
|
|
||||||
| memory search enabled | NO |
|
|
||||||
| **overall** | **BLOCKED / INTEGRATION PENDING** |
|
|
||||||
|
|
||||||
## Verified Artifact
|
|
||||||
|
|
||||||
- **PR #6 merge commit**: `ae49bf0fa35b0f599a96a4d12644576447f39cec`
|
|
||||||
- **Current baseline**: V1R4C5
|
|
||||||
- **V1R4C6 tag**: absent
|
|
||||||
- **Memory search**: disabled
|
|
||||||
- **Force-push**: disabled
|
|
||||||
|
|
||||||
## Remaining Gates (not yet executed)
|
|
||||||
|
|
||||||
### Gate A — Admin Workbench / Portal AIOS
|
|
||||||
|
|
||||||
- merge candidate review;
|
|
||||||
- RBAC acceptance;
|
|
||||||
- task progress visibility;
|
|
||||||
- flow diagram visibility;
|
|
||||||
- health/observability validation.
|
|
||||||
|
|
||||||
### Gate B — SR-03 Search Gateway
|
|
||||||
|
|
||||||
- provider binding validation;
|
|
||||||
- scope-before-ranking validation;
|
|
||||||
- provenance validation;
|
|
||||||
- fallback validation.
|
|
||||||
|
|
||||||
### Gate C — Agent Platform
|
|
||||||
|
|
||||||
- event scope validation;
|
|
||||||
- message plane scope validation;
|
|
||||||
- dispatch safe-mode validation;
|
|
||||||
- owner gate validation.
|
|
||||||
|
|
||||||
## Release Stop Point
|
|
||||||
|
|
||||||
After Gates A/B/C pass:
|
|
||||||
|
|
||||||
```text
|
|
||||||
prepare V1R4C6 tag
|
|
||||||
↓
|
|
||||||
owner confirmation required
|
|
||||||
↓
|
|
||||||
tag creation
|
|
||||||
↓
|
|
||||||
baseline promotion
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hard Constraints
|
|
||||||
|
|
||||||
Remain:
|
|
||||||
|
|
||||||
```text
|
|
||||||
AIOS_MEMORY_ENABLED=false
|
|
||||||
memory search disabled
|
|
||||||
force-push forbidden
|
|
||||||
gitea DB mutation requires owner approval
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# User Data Isolation Redline
|
||||||
|
|
||||||
|
Status: mandatory
|
||||||
|
|
||||||
|
Decision date: 2026-07-10
|
||||||
|
|
||||||
|
Baseline at decision: V1R4C3
|
||||||
|
|
||||||
|
## Redline
|
||||||
|
|
||||||
|
User data isolation is a hard system-design requirement.
|
||||||
|
|
||||||
|
No component may read, write, search, rank, summarize, cache, or report data from another user, tenant, project, session, workspace, branch, agent, or task context unless explicit authorization and context binding exist.
|
||||||
|
|
||||||
|
This applies to memory search, filesystem logs, vector indexes, search gateways, autonomous controller state, task records, audit logs, artifacts, and any fallback provider.
|
||||||
|
|
||||||
|
## Incident Class
|
||||||
|
|
||||||
|
Any cross-context data contamination is a security and reliability incident, not a normal feature bug.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- memory search returns records from another project;
|
||||||
|
- shared workspace logs are used as current-session evidence;
|
||||||
|
- global `memory/` state mixes unrelated users or tasks;
|
||||||
|
- fallback provider uses unscoped records;
|
||||||
|
- agent reports status based on stale or unrelated context;
|
||||||
|
- shared cache leaks content across users, groups, or sessions.
|
||||||
|
|
||||||
|
## Core Rule
|
||||||
|
|
||||||
|
A query may only access records satisfying all required scope dimensions:
|
||||||
|
|
||||||
|
```text
|
||||||
|
user_id
|
||||||
|
AND tenant_id / group_id
|
||||||
|
AND session_id or conversation_id
|
||||||
|
AND project_id
|
||||||
|
AND workspace_id
|
||||||
|
AND branch or baseline scope
|
||||||
|
AND agent_id / tool_context when applicable
|
||||||
|
```
|
||||||
|
|
||||||
|
If a dimension is unavailable, the safe behavior is deny-by-default or text-only fallback.
|
||||||
|
|
||||||
|
## Forbidden Design
|
||||||
|
|
||||||
|
The following are forbidden in production paths:
|
||||||
|
|
||||||
|
1. Global shared memory directories without per-scope namespace.
|
||||||
|
2. Search over untagged records.
|
||||||
|
3. Search over records whose owner/scope cannot be proven.
|
||||||
|
4. Trusting filesystem location alone as authorization.
|
||||||
|
5. Treating memory search results as authoritative without provenance.
|
||||||
|
6. Cross-session fallback without explicit allowlist.
|
||||||
|
7. Auto-reporting from memory results that are not bound to the current runtime/baseline/task.
|
||||||
|
8. Using stale context from unrelated branches or projects as current-state evidence.
|
||||||
|
|
||||||
|
## Required Design
|
||||||
|
|
||||||
|
Every persisted memory/state record must include immutable metadata:
|
||||||
|
|
||||||
|
```text
|
||||||
|
record_id
|
||||||
|
user_id
|
||||||
|
tenant_id or group_id
|
||||||
|
session_id / conversation_id
|
||||||
|
project_id
|
||||||
|
workspace_id
|
||||||
|
branch
|
||||||
|
baseline
|
||||||
|
agent_id
|
||||||
|
source_tool
|
||||||
|
created_at
|
||||||
|
schema_version
|
||||||
|
sensitivity
|
||||||
|
retention_class
|
||||||
|
```
|
||||||
|
|
||||||
|
Every read/search request must include a scope envelope:
|
||||||
|
|
||||||
|
```text
|
||||||
|
request_user_id
|
||||||
|
request_tenant_id / group_id
|
||||||
|
request_session_id / conversation_id
|
||||||
|
request_project_id
|
||||||
|
request_workspace_id
|
||||||
|
allowed_branches / current_branch
|
||||||
|
allowed_baselines / current_baseline
|
||||||
|
purpose
|
||||||
|
```
|
||||||
|
|
||||||
|
A record is readable only if its metadata matches the request scope policy.
|
||||||
|
|
||||||
|
## Enforcement Layers
|
||||||
|
|
||||||
|
### 1. Storage namespace isolation
|
||||||
|
|
||||||
|
Memory/state paths must be namespaced:
|
||||||
|
|
||||||
|
```text
|
||||||
|
memory/{tenant_id}/{user_id}/{project_id}/{session_id}/...
|
||||||
|
state/{tenant_id}/{project_id}/{workspace_id}/...
|
||||||
|
```
|
||||||
|
|
||||||
|
No production component may write unrelated contexts into a single global `memory/` directory.
|
||||||
|
|
||||||
|
### 2. Metadata enforcement
|
||||||
|
|
||||||
|
Untagged records are unreadable by default.
|
||||||
|
|
||||||
|
Records without owner/scope metadata may be archived for forensic review but must not be searched or used for live reasoning.
|
||||||
|
|
||||||
|
### 3. Query-time ACL
|
||||||
|
|
||||||
|
Search must apply a mandatory scope filter before ranking or summarization.
|
||||||
|
|
||||||
|
The ranking stage must never receive out-of-scope records.
|
||||||
|
|
||||||
|
### 4. Provenance requirement
|
||||||
|
|
||||||
|
Every memory/search result used for operational reporting must carry provenance:
|
||||||
|
|
||||||
|
```text
|
||||||
|
record_id
|
||||||
|
scope metadata
|
||||||
|
source path or source index
|
||||||
|
match reason
|
||||||
|
created_at
|
||||||
|
```
|
||||||
|
|
||||||
|
Responses must prefer current runtime truth sources over memory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
getRuntimeStatus
|
||||||
|
getBaseline
|
||||||
|
listPatches
|
||||||
|
explicit user-provided current context
|
||||||
|
current branch files
|
||||||
|
```
|
||||||
|
|
||||||
|
Memory search is advisory only after scope validation.
|
||||||
|
|
||||||
|
### 5. Fallback safety
|
||||||
|
|
||||||
|
If provider search cannot prove scope isolation, the system must use text-only fallback or no-memory fallback.
|
||||||
|
|
||||||
|
Fallback must not silently broaden the scope.
|
||||||
|
|
||||||
|
### 6. Write-path isolation
|
||||||
|
|
||||||
|
Writers must not append current-session records into shared directories.
|
||||||
|
|
||||||
|
Writers must derive the target path from the scope envelope, not from a global default.
|
||||||
|
|
||||||
|
### 7. Audit and detection
|
||||||
|
|
||||||
|
The system must produce audit events when:
|
||||||
|
|
||||||
|
- a record is denied due to scope mismatch;
|
||||||
|
- untagged memory is encountered;
|
||||||
|
- fallback disables memory search;
|
||||||
|
- a provider tries to return out-of-scope records;
|
||||||
|
- cross-context contamination is detected.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
A memory/search provider is allowed only if all tests pass:
|
||||||
|
|
||||||
|
1. Same user, same project, same session -> records are visible.
|
||||||
|
2. Same user, same project, different session -> visible only if policy permits.
|
||||||
|
3. Same user, different project -> denied by default.
|
||||||
|
4. Different user, same project -> denied unless explicitly shared by group policy.
|
||||||
|
5. Different tenant/group -> denied.
|
||||||
|
6. Untagged record -> denied.
|
||||||
|
7. Stale branch/baseline record -> not used as current evidence.
|
||||||
|
8. Provider fallback without scope support -> memory disabled.
|
||||||
|
9. Search result provenance is returned and logged.
|
||||||
|
10. Out-of-scope records are filtered before ranking/summarization.
|
||||||
|
|
||||||
|
## Runtime Rule
|
||||||
|
|
||||||
|
If memory search is enabled without these controls, recurrence is possible.
|
||||||
|
|
||||||
|
Therefore, memory search must remain disabled or in scoped allowlist mode until the isolation acceptance suite is green.
|
||||||
|
|
||||||
|
## Owner Gate
|
||||||
|
|
||||||
|
Any change that enables shared memory search, cross-session recall, global vector indexes, or cross-agent memory reuse requires owner/admin approval.
|
||||||
|
|
||||||
|
## Final Rule
|
||||||
|
|
||||||
|
User data isolation is a redline. Reliability, convenience, or recall quality may not override it.
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
apiVersion: aios.gitops/v1
|
|
||||||
kind: ExecutionOrder
|
|
||||||
metadata:
|
|
||||||
name: openclaw-v1r4c6-merge-tests-deploy
|
|
||||||
date: 2026-07-11
|
|
||||||
owner_approval:
|
|
||||||
branch: request/openclaw-v1r4c6-merge-deploy-20260711
|
|
||||||
file: approvals/openclaw-v1r4c6-merge-deploy-owner-approval.yaml
|
|
||||||
commit: 4653f92
|
|
||||||
spec:
|
|
||||||
objective: >-
|
|
||||||
Integrate the approved V1R4C6 Admin Workbench, Search Gateway, and Agent Platform
|
|
||||||
changes into a clean candidate based on the latest main, execute all mandatory gates,
|
|
||||||
merge to main only after success, then deploy and verify production.
|
|
||||||
confirmation_policy: no_additional_confirmation_required
|
|
||||||
source:
|
|
||||||
primary_branch: release/v1r4c6-admin-search-agent-integration-20260711
|
|
||||||
supporting_domains:
|
|
||||||
- admin-workbench-portal-aios
|
|
||||||
- search-gateway-governance
|
|
||||||
- agent-platform-integration
|
|
||||||
target:
|
|
||||||
integration_branch: integration/v1r4c6-merge-deploy-20260711-01
|
|
||||||
final_branch: main
|
|
||||||
release_version: V1R4C6
|
|
||||||
execution:
|
|
||||||
- id: preflight
|
|
||||||
actions:
|
|
||||||
- fetch_all_refs_and_tags
|
|
||||||
- verify_workspace_clean
|
|
||||||
- reset_integration_candidate_to_latest_main
|
|
||||||
- verify_baseline_is_V1R4C5
|
|
||||||
- verify_memory_search_enabled_is_false
|
|
||||||
- verify_V1R4C5_tag_unchanged
|
|
||||||
- record_main_head_and_source_head
|
|
||||||
stop_conditions:
|
|
||||||
- dirty_workspace
|
|
||||||
- missing_source_branch
|
|
||||||
- baseline_not_V1R4C5
|
|
||||||
- protected_setting_changed
|
|
||||||
- id: semantic-diff-review
|
|
||||||
actions:
|
|
||||||
- compute_three_dot_diff_from_main_to_primary_branch
|
|
||||||
- classify_changes_by_admin_search_agent_domains
|
|
||||||
- detect_duplicate_control_artifacts_already_merged_by_PR_6
|
|
||||||
- preserve_PR_7_NOT_RUN_status_until_tests_execute
|
|
||||||
- reject_reversion_of_V1R4C5_baseline_metadata
|
|
||||||
- reject_enablement_of_memory_search
|
|
||||||
output:
|
|
||||||
- execution/evidence/v1r4c6-semantic-diff-report.md
|
|
||||||
- id: merge-candidate
|
|
||||||
actions:
|
|
||||||
- merge_primary_branch_into_integration_candidate_without_fast_forward
|
|
||||||
- resolve_text_conflicts_by_preserving_latest_main_control_metadata
|
|
||||||
- preserve_source_business_functionality_when_not_conflicting_with_safety_controls
|
|
||||||
- commit_resolved_candidate
|
|
||||||
forbidden:
|
|
||||||
- force_push
|
|
||||||
- direct_commit_to_main
|
|
||||||
- bypass_branch_protection
|
|
||||||
- bypass_owner_gate
|
|
||||||
- weaken_rbac_or_scope_isolation
|
|
||||||
- mark_unexecuted_tests_as_passed
|
|
||||||
- id: gate-a-admin
|
|
||||||
required_checks:
|
|
||||||
- admin_workbench_routes_and_assets_load
|
|
||||||
- portal_aios_manifest_dashboard_health_endpoints
|
|
||||||
- server_side_and_frontend_rbac
|
|
||||||
- empty_error_and_loading_states
|
|
||||||
- tenant_user_project_session_isolation
|
|
||||||
- id: gate-b-search
|
|
||||||
required_checks:
|
|
||||||
- real_provider_binding_or_explicit_safe_fallback
|
|
||||||
- timeout_limited_retry_and_safe_fallback
|
|
||||||
- scope_filter_before_ranking
|
|
||||||
- provenance_in_results
|
|
||||||
- provider_and_fallback_audit
|
|
||||||
- unauthorized_results_never_enter_ranking_or_summary
|
|
||||||
- memory_search_remains_disabled
|
|
||||||
- id: gate-c-agent
|
|
||||||
required_checks:
|
|
||||||
- unified_agent_event_bus
|
|
||||||
- scoped_agent_gpt_message_plane
|
|
||||||
- autonomous_controller_dispatch
|
|
||||||
- safe_mode_stops_dispatch_and_change
|
|
||||||
- high_risk_actions_remain_owner_gated
|
|
||||||
- duplicate_dispatch_is_idempotent_or_rejected
|
|
||||||
- queue_failure_retry_observability
|
|
||||||
- id: runtime-and-regression
|
|
||||||
actions:
|
|
||||||
- run_getRuntimeStatus
|
|
||||||
- run_getBaseline
|
|
||||||
- run_createBranch_smoke_test
|
|
||||||
- run_createPatch_smoke_test
|
|
||||||
- run_full_regression_suite
|
|
||||||
- run_deployment_preflight_health_checks
|
|
||||||
expected:
|
|
||||||
reads: "200 OK with service, status, workspace_branch"
|
|
||||||
writes: "200 OK with branch, commit, rev, sync_status"
|
|
||||||
- id: promote-main
|
|
||||||
conditions:
|
|
||||||
- gate_a_passed
|
|
||||||
- gate_b_passed
|
|
||||||
- gate_c_passed
|
|
||||||
- runtime_acceptance_passed
|
|
||||||
- regression_passed
|
|
||||||
- deployment_preflight_passed
|
|
||||||
actions:
|
|
||||||
- open_or_update_merge_request_from_integration_candidate_to_main
|
|
||||||
- merge_without_rewriting_history
|
|
||||||
- record_new_main_head
|
|
||||||
- update_versions_V1R4C6_json_to_release_ready_or_active_baseline_as_policy_requires
|
|
||||||
- update_baseline_current_json_only_after_successful_promotion
|
|
||||||
- create_V1R4C6_tag_according_to_repository_tag_semantics
|
|
||||||
- id: deploy-and-verify
|
|
||||||
actions:
|
|
||||||
- trigger_direct_deployment_from_new_main
|
|
||||||
- verify_service_health
|
|
||||||
- verify_admin_search_agent_smoke_paths
|
|
||||||
- verify_runtime_status_and_baseline_after_deploy
|
|
||||||
- verify_rollback_artifact_and_previous_baseline_are_available
|
|
||||||
- publish_production_verification_report
|
|
||||||
failure_policy:
|
|
||||||
mode: fail_closed
|
|
||||||
on_any_failure:
|
|
||||||
- stop_pipeline
|
|
||||||
- do_not_update_main
|
|
||||||
- do_not_create_or_move_release_tag
|
|
||||||
- do_not_deploy
|
|
||||||
- preserve_integration_branch_for_forensics
|
|
||||||
- commit_failure_evidence_and_exact_blocker
|
|
||||||
reporting:
|
|
||||||
required_files:
|
|
||||||
- execution/evidence/v1r4c6-semantic-diff-report.md
|
|
||||||
- execution/evidence/gate-a-admin-result.md
|
|
||||||
- execution/evidence/gate-b-search-result.md
|
|
||||||
- execution/evidence/gate-c-agent-result.md
|
|
||||||
- execution/evidence/runtime-acceptance-result.md
|
|
||||||
- execution/evidence/regression-result.md
|
|
||||||
- execution/evidence/deployment-result.md
|
|
||||||
- execution/evidence/production-verification-result.md
|
|
||||||
final_summary_fields:
|
|
||||||
- source_head
|
|
||||||
- integration_head
|
|
||||||
- main_head_before
|
|
||||||
- main_head_after
|
|
||||||
- merge_commit
|
|
||||||
- release_tag
|
|
||||||
- deployment_id
|
|
||||||
- gate_results
|
|
||||||
- production_health
|
|
||||||
- rollback_ready
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import {
|
|
||||||
classifyRun,
|
|
||||||
decideToolCall,
|
|
||||||
runKey,
|
|
||||||
} from "./policy.js";
|
|
||||||
|
|
||||||
const activeRuns = new Map();
|
|
||||||
const MAX_RUN_AGE_MS = 60 * 60 * 1000;
|
|
||||||
|
|
||||||
function prune(now = Date.now()) {
|
|
||||||
for (const [key, value] of activeRuns.entries()) {
|
|
||||||
if (now - value.recordedAt > MAX_RUN_AGE_MS) activeRuns.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
id: "aios-tenant-boundary",
|
|
||||||
name: "AIOS Tenant Boundary",
|
|
||||||
description: "Fail-closed user-origin and side-effect policy for Tenant Cells.",
|
|
||||||
|
|
||||||
register(api) {
|
|
||||||
api.on(
|
|
||||||
"before_agent_run",
|
|
||||||
async (event, ctx) => {
|
|
||||||
prune();
|
|
||||||
const config = event.context?.pluginConfig ?? api.pluginConfig ?? {};
|
|
||||||
const trust = classifyRun(ctx, config);
|
|
||||||
const key = runKey(event, ctx);
|
|
||||||
if (key) {
|
|
||||||
activeRuns.set(key, { ...trust, recordedAt: Date.now() });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
config.blockUnknownAgentRuns === true &&
|
|
||||||
trust.trusted !== true &&
|
|
||||||
trust.reason !== "job_id_present"
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
outcome: "block",
|
|
||||||
reason: trust.reason,
|
|
||||||
message: "This message cannot be verified as coming from the active authenticated user.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { outcome: "pass" };
|
|
||||||
},
|
|
||||||
{ priority: 100 },
|
|
||||||
);
|
|
||||||
|
|
||||||
api.on(
|
|
||||||
"before_tool_call",
|
|
||||||
async (event, ctx) => {
|
|
||||||
prune();
|
|
||||||
const config = event.context?.pluginConfig ?? api.pluginConfig ?? {};
|
|
||||||
const key = runKey(event, ctx);
|
|
||||||
const trust = key ? activeRuns.get(key) : undefined;
|
|
||||||
const decision = decideToolCall(event.toolName, trust, config);
|
|
||||||
|
|
||||||
if (decision.action === "block") {
|
|
||||||
return {
|
|
||||||
block: true,
|
|
||||||
blockReason: `AIOS V2R1 origin policy denied tool call: ${decision.code}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (decision.action === "approval") {
|
|
||||||
return {
|
|
||||||
requireApproval: {
|
|
||||||
title: `Approve ${String(event.toolName)}`,
|
|
||||||
description: "Authenticated user requested a side-effecting tool call in this Tenant Cell.",
|
|
||||||
severity: "warning",
|
|
||||||
timeoutMs: Number(config.approvalTimeoutMs ?? 60000),
|
|
||||||
allowedDecisions: ["allow-once", "deny"],
|
|
||||||
pluginId: "aios-tenant-boundary",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
{ priority: 100 },
|
|
||||||
);
|
|
||||||
|
|
||||||
api.on("agent_end", async (event, ctx) => {
|
|
||||||
const key = runKey(event, ctx);
|
|
||||||
if (key) activeRuns.delete(key);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
const DEFAULT_READ_ONLY_TOOLS = Object.freeze([
|
|
||||||
"read", "grep", "glob", "find", "ls", "web_search", "web_fetch",
|
|
||||||
"memory_search", "sessions_list", "sessions_history", "session_status",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function strings(value) {
|
|
||||||
return Array.isArray(value)
|
|
||||||
? value.filter((item) => typeof item === "string" && item.trim())
|
|
||||||
.map((item) => item.trim())
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveSenderId(ctx = {}) {
|
|
||||||
const direct = typeof ctx.senderId === "string" ? ctx.senderId.trim() : "";
|
|
||||||
const nested = typeof ctx.channelContext?.sender?.id === "string"
|
|
||||||
? ctx.channelContext.sender.id.trim()
|
|
||||||
: "";
|
|
||||||
return direct || nested || undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function classifyRun(ctx = {}, config = {}) {
|
|
||||||
const senderId = resolveSenderId(ctx);
|
|
||||||
const trusted = new Set(strings(config.trustedSenderIds));
|
|
||||||
const blocked = new Set(strings(config.blockedSenderIds));
|
|
||||||
|
|
||||||
if (typeof ctx.jobId === "string" && ctx.jobId) {
|
|
||||||
return { trusted: false, origin: "automation", reason: "job_id_present" };
|
|
||||||
}
|
|
||||||
if (!senderId) {
|
|
||||||
return { trusted: false, origin: "system_or_unknown", reason: "sender_id_missing" };
|
|
||||||
}
|
|
||||||
if (blocked.has(senderId)) {
|
|
||||||
return { trusted: false, origin: "blocked_sender", reason: "sender_blocked", senderId };
|
|
||||||
}
|
|
||||||
if (config.requireExplicitTrustedSenders !== false && !trusted.has(senderId)) {
|
|
||||||
return { trusted: false, origin: "untrusted_sender", reason: "sender_not_allowlisted", senderId };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
trusted: true,
|
|
||||||
origin: "authenticated_channel_user",
|
|
||||||
reason: "sender_verified",
|
|
||||||
senderId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decideToolCall(toolName, runTrust, config = {}) {
|
|
||||||
const name = typeof toolName === "string" ? toolName.trim().toLowerCase() : "";
|
|
||||||
const configured = strings(config.readOnlyTools).map((item) => item.toLowerCase());
|
|
||||||
const readOnly = new Set(configured.length ? configured : DEFAULT_READ_ONLY_TOOLS);
|
|
||||||
if (!name) {
|
|
||||||
return { action: "block", code: "tool_name_missing" };
|
|
||||||
}
|
|
||||||
if (readOnly.has(name)) {
|
|
||||||
return { action: "allow", code: "read_only" };
|
|
||||||
}
|
|
||||||
if (!runTrust || runTrust.trusted !== true) {
|
|
||||||
return {
|
|
||||||
action: "block",
|
|
||||||
code: runTrust?.reason || "run_provenance_missing",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (config.requireApprovalForHumanSideEffects === false) {
|
|
||||||
return { action: "allow", code: "trusted_side_effect" };
|
|
||||||
}
|
|
||||||
return { action: "approval", code: "human_side_effect" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runKey(event = {}, ctx = {}) {
|
|
||||||
const key = event.runId || ctx.runId;
|
|
||||||
return typeof key === "string" && key.trim() ? key.trim() : undefined;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "aios-tenant-boundary",
|
|
||||||
"name": "AIOS Tenant Boundary",
|
|
||||||
"description": "Enforces AIOS V2R1 tenant provenance and tool policy boundaries.",
|
|
||||||
"version": "2.1.0-rc1",
|
|
||||||
"entry": "dist/index.js",
|
|
||||||
"configSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"requireExplicitTrustedSenders": { "type": "boolean", "default": true },
|
|
||||||
"trustedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
|
||||||
"blockedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
|
||||||
"readOnlyTools": { "type": "array", "items": { "type": "string" } },
|
|
||||||
"requireApprovalForHumanSideEffects": { "type": "boolean", "default": true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@aios/openclaw-tenant-boundary",
|
|
||||||
"version": "2.1.0-rc.1",
|
|
||||||
"type": "module",
|
|
||||||
"description": "AIOS V2R1 Tenant Cell provenance and side-effect policy boundary for OpenClaw.",
|
|
||||||
"files": [
|
|
||||||
"dist",
|
|
||||||
"openclaw.plugin.json"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"test": "node --test test/*.test.js"
|
|
||||||
},
|
|
||||||
"openclaw": {
|
|
||||||
"extensions": [
|
|
||||||
"./dist/index.js"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,429 +0,0 @@
|
|||||||
# CMD-20260711-v1r4c6-integrate-deploy-verify
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Perform the actual controlled integration, deployment, and verification for V1R4C6.
|
|
||||||
|
|
||||||
V1R4C6 includes:
|
|
||||||
|
|
||||||
1. Admin Workbench / Portal AIOS read-only rollout.
|
|
||||||
2. SR-03 Search Gateway real provider binding.
|
|
||||||
3. Agent Platform Integration.
|
|
||||||
|
|
||||||
Stop before V1R4C6 Git tag creation and baseline promotion. Those operations require owner confirmation.
|
|
||||||
|
|
||||||
## Hard Constraints
|
|
||||||
|
|
||||||
```text
|
|
||||||
AIOS_MEMORY_ENABLED=false
|
|
||||||
memory search remains disabled
|
|
||||||
no global vector index
|
|
||||||
no force-push
|
|
||||||
no V1R4C5 tag movement
|
|
||||||
no Gitea DB mutation
|
|
||||||
no secret plaintext in Git, logs, or reports
|
|
||||||
```
|
|
||||||
|
|
||||||
If any mandatory security or reliability check fails, stop deployment and report the blocker. Do not bypass the gate.
|
|
||||||
|
|
||||||
## Verified Base
|
|
||||||
|
|
||||||
Required before starting:
|
|
||||||
|
|
||||||
```text
|
|
||||||
current_baseline = V1R4C5
|
|
||||||
previous_baseline = V1R4C3
|
|
||||||
baseline_git = a32c76a3c1baa49562e53fb8adce10a5ed0e6704
|
|
||||||
AIOS_GITOPS_BARE_REPO = /data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios.git
|
|
||||||
runtime = active
|
|
||||||
```
|
|
||||||
|
|
||||||
## Phase 0 — Preflight and Backup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT=/data/aios-gitops/workspace
|
|
||||||
SOURCE_RELEASE=release/v1r4c6-admin-search-agent-integration-20260711
|
|
||||||
EXEC_BRANCH=release/v1r4c6-integration-exec-20260711
|
|
||||||
|
|
||||||
cd "$ROOT"
|
|
||||||
git fetch origin --tags --prune
|
|
||||||
|
|
||||||
git status --short
|
|
||||||
|
|
||||||
test -f baseline/current.json
|
|
||||||
python3 - <<'PY'
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
p = Path('baseline/current.json')
|
|
||||||
d = json.loads(p.read_text(encoding='utf-8'))
|
|
||||||
assert d['current_baseline'] == 'V1R4C5', d
|
|
||||||
assert d['previous_baseline'] == 'V1R4C3', d
|
|
||||||
assert d['git'] == 'a32c76a3c1baa49562e53fb8adce10a5ed0e6704', d
|
|
||||||
assert d.get('constraints', {}).get('memory_search_enabled') is False, d
|
|
||||||
print('baseline preflight: PASS')
|
|
||||||
PY
|
|
||||||
|
|
||||||
test -d /data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios.git
|
|
||||||
|
|
||||||
BACKUP=/data/aios-gitops/runtime/backups/v1r4c6-predeploy-$(date +%Y%m%d-%H%M%S)
|
|
||||||
mkdir -p "$BACKUP"
|
|
||||||
cp -a baseline/current.json "$BACKUP/current.json"
|
|
||||||
cp -a versions/V1R4C5.json "$BACKUP/V1R4C5.json"
|
|
||||||
|
|
||||||
if git show-ref --verify --quiet "refs/heads/$EXEC_BRANCH"; then
|
|
||||||
git checkout "$EXEC_BRANCH"
|
|
||||||
git pull --ff-only origin "$EXEC_BRANCH"
|
|
||||||
else
|
|
||||||
git checkout -b "$EXEC_BRANCH" "origin/$SOURCE_RELEASE"
|
|
||||||
git push -u origin "$EXEC_BRANCH"
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
Never use `git push --force` or `git tag -f`.
|
|
||||||
|
|
||||||
## Phase 1 — Merge Group A: Admin Workbench / Portal AIOS
|
|
||||||
|
|
||||||
Merge in this order:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git merge --no-ff origin/feature/v1r4c4-sr04-admin-workbench-visibility-20260711 \
|
|
||||||
-m 'merge(v1r4c6): admin workbench visibility preparation'
|
|
||||||
|
|
||||||
git merge --no-ff origin/feature/v1r4c4-sr04-portal-aios-visibility-20260710 \
|
|
||||||
-m 'merge(v1r4c6): portal AIOS visibility preparation'
|
|
||||||
|
|
||||||
git merge --no-ff origin/patch/user-portal-me-route-diagnostics-20260709 \
|
|
||||||
-m 'merge(v1r4c6): portal AIOS routes and RBAC manifest'
|
|
||||||
|
|
||||||
git merge --no-ff origin/patch/user-portal-rbac-ops-dashboard \
|
|
||||||
-m 'merge(v1r4c6): portal RBAC operations dashboard'
|
|
||||||
|
|
||||||
git merge --no-ff origin/patch/agent-observability-webui \
|
|
||||||
-m 'merge(v1r4c6): agent observability web UI'
|
|
||||||
```
|
|
||||||
|
|
||||||
Before merging `patch/web-ui-complete-gitops-dashboard-20260706`, compare overlap:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git diff --stat HEAD..origin/patch/web-ui-complete-gitops-dashboard-20260706
|
|
||||||
```
|
|
||||||
|
|
||||||
Merge only if it adds required functionality not already present. Otherwise record it as superseded.
|
|
||||||
|
|
||||||
### Conflict Policy
|
|
||||||
|
|
||||||
Do not auto-resolve conflicts in:
|
|
||||||
|
|
||||||
```text
|
|
||||||
auth/RBAC configuration
|
|
||||||
user/session/tenant isolation
|
|
||||||
search provider configuration
|
|
||||||
safe-mode/controller dispatch
|
|
||||||
systemd units
|
|
||||||
baseline/current.json
|
|
||||||
versions/*.json
|
|
||||||
```
|
|
||||||
|
|
||||||
For those files, stop and report conflict paths.
|
|
||||||
|
|
||||||
### Group A Tests
|
|
||||||
|
|
||||||
Run at minimum:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -m pytest -q \
|
|
||||||
tests/test_portal_aios_frontend_contract.py \
|
|
||||||
tests/test_portal_aios_module_registry.py \
|
|
||||||
tests/test_portal_aios_navigation.py \
|
|
||||||
tests/test_portal_integration_health.py \
|
|
||||||
tests/test_portal_system_health_adapter.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Run any additional portal/RBAC/dashboard tests discovered under `tests/`.
|
|
||||||
|
|
||||||
## Phase 2 — Merge Group B: SR-03 Search Gateway
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git merge --no-ff origin/feature/v1r4c4-sr02-search-gateway-resilience-20260710 \
|
|
||||||
-m 'merge(v1r4c6): search gateway resilience metadata'
|
|
||||||
|
|
||||||
git merge --no-ff origin/patch/search-gateway-governed-general-capability \
|
|
||||||
-m 'merge(v1r4c6): governed search gateway provider binding'
|
|
||||||
|
|
||||||
git merge --no-ff origin/patch/gitops-change-archive-audit \
|
|
||||||
-m 'merge(v1r4c6): search and GitOps audit evidence'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Group B Security Review
|
|
||||||
|
|
||||||
Verify before deployment:
|
|
||||||
|
|
||||||
```text
|
|
||||||
scope filtering occurs before ranking
|
|
||||||
provenance is attached to accepted results
|
|
||||||
out-of-scope records never reach ranking or summarization
|
|
||||||
fallback is text-only, no-memory, or scoped-only
|
|
||||||
global memory/vector index is disabled
|
|
||||||
memory search remains disabled
|
|
||||||
provider credentials are secret references only
|
|
||||||
```
|
|
||||||
|
|
||||||
Reject any code path that silently broadens search scope.
|
|
||||||
|
|
||||||
### Group B Tests
|
|
||||||
|
|
||||||
Discover and run all search/provider/isolation tests:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
find tests -maxdepth 2 -type f \
|
|
||||||
\( -iname '*search*' -o -iname '*provider*' -o -iname '*scope*' -o -iname '*isolation*' \) \
|
|
||||||
-print
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run the discovered files with `python3 -m pytest -q`.
|
|
||||||
|
|
||||||
Also run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash ops/ci/run-user-data-isolation-acceptance.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
If that script is not present after merge, stop: the data-isolation gate is mandatory.
|
|
||||||
|
|
||||||
## Phase 3 — Merge Group C: Agent Platform
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git merge --no-ff origin/patch/agent-event-bus-unified \
|
|
||||||
-m 'merge(v1r4c6): unified scoped agent event bus'
|
|
||||||
|
|
||||||
git merge --no-ff origin/patch/agent-gpt-message-plane \
|
|
||||||
-m 'merge(v1r4c6): scoped agent GPT message plane'
|
|
||||||
|
|
||||||
git merge --no-ff origin/patch/autonomous-controller-dispatch-20260709 \
|
|
||||||
-m 'merge(v1r4c6): autonomous controller dispatch with gates'
|
|
||||||
|
|
||||||
git merge --no-ff origin/patch/self-evolution-agent-orchestration-policy \
|
|
||||||
-m 'merge(v1r4c6): self-evolution orchestration policy'
|
|
||||||
```
|
|
||||||
|
|
||||||
Review `patch/aios-autonomous-orchestrator-architecture-review-v1`:
|
|
||||||
|
|
||||||
- merge if it contains a current architecture record not already present;
|
|
||||||
- otherwise mark it superseded in the release report.
|
|
||||||
|
|
||||||
### Group C Security Review
|
|
||||||
|
|
||||||
Verify:
|
|
||||||
|
|
||||||
```text
|
|
||||||
event envelope includes tenant/group, user, project, session, agent, source and provenance
|
|
||||||
message plane rejects cross-scope delivery
|
|
||||||
denied messages never forward body/content
|
|
||||||
controller dispatch checks autonomous safe mode before mutation
|
|
||||||
consequential actions remain owner-gated
|
|
||||||
duplicate dispatch is idempotent or rejected
|
|
||||||
queue/retry behavior is bounded and observable
|
|
||||||
```
|
|
||||||
|
|
||||||
### Group C Tests
|
|
||||||
|
|
||||||
Discover and run agent/event/message/dispatch/controller tests:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
find tests -maxdepth 2 -type f \
|
|
||||||
\( -iname '*agent*' -o -iname '*event*' -o -iname '*message*' -o -iname '*dispatch*' -o -iname '*autonomous_controller*' \) \
|
|
||||||
-print
|
|
||||||
```
|
|
||||||
|
|
||||||
Run all discovered files with `python3 -m pytest -q`.
|
|
||||||
|
|
||||||
Re-run the SR-02 watcher/controller suite and require all tests to pass.
|
|
||||||
|
|
||||||
## Phase 4 — Full Test and Static Validation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -m compileall -q active-runtime scripts tests
|
|
||||||
python3 -m pytest -q
|
|
||||||
```
|
|
||||||
|
|
||||||
If the full suite has unrelated historical failures, report them separately, but all V1R4C6-touched component tests must pass.
|
|
||||||
|
|
||||||
Validate JSON/YAML configuration without printing secrets.
|
|
||||||
|
|
||||||
Verify:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git diff --check
|
|
||||||
git status --short
|
|
||||||
```
|
|
||||||
|
|
||||||
Commit integration result normally:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add -A
|
|
||||||
git commit -m 'release: integrate V1R4C6 admin search and agent platform'
|
|
||||||
git push origin "$EXEC_BRANCH"
|
|
||||||
```
|
|
||||||
|
|
||||||
If no uncommitted changes remain because merge commits already captured everything, just push normally.
|
|
||||||
|
|
||||||
## Phase 5 — Deploy to Staging/Production Runtime
|
|
||||||
|
|
||||||
Use the existing deployment mechanism for this repository. Do not invent a new deployment path.
|
|
||||||
|
|
||||||
Before restart/reload, identify changed services and frontend bundles from the merged diff.
|
|
||||||
|
|
||||||
At minimum:
|
|
||||||
|
|
||||||
1. Deploy backend modules under `active-runtime/app/`.
|
|
||||||
2. Deploy portal frontend/navigation/proxy changes.
|
|
||||||
3. Deploy Search Gateway provider binding using secret references.
|
|
||||||
4. Deploy Agent Platform event/message/dispatch changes.
|
|
||||||
5. Preserve existing Gitea sentinel and user-data isolation systemd controls.
|
|
||||||
6. Keep `AIOS_MEMORY_ENABLED=false`.
|
|
||||||
|
|
||||||
Restart only affected services. Do not restart Gitea unless it independently fails health checks.
|
|
||||||
|
|
||||||
After deployment, verify affected services are active and no restart loop exists.
|
|
||||||
|
|
||||||
## Phase 6 — Actual Deployment Verification
|
|
||||||
|
|
||||||
### Global
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsS http://127.0.0.1:18890/api/base | python3 -m json.tool
|
|
||||||
curl -fsS https://cnnetmkt.top/api/base | python3 -m json.tool
|
|
||||||
```
|
|
||||||
|
|
||||||
Before V1R4C6 promotion both must still report V1R4C5.
|
|
||||||
|
|
||||||
Verify Gitea sentinel remains healthy and memory search remains disabled.
|
|
||||||
|
|
||||||
### Gate A — Portal/Admin Workbench
|
|
||||||
|
|
||||||
Use an existing authenticated test mechanism or secret-backed admin session. Do not log cookies/tokens.
|
|
||||||
|
|
||||||
Verify:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/portal/me/aios/manifest
|
|
||||||
/portal/me/aios/dashboard
|
|
||||||
/portal/me/aios/health
|
|
||||||
```
|
|
||||||
|
|
||||||
For `user`, `group_admin/admin`, and `owner`, record only module names/status, not personal data.
|
|
||||||
|
|
||||||
Required admin-visible functions:
|
|
||||||
|
|
||||||
```text
|
|
||||||
task progress
|
|
||||||
process/flow diagram entry
|
|
||||||
system health
|
|
||||||
agent observability
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify forbidden owner modules are hidden from non-owner admins.
|
|
||||||
|
|
||||||
### Gate B — Search Gateway
|
|
||||||
|
|
||||||
Execute scoped test queries using non-sensitive fixtures.
|
|
||||||
|
|
||||||
Verify:
|
|
||||||
|
|
||||||
```text
|
|
||||||
real provider selected
|
|
||||||
scope applied before ranking
|
|
||||||
provenance returned
|
|
||||||
out-of-scope fixture denied
|
|
||||||
safe fallback works
|
|
||||||
memory search remains disabled
|
|
||||||
```
|
|
||||||
|
|
||||||
### Gate C — Agent Platform
|
|
||||||
|
|
||||||
Using synthetic non-sensitive events/messages:
|
|
||||||
|
|
||||||
```text
|
|
||||||
same-scope delivery succeeds
|
|
||||||
cross-user/project/session delivery is denied
|
|
||||||
denied payload body is not forwarded
|
|
||||||
safe-mode blocks controller mutation
|
|
||||||
owner-gated action remains pending approval
|
|
||||||
duplicate dispatch is idempotent/rejected
|
|
||||||
```
|
|
||||||
|
|
||||||
## Phase 7 — Release Evidence
|
|
||||||
|
|
||||||
Update:
|
|
||||||
|
|
||||||
```text
|
|
||||||
docs/releases/V1R4C6-acceptance-checklist.md
|
|
||||||
docs/releases/V1R4C6-acceptance-evidence.md
|
|
||||||
docs/releases/V1R4C6-release-gate-status.md
|
|
||||||
versions/V1R4C6.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Set each gate to PASS/FAIL with concrete test commands and results.
|
|
||||||
|
|
||||||
Required final state before requesting owner confirmation:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Gate A = PASS
|
|
||||||
Gate B = PASS
|
|
||||||
Gate C = PASS
|
|
||||||
full touched-component tests = PASS
|
|
||||||
runtime write acceptance = PASS
|
|
||||||
Gitea sentinel = healthy
|
|
||||||
memory search = disabled
|
|
||||||
force-push used = no
|
|
||||||
V1R4C6 tag = absent
|
|
||||||
active baseline = V1R4C5
|
|
||||||
```
|
|
||||||
|
|
||||||
Commit and push evidence normally to `$EXEC_BRANCH`.
|
|
||||||
|
|
||||||
## Stop Point — Owner Confirmation Required
|
|
||||||
|
|
||||||
Do not create `V1R4C6` tag.
|
|
||||||
Do not promote baseline to V1R4C6.
|
|
||||||
Do not enable memory search.
|
|
||||||
|
|
||||||
When all gates pass, notify the owner through the configured notification mechanism, preferably WeChat if configured. The notification must contain no secrets and must request confirmation for:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Create V1R4C6 Git tag
|
|
||||||
Promote active baseline from V1R4C5 to V1R4C6
|
|
||||||
```
|
|
||||||
|
|
||||||
Required report fields:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Status
|
|
||||||
Integration branch and HEAD
|
|
||||||
Merged branches
|
|
||||||
Skipped/superseded branches
|
|
||||||
Gate A result
|
|
||||||
Gate B result
|
|
||||||
Gate C result
|
|
||||||
Test totals
|
|
||||||
Deployment services/build versions
|
|
||||||
Portal/admin verification result
|
|
||||||
Search provider verification result
|
|
||||||
Agent platform verification result
|
|
||||||
Gitea sentinel status
|
|
||||||
Memory search status
|
|
||||||
Active baseline
|
|
||||||
V1R4C6 tag status
|
|
||||||
Rollback readiness
|
|
||||||
Owner confirmation required
|
|
||||||
```
|
|
||||||
|
|
||||||
If WeChat notification is not configured, write the exact notification message to:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/data/aios-gitops/runtime/state/V1R4C6-owner-confirmation-message.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
and report that path.
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
# CMD-20260711-v1r4c6-run-to-owner-gate-wechat
|
|
||||||
|
|
||||||
## Execution Mode
|
|
||||||
|
|
||||||
Execute immediately using the existing V1R4C6 integration command:
|
|
||||||
|
|
||||||
```text
|
|
||||||
ops/openclaw/CMD-20260711-v1r4c6-integrate-deploy-verify.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Continue through actual merge, tests, deployment, and production verification.
|
|
||||||
|
|
||||||
Stop only at the owner gate for:
|
|
||||||
|
|
||||||
```text
|
|
||||||
V1R4C6 Git tag creation
|
|
||||||
V1R4C6 baseline promotion
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not ask the owner to perform routine engineering or deployment work.
|
|
||||||
|
|
||||||
## Verified Starting State
|
|
||||||
|
|
||||||
Required before starting:
|
|
||||||
|
|
||||||
```text
|
|
||||||
runtime = active
|
|
||||||
current_baseline = V1R4C5
|
|
||||||
previous_baseline = V1R4C3
|
|
||||||
baseline_git = a32c76a3c1baa49562e53fb8adce10a5ed0e6704
|
|
||||||
AIOS_GITOPS_BARE_REPO = /data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios.git
|
|
||||||
memory_search_enabled = false
|
|
||||||
force_push_allowed = false
|
|
||||||
```
|
|
||||||
|
|
||||||
If any starting condition differs, stop and send a WeChat blocker notification.
|
|
||||||
|
|
||||||
## Required Work
|
|
||||||
|
|
||||||
Complete all phases in the referenced command:
|
|
||||||
|
|
||||||
1. Create/update the normal non-force-pushed integration execution branch.
|
|
||||||
2. Merge and validate Admin Workbench / Portal AIOS.
|
|
||||||
3. Merge and validate SR-03 Search Gateway real provider binding.
|
|
||||||
4. Merge and validate Agent Platform Integration.
|
|
||||||
5. Run component and full touched-scope test suites.
|
|
||||||
6. Deploy using the repository's existing deployment mechanism.
|
|
||||||
7. Verify production routes, RBAC, scope isolation, provenance, safe mode, and owner gates.
|
|
||||||
8. Update V1R4C6 release evidence and version metadata.
|
|
||||||
9. Push all normal commits to origin.
|
|
||||||
10. Keep V1R4C6 tag absent and baseline at V1R4C5 until owner approval.
|
|
||||||
|
|
||||||
## Hard Constraints
|
|
||||||
|
|
||||||
```text
|
|
||||||
AIOS_MEMORY_ENABLED=false
|
|
||||||
memory search remains disabled
|
|
||||||
no global memory/vector index
|
|
||||||
no force-push
|
|
||||||
no tag rewrite
|
|
||||||
no V1R4C5 tag movement
|
|
||||||
no Gitea DB mutation
|
|
||||||
no plaintext secrets in Git, logs, reports, or WeChat
|
|
||||||
```
|
|
||||||
|
|
||||||
Never bypass a failed security, isolation, RBAC, safe-mode, or owner gate.
|
|
||||||
|
|
||||||
## WeChat Progress Notifications
|
|
||||||
|
|
||||||
Use the configured OpenClaw WeChat notification channel.
|
|
||||||
|
|
||||||
Send concise progress notifications at these milestones:
|
|
||||||
|
|
||||||
### Notification 1 — Integration Started
|
|
||||||
|
|
||||||
```text
|
|
||||||
V1R4C6 集成已启动。
|
|
||||||
范围:管理员工作台、SR-03 Search Gateway、Agent Platform。
|
|
||||||
当前基线:V1R4C5。
|
|
||||||
memory search 保持关闭。
|
|
||||||
下一步:完成合并、测试和部署验证。
|
|
||||||
```
|
|
||||||
|
|
||||||
### Notification 2 — Gate A Complete
|
|
||||||
|
|
||||||
Include only:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Gate A result: PASS/FAIL
|
|
||||||
Admin task progress visibility: PASS/FAIL
|
|
||||||
Flow diagram visibility: PASS/FAIL
|
|
||||||
RBAC/isolation: PASS/FAIL
|
|
||||||
Blockers, if any
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not include user records, tokens, cookies, or sensitive task content.
|
|
||||||
|
|
||||||
### Notification 3 — Gate B Complete
|
|
||||||
|
|
||||||
Include only:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Gate B result: PASS/FAIL
|
|
||||||
Real provider binding: PASS/FAIL
|
|
||||||
Scope-before-ranking: PASS/FAIL
|
|
||||||
Provenance: PASS/FAIL
|
|
||||||
Safe fallback: PASS/FAIL
|
|
||||||
Memory search: disabled
|
|
||||||
Blockers, if any
|
|
||||||
```
|
|
||||||
|
|
||||||
### Notification 4 — Gate C Complete
|
|
||||||
|
|
||||||
Include only:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Gate C result: PASS/FAIL
|
|
||||||
Event scope: PASS/FAIL
|
|
||||||
Message plane scope: PASS/FAIL
|
|
||||||
Safe-mode dispatch block: PASS/FAIL
|
|
||||||
Owner gate: PASS/FAIL
|
|
||||||
Blockers, if any
|
|
||||||
```
|
|
||||||
|
|
||||||
## Final WeChat Owner Authorization Request
|
|
||||||
|
|
||||||
Send only after all mandatory gates and deployment verification pass.
|
|
||||||
|
|
||||||
Use this message:
|
|
||||||
|
|
||||||
```text
|
|
||||||
V1R4C6 已完成实际合并、测试、部署和生产验证,现等待最终发布授权。
|
|
||||||
|
|
||||||
版本范围:
|
|
||||||
1. 管理员工作台 / Portal AIOS 上线
|
|
||||||
2. SR-03 Search Gateway Real Provider Binding
|
|
||||||
3. Agent Platform Integration
|
|
||||||
|
|
||||||
验收结果:
|
|
||||||
- Gate A(管理员工作台 / RBAC / 隔离):PASS
|
|
||||||
- Gate B(Provider / scope / provenance / fallback):PASS
|
|
||||||
- Gate C(Event Bus / Message Plane / Dispatch / Owner Gate):PASS
|
|
||||||
- Runtime 写链路:PASS
|
|
||||||
- Gitea consistency sentinel:healthy
|
|
||||||
- memory search:仍为 disabled
|
|
||||||
- force-push:未使用
|
|
||||||
- 当前 active baseline:V1R4C5
|
|
||||||
- V1R4C6 tag:尚未创建
|
|
||||||
|
|
||||||
请明确回复以下其中一项:
|
|
||||||
A. 批准创建 V1R4C6 Git tag,并将 active baseline 从 V1R4C5 晋升至 V1R4C6。
|
|
||||||
B. 暂不批准,保持 V1R4C5,并说明需要处理的问题。
|
|
||||||
```
|
|
||||||
|
|
||||||
## Authorization Rules
|
|
||||||
|
|
||||||
Do not treat reactions, emojis, acknowledgements, or ambiguous replies as approval.
|
|
||||||
|
|
||||||
Valid approval must explicitly authorize both:
|
|
||||||
|
|
||||||
```text
|
|
||||||
create V1R4C6 Git tag
|
|
||||||
promote baseline V1R4C5 -> V1R4C6
|
|
||||||
```
|
|
||||||
|
|
||||||
If the owner approves only one operation, execute only the approved operation and stop before the other.
|
|
||||||
|
|
||||||
Before executing a consequential operation, record authorization in:
|
|
||||||
|
|
||||||
```text
|
|
||||||
docs/releases/V1R4C6-owner-approval.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Record only:
|
|
||||||
|
|
||||||
```text
|
|
||||||
approved operation(s)
|
|
||||||
approved_at
|
|
||||||
approval channel = wechat
|
|
||||||
message/conversation identifier if available
|
|
||||||
owner identity reference without copying personal message content
|
|
||||||
memory search remains disabled
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not store access tokens, cookies, phone numbers, or unrelated chat content.
|
|
||||||
|
|
||||||
## WeChat Failure Handling
|
|
||||||
|
|
||||||
If the configured WeChat channel is unavailable:
|
|
||||||
|
|
||||||
1. Do not claim that a message was sent.
|
|
||||||
2. Stop before tag/promotion.
|
|
||||||
3. Write the exact final authorization request to:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/data/aios-gitops/runtime/state/V1R4C6-owner-confirmation-message.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Write notification failure details to:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/data/aios-gitops/runtime/state/V1R4C6-wechat-notification-failure.json
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Report the failure through the active GitOps task/status channel.
|
|
||||||
|
|
||||||
## Final Stop Condition
|
|
||||||
|
|
||||||
Before explicit owner authorization, final state must remain:
|
|
||||||
|
|
||||||
```text
|
|
||||||
current_baseline = V1R4C5
|
|
||||||
V1R4C6 tag = absent
|
|
||||||
memory search = disabled
|
|
||||||
```
|
|
||||||
|
|
||||||
After owner authorization, follow the existing tag confirmation and baseline promotion controls. Do not bypass consequential-operation confirmation layers.
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"type": "branch",
|
|
||||||
"branch": "reconcile/v1r4c5-release-to-main-20260711",
|
|
||||||
"base": "main",
|
|
||||||
"created_by": "chatgpt-gitops-bot",
|
|
||||||
"created_at": "2026-07-10T23:56:41Z",
|
|
||||||
"direct_main_push": false,
|
|
||||||
"owner_approval_required": true
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"type": "branch",
|
|
||||||
"branch": "release/v2r1c0-tenant-cell-fabric-20260712",
|
|
||||||
"base": "main",
|
|
||||||
"created_by": "chatgpt-gitops-bot",
|
|
||||||
"created_at": "2026-07-12T14:39:23Z",
|
|
||||||
"direct_main_push": false,
|
|
||||||
"owner_approval_required": true
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "patch-1783707996243-33717dfe",
|
|
||||||
"patch_name": "v1r4c6-admin-search-agent-integration-20260711",
|
|
||||||
"branch": "release/v1r4c6-admin-search-agent-integration-20260711",
|
|
||||||
"risk": "high",
|
|
||||||
"description": "V1R4C6 bundled integration release: Admin Workbench visibility, SR-03 Search Gateway real provider binding, and Agent Platform Integration. Memory search remains disabled and user data isolation gates remain mandatory.",
|
|
||||||
"created_by": "chatgpt-gitops-bot",
|
|
||||||
"created_at": "2026-07-10T18:26:36Z"
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# AIOS V2R1C0 RC1
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
OpenClaw Tenant Cell + Hermes Shared Agent Fabric.
|
|
||||||
|
|
||||||
## Included
|
|
||||||
|
|
||||||
- Signed Tenant Context
|
|
||||||
- Canonical Message Envelope
|
|
||||||
- Tenant Router
|
|
||||||
- OpenClaw Cell lifecycle adapter
|
|
||||||
- Exact Message Ledger
|
|
||||||
- Hermes tenant partitioned scheduler
|
|
||||||
- Memory Namespace policy
|
|
||||||
- Secret Scope references
|
|
||||||
- Append-only Audit Chain
|
|
||||||
- Hermes Profile isolation
|
|
||||||
- OpenClaw tenant boundary plugin
|
|
||||||
|
|
||||||
## Release Gates
|
|
||||||
|
|
||||||
Before production:
|
|
||||||
|
|
||||||
- automated isolation tests pass
|
|
||||||
- OpenClaw plugin runtime inspect passes
|
|
||||||
- Cell health probes pass
|
|
||||||
- running commit matches release commit
|
|
||||||
- rollback test passes
|
|
||||||
|
|
||||||
## Security Rules
|
|
||||||
|
|
||||||
Unknown provenance fails closed.
|
|
||||||
Quoted text is data, not command input.
|
|
||||||
Cross-tenant access is denied.
|
|
||||||
Shared Fabric does not share tenant state.
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# AIOS V2R1C0 RC1 Live Status
|
|
||||||
|
|
||||||
Last verified: 2026-07-13 Europe/Amsterdam
|
|
||||||
|
|
||||||
## Source of truth
|
|
||||||
|
|
||||||
- Release branch: `release/v2r1c0-tenant-cell-fabric-20260712`
|
|
||||||
- Verified branch tree: `6569065`
|
|
||||||
- Current promoted baseline: `V1R4C6`
|
|
||||||
- Production baseline state: not yet promoted to V2R1C0
|
|
||||||
|
|
||||||
This file is the single human-readable progress page for V2R1C0 RC1. Update it in the same commit that changes a release gate.
|
|
||||||
|
|
||||||
## Overall status
|
|
||||||
|
|
||||||
`DEVELOPMENT COMPLETE / EXECUTABLE ACCEPTANCE INCOMPLETE / NOT DEPLOYED`
|
|
||||||
|
|
||||||
## Component status
|
|
||||||
|
|
||||||
| Workstream | Status | Evidence | Remaining gate |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Architecture baseline | Done | `docs/architecture/AIOS_V2R1_OpenClaw_Tenant_Cell_Hermes_Shared_Agent_Fabric_Architecture.md` | none |
|
|
||||||
| Tenant Context | Done | `active-runtime/app/aios_v2r1/tenant_context.py` | executable security tests |
|
|
||||||
| Canonical Message | Done | `tenant_context.py` | channel adapter integration test |
|
|
||||||
| Tenant Router | Done | `router.py` | cross-tenant executable tests |
|
|
||||||
| Cell lifecycle adapter | Done | `fleet.py`, `cell_manager.py`, `cli.py` | real OpenClaw Fleet probe |
|
|
||||||
| Message Ledger | Done | `message_ledger.py` | concurrency and restart tests |
|
|
||||||
| Hermes Fabric | Done | `hermes_fabric.py` | worker/profile runtime test |
|
|
||||||
| Memory isolation | Done | `memory_scope.py` | cross-tenant executable tests |
|
|
||||||
| Secret scope | Done | `secret_scope.py` | secret broker integration |
|
|
||||||
| Audit chain | Done | `audit.py` | tamper executable test |
|
|
||||||
| Hermes Profile isolation | Done | `hermes_profile.py` | container/profile runtime test |
|
|
||||||
| OpenClaw boundary plugin | Code complete | `openclaw/plugins/aios-tenant-boundary/` | install + runtime hook inspection |
|
|
||||||
| Python automated tests | Blocked | no committed executable suite | add and run tests |
|
|
||||||
| Node plugin automated tests | Blocked | no committed executable suite | add and run tests |
|
|
||||||
| Staging Cell deployment | Not started | none | tests must pass first |
|
|
||||||
| Rollback rehearsal | Not started | none | staging deployment required |
|
|
||||||
| Merge to `main` | Not started | release branch differs from main | owner approval after gates pass |
|
|
||||||
| RC tag | Not created | no V2R1 tag | owner approval |
|
|
||||||
| Production deployment | Not started | baseline remains V1R4C6 | owner production approval |
|
|
||||||
|
|
||||||
## Release gates
|
|
||||||
|
|
||||||
- [ ] Python executable tests committed and passing
|
|
||||||
- [ ] Node executable tests committed and passing
|
|
||||||
- [ ] OpenClaw plugin installs successfully
|
|
||||||
- [ ] Runtime inspection proves hooks registered
|
|
||||||
- [ ] Tenant Cell status and doctor pass
|
|
||||||
- [ ] Staging message and tool-policy smoke tests pass
|
|
||||||
- [ ] Rollback succeeds
|
|
||||||
- [ ] Running commit matches approved release commit
|
|
||||||
- [ ] Owner approves merge, tag and production cutover
|
|
||||||
|
|
||||||
## Parallel work ownership
|
|
||||||
|
|
||||||
- Track A: Python isolation and message tests
|
|
||||||
- Track B: OpenClaw plugin tests and runtime inspection
|
|
||||||
- Track C: Cell staging deployment, health and rollback
|
|
||||||
- Track D: release evidence, digests and final acceptance report
|
|
||||||
|
|
||||||
## Update rule
|
|
||||||
|
|
||||||
Every worker must update this page with:
|
|
||||||
|
|
||||||
1. exact command,
|
|
||||||
2. exit code,
|
|
||||||
3. test counts,
|
|
||||||
4. commit hash,
|
|
||||||
5. artifact digest,
|
|
||||||
6. blocker or PASS result.
|
|
||||||
|
|
||||||
No item may be marked Done from prose-only reports.
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
"""End-to-end boundary tests for the V2R1 authenticated ingress runtime."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import sys
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from tempfile import TemporaryDirectory
|
|
||||||
|
|
||||||
from aios_v2r1.runtime import RuntimeWiring
|
|
||||||
from aios_v2r1.message_ledger import MessageLedger
|
|
||||||
from aios_v2r1.tenant_context import canonical_json
|
|
||||||
|
|
||||||
|
|
||||||
class FixedClock:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.now = datetime(2026, 7, 13, tzinfo=timezone.utc)
|
|
||||||
|
|
||||||
def __call__(self) -> datetime:
|
|
||||||
return self.now
|
|
||||||
|
|
||||||
|
|
||||||
class TestRuntimeIngressFlow:
|
|
||||||
"""Use the public ingress boundary; do not reach into router internals."""
|
|
||||||
|
|
||||||
internal_key = b"runtime-integration-internal-key-32bytes"
|
|
||||||
ingress_key = b"runtime-integration-ingress-key-32bytes!"
|
|
||||||
|
|
||||||
def setup_method(self) -> None:
|
|
||||||
self.directory = TemporaryDirectory()
|
|
||||||
self.clock = FixedClock()
|
|
||||||
self.runtime = RuntimeWiring.create(
|
|
||||||
self.directory.name,
|
|
||||||
signing_key=self.internal_key,
|
|
||||||
ingress_hmac_key=self.ingress_key,
|
|
||||||
clock=self.clock,
|
|
||||||
)
|
|
||||||
self.runtime.router.register_tenant("tenant-a", "cell-a")
|
|
||||||
self.runtime.router.register_tenant("tenant-b", "cell-b")
|
|
||||||
self.runtime.router.bind_channel(
|
|
||||||
tenant_id="tenant-a", channel="weixin", channel_account_id="account-a"
|
|
||||||
)
|
|
||||||
self.runtime.router.bind_channel(
|
|
||||||
tenant_id="tenant-b", channel="weixin", channel_account_id="account-b"
|
|
||||||
)
|
|
||||||
self.runtime.router.bind_principal(
|
|
||||||
tenant_id="tenant-a",
|
|
||||||
channel="weixin",
|
|
||||||
channel_account_id="account-a",
|
|
||||||
principal_id="user-a",
|
|
||||||
)
|
|
||||||
self.runtime.router.bind_session(
|
|
||||||
tenant_id="tenant-a",
|
|
||||||
session_id="session-a",
|
|
||||||
cell_id="cell-a",
|
|
||||||
principal_id="user-a",
|
|
||||||
ttl_seconds=60,
|
|
||||||
)
|
|
||||||
|
|
||||||
def teardown_method(self) -> None:
|
|
||||||
self.directory.cleanup()
|
|
||||||
|
|
||||||
def _message(self, *, event_id: str = "event-a", message_id: str = "message-a",
|
|
||||||
body: str = "hello", **changes: object) -> dict[str, object]:
|
|
||||||
values: dict[str, object] = {
|
|
||||||
"tenant_id": "tenant-a", "cell_id": "cell-a", "principal_id": "user-a",
|
|
||||||
"principal_type": "authenticated_user", "origin_type": "channel_user",
|
|
||||||
"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": message_id, "reply_to_message_id": None,
|
|
||||||
"direction": "inbound", "command_capable": True,
|
|
||||||
"occurred_at": "2026-07-13T00:00:00+00:00",
|
|
||||||
"received_at": "2026-07-13T00:00:01+00:00",
|
|
||||||
"content": {"body": body, "quoted_body": None},
|
|
||||||
"adapter": {"name": "weixin", "version": "v2"},
|
|
||||||
}
|
|
||||||
values.update(changes)
|
|
||||||
return values
|
|
||||||
|
|
||||||
def _request(self, **changes: object) -> dict[str, object]:
|
|
||||||
values: dict[str, object] = {
|
|
||||||
"principal_id": "user-a", "tenant_id": "tenant-a", "cell_id": "cell-a",
|
|
||||||
"channel": "weixin", "channel_account_id": "account-a", "session_id": "session-a",
|
|
||||||
"message": self._message(),
|
|
||||||
}
|
|
||||||
values.update(changes)
|
|
||||||
values["signature"] = hmac.new(
|
|
||||||
self.ingress_key, canonical_json(values), hashlib.sha256
|
|
||||||
).hexdigest()
|
|
||||||
return values
|
|
||||||
|
|
||||||
def test_authenticated_ingress_uses_direct_append_and_acquires_session(self) -> None:
|
|
||||||
# Observe the real MessageLedger object while preserving every method
|
|
||||||
# implementation. This proves the public ingress path never calls
|
|
||||||
# the legacy compatibility entrypoint; it is not a mock or bypass.
|
|
||||||
remember_code = MessageLedger.remember.__code__
|
|
||||||
remember_calls = 0
|
|
||||||
previous_profile = sys.getprofile()
|
|
||||||
|
|
||||||
def observe(frame: object, event: str, arg: object) -> None:
|
|
||||||
nonlocal remember_calls
|
|
||||||
if event == "call" and getattr(frame, "f_code", None) is remember_code:
|
|
||||||
remember_calls += 1
|
|
||||||
|
|
||||||
sys.setprofile(observe)
|
|
||||||
try:
|
|
||||||
response = self.runtime.ingest(request=self._request())
|
|
||||||
finally:
|
|
||||||
sys.setprofile(previous_profile)
|
|
||||||
|
|
||||||
assert response.status == "ACCEPTED"
|
|
||||||
assert response.result is not None
|
|
||||||
assert response.result.record.message_id == "message-a"
|
|
||||||
assert response.result.session.session_id == "session-a"
|
|
||||||
assert self.runtime.ledger.count() == 1
|
|
||||||
assert self.runtime.session_pool.active_count() == 1
|
|
||||||
assert remember_calls == 0
|
|
||||||
|
|
||||||
def test_forged_tenant_claim_is_rejected_before_ledger_or_session_mutation(self) -> None:
|
|
||||||
response = self.runtime.ingest(request=self._request(
|
|
||||||
tenant_id="tenant-b",
|
|
||||||
cell_id="cell-b",
|
|
||||||
message=self._message(tenant_id="tenant-b", cell_id="cell-b"),
|
|
||||||
))
|
|
||||||
|
|
||||||
assert response.status == "AUTH_FAILED"
|
|
||||||
assert response.result is None
|
|
||||||
assert self.runtime.ledger.count() == 0
|
|
||||||
assert self.runtime.session_pool.active_count() == 0
|
|
||||||
|
|
||||||
def test_conflicting_duplicate_message_is_mapped_at_the_ingress_boundary(self) -> None:
|
|
||||||
assert self.runtime.ingest(request=self._request(
|
|
||||||
message=self._message(body="first")
|
|
||||||
)).status == "ACCEPTED"
|
|
||||||
|
|
||||||
response = self.runtime.ingest(request=self._request(
|
|
||||||
message=self._message(event_id="event-b", body="second")
|
|
||||||
))
|
|
||||||
|
|
||||||
assert response.status == "CONFLICT"
|
|
||||||
assert response.result is None
|
|
||||||
assert self.runtime.ledger.count() == 1
|
|
||||||
assert self.runtime.session_pool.active_count() == 1
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# AIOS V2R1C0 RC1 Acceptance
|
|
||||||
|
|
||||||
## Tenant Isolation
|
|
||||||
|
|
||||||
- cross tenant memory access: DENY
|
|
||||||
- cross cell context: DENY
|
|
||||||
- cross tenant secret scope: DENY
|
|
||||||
- cross tenant queue claim: DENY
|
|
||||||
|
|
||||||
## Message Safety
|
|
||||||
|
|
||||||
- unsafe numeric message id: DENY
|
|
||||||
- exact reply resolution only: PASS
|
|
||||||
- quote text separate from command body: PASS
|
|
||||||
- heartbeat side effects: DENY
|
|
||||||
- cron side effects: DENY
|
|
||||||
- assistant output execution: DENY
|
|
||||||
|
|
||||||
## Hermes
|
|
||||||
|
|
||||||
- profile HOME isolation: PASS
|
|
||||||
- workspace escape: DENY
|
|
||||||
- tenant queue partition: PASS
|
|
||||||
|
|
||||||
## OpenClaw
|
|
||||||
|
|
||||||
- plugin manifest validation: PASS
|
|
||||||
- runtime hook registration: REQUIRED
|
|
||||||
- side effect approval: REQUIRED
|
|
||||||
|
|
||||||
## Release Gate
|
|
||||||
|
|
||||||
No production deployment until executable tests and runtime evidence pass.
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Hermes Fabric Contract Tests
|
|
||||||
|
|
||||||
Required executable assertions:
|
|
||||||
|
|
||||||
1. Duplicate event_id must raise ConflictError, never raw sqlite3.IntegrityError.
|
|
||||||
2. Same tenant/cell/profile worker can claim its own queue.
|
|
||||||
3. Cross tenant queue claim must return no task or be denied.
|
|
||||||
4. Non-command messages cannot enqueue execution jobs.
|
|
||||||
5. Unknown provenance cannot create execution jobs.
|
|
||||||
|
|
||||||
Evidence required:
|
|
||||||
|
|
||||||
- command
|
|
||||||
- exit code
|
|
||||||
- pass count
|
|
||||||
- commit
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
"""Gate 1 contract tests for the Hermes shared agent fabric."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
from tempfile import TemporaryDirectory
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from aios_v2r1.hermes_fabric import HermesFabric
|
|
||||||
from aios_v2r1.tenant_context import CanonicalMessage, ConflictError, ScopeError
|
|
||||||
|
|
||||||
|
|
||||||
def authenticated_command(
|
|
||||||
event_id: str,
|
|
||||||
*,
|
|
||||||
command_capable: bool = True,
|
|
||||||
origin_type: str = "channel_user",
|
|
||||||
) -> CanonicalMessage:
|
|
||||||
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",
|
|
||||||
received_at="2026-07-13T00:00:01+00:00",
|
|
||||||
body="/run",
|
|
||||||
quoted_body=None,
|
|
||||||
adapter_name="weixin",
|
|
||||||
adapter_version="v2",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class HermesFabricContractTests(unittest.TestCase):
|
|
||||||
def test_duplicate_event_returns_sanitized_conflict(self) -> None:
|
|
||||||
with TemporaryDirectory() as directory:
|
|
||||||
fabric = HermesFabric(f"{directory}/fabric.sqlite3")
|
|
||||||
message = authenticated_command("event-a")
|
|
||||||
|
|
||||||
first = fabric.enqueue(message=message, profile_id="profile-a")
|
|
||||||
self.assertEqual(first.event_id, "event-a")
|
|
||||||
|
|
||||||
with self.assertRaises(ConflictError) as raised:
|
|
||||||
fabric.enqueue(message=message, profile_id="profile-a")
|
|
||||||
|
|
||||||
error = raised.exception
|
|
||||||
self.assertNotIsInstance(error, sqlite3.IntegrityError)
|
|
||||||
self.assertNotIn("sqlite", str(error).lower())
|
|
||||||
self.assertIsNone(error.__cause__)
|
|
||||||
self.assertIsNone(error.__context__)
|
|
||||||
|
|
||||||
def test_non_command_event_cannot_enqueue(self) -> None:
|
|
||||||
with TemporaryDirectory() as directory:
|
|
||||||
fabric = HermesFabric(f"{directory}/fabric.sqlite3")
|
|
||||||
|
|
||||||
with self.assertRaises(ScopeError):
|
|
||||||
fabric.enqueue(
|
|
||||||
message=authenticated_command(
|
|
||||||
"event-non-command", command_capable=False
|
|
||||||
),
|
|
||||||
profile_id="profile-a",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_heartbeat_provenance_cannot_enqueue(self) -> None:
|
|
||||||
with TemporaryDirectory() as directory:
|
|
||||||
fabric = HermesFabric(f"{directory}/fabric.sqlite3")
|
|
||||||
|
|
||||||
with self.assertRaises(ScopeError):
|
|
||||||
fabric.enqueue(
|
|
||||||
message=authenticated_command(
|
|
||||||
"event-heartbeat", origin_type="heartbeat"
|
|
||||||
),
|
|
||||||
profile_id="profile-a",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
"""Contract tests for the runtime-facing direct MessageLedger append path."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from tempfile import TemporaryDirectory
|
|
||||||
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",
|
|
||||||
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=message_id, reply_to_message_id=reply_to_message_id,
|
|
||||||
direction="inbound", command_capable=True,
|
|
||||||
occurred_at="2026-07-13T00:00:00+00:00",
|
|
||||||
received_at="2026-07-13T00:00:01+00:00", body=body,
|
|
||||||
quoted_body=None, adapter_name="weixin", adapter_version="v2",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MessageLedgerAppendContractTests(unittest.TestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.directory = TemporaryDirectory()
|
|
||||||
self.addCleanup(self.directory.cleanup)
|
|
||||||
self.ledger = MessageLedger(f"{self.directory.name}/ledger.sqlite3")
|
|
||||||
|
|
||||||
def test_append_writes_directly_without_using_remember(self) -> None:
|
|
||||||
# A compatibility wrapper may remain, but the runtime append path may not
|
|
||||||
# route through it: this catches append() -> remember() regressions.
|
|
||||||
self.ledger.remember = lambda _: (_ for _ in ()).throw(AssertionError("remember called")) # type: ignore[method-assign]
|
|
||||||
record = self.ledger.append(message())
|
|
||||||
self.assertEqual("message-a", record.message_id)
|
|
||||||
self.assertEqual(1, self.ledger.count())
|
|
||||||
|
|
||||||
def test_append_rejects_same_message_id_with_different_content(self) -> None:
|
|
||||||
self.ledger.append(message(body="first"))
|
|
||||||
with self.assertRaises(ConflictError):
|
|
||||||
self.ledger.append(message(event_id="event-b", body="second"))
|
|
||||||
self.assertEqual(1, self.ledger.count())
|
|
||||||
|
|
||||||
def test_append_is_idempotent_when_only_event_id_changes(self) -> None:
|
|
||||||
stored = self.ledger.append(message(event_id="event-a", body="same body"))
|
|
||||||
replay = self.ledger.append(message(event_id="event-b", body="same body"))
|
|
||||||
|
|
||||||
self.assertEqual(stored, replay)
|
|
||||||
self.assertEqual("event-a", replay.event_id)
|
|
||||||
self.assertEqual(1, self.ledger.count())
|
|
||||||
|
|
||||||
def test_append_keeps_tenants_isolated(self) -> None:
|
|
||||||
self.ledger.append(message())
|
|
||||||
self.ledger.append(message(tenant_id="tenant-b", cell_id="cell-b"))
|
|
||||||
self.assertEqual(2, self.ledger.count())
|
|
||||||
self.assertEqual("hello", self.ledger.resolve_quote(
|
|
||||||
message(tenant_id="tenant-b", cell_id="cell-b", event_id="event-c",
|
|
||||||
message_id="message-c", body="reply", reply_to_message_id="message-a")
|
|
||||||
))
|
|
||||||
|
|
||||||
def test_concurrent_identical_append_is_one_record(self) -> None:
|
|
||||||
with ThreadPoolExecutor(max_workers=16) as executor:
|
|
||||||
records = list(executor.map(lambda _: self.ledger.append(message()), range(16)))
|
|
||||||
self.assertEqual(1, self.ledger.count())
|
|
||||||
self.assertEqual({"message-a"}, {record.message_id for record in records})
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
"""Gate 1 contract tests for authoritative TenantRouter isolation."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from tempfile import TemporaryDirectory
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from aios_v2r1.router import TenantRouter
|
|
||||||
from aios_v2r1.tenant_context import CanonicalMessage, ConflictError, ScopeError
|
|
||||||
|
|
||||||
|
|
||||||
class MutableClock:
|
|
||||||
def __init__(self, now: datetime) -> None:
|
|
||||||
self.now = now
|
|
||||||
|
|
||||||
def __call__(self) -> datetime:
|
|
||||||
return self.now
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_message(*, tenant_id: str = "tenant-a", cell_id: str = "cell-a") -> CanonicalMessage:
|
|
||||||
return CanonicalMessage(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
cell_id=cell_id,
|
|
||||||
principal_id="user-a",
|
|
||||||
principal_type="authenticated_user",
|
|
||||||
origin_type="channel_user",
|
|
||||||
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-a",
|
|
||||||
message_id="message-a",
|
|
||||||
reply_to_message_id=None,
|
|
||||||
direction="inbound",
|
|
||||||
command_capable=True,
|
|
||||||
occurred_at="2026-07-13T00:00:00+00:00",
|
|
||||||
received_at="2026-07-13T00:00:01+00:00",
|
|
||||||
body="hello",
|
|
||||||
quoted_body=None,
|
|
||||||
adapter_name="weixin",
|
|
||||||
adapter_version="v2",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TenantRouterIsolationContractTests(unittest.TestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.directory = TemporaryDirectory()
|
|
||||||
self.addCleanup(self.directory.cleanup)
|
|
||||||
self.database = f"{self.directory.name}/router.sqlite3"
|
|
||||||
self.router = TenantRouter(self.database)
|
|
||||||
self.router.register_tenant("tenant-a", "cell-a")
|
|
||||||
self.router.register_tenant("tenant-b", "cell-b")
|
|
||||||
self.router.bind_channel(
|
|
||||||
tenant_id="tenant-a", channel="weixin", channel_account_id="account-a"
|
|
||||||
)
|
|
||||||
self.router.bind_channel(
|
|
||||||
tenant_id="tenant-b", channel="weixin", channel_account_id="account-b"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_channel_account_cannot_bind_two_tenants(self) -> None:
|
|
||||||
with self.assertRaises(ConflictError):
|
|
||||||
self.router.bind_channel(
|
|
||||||
tenant_id="tenant-b", channel="weixin", channel_account_id="account-a"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_cell_cannot_bind_two_tenants(self) -> None:
|
|
||||||
with self.assertRaises(ConflictError):
|
|
||||||
self.router.register_tenant("tenant-c", "cell-a")
|
|
||||||
|
|
||||||
def test_principal_cannot_bind_across_tenant_boundary(self) -> None:
|
|
||||||
with self.assertRaises(ScopeError):
|
|
||||||
self.router.bind_principal(
|
|
||||||
tenant_id="tenant-b",
|
|
||||||
channel="weixin",
|
|
||||||
channel_account_id="account-a",
|
|
||||||
principal_id="user-a",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_session_cannot_cross_cell(self) -> None:
|
|
||||||
with self.assertRaises(ScopeError):
|
|
||||||
self.router.bind_session(
|
|
||||||
tenant_id="tenant-a",
|
|
||||||
session_id="session-a",
|
|
||||||
cell_id="cell-b",
|
|
||||||
principal_id="user-a",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_session_cannot_cross_principal(self) -> None:
|
|
||||||
self.router.bind_session(
|
|
||||||
tenant_id="tenant-a",
|
|
||||||
session_id="session-a",
|
|
||||||
cell_id="cell-a",
|
|
||||||
principal_id="user-a",
|
|
||||||
)
|
|
||||||
|
|
||||||
with self.assertRaises(ConflictError):
|
|
||||||
self.router.bind_session(
|
|
||||||
tenant_id="tenant-a",
|
|
||||||
session_id="session-a",
|
|
||||||
cell_id="cell-a",
|
|
||||||
principal_id="user-b",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_expired_session_is_rejected_without_private_storage_access(self) -> None:
|
|
||||||
clock = MutableClock(datetime(2026, 7, 13, tzinfo=timezone.utc))
|
|
||||||
router = TenantRouter(f"{self.directory.name}/expiry.sqlite3", clock=clock)
|
|
||||||
router.register_tenant("tenant-a", "cell-a")
|
|
||||||
router.bind_channel(
|
|
||||||
tenant_id="tenant-a", channel="weixin", channel_account_id="account-a"
|
|
||||||
)
|
|
||||||
router.bind_session(
|
|
||||||
tenant_id="tenant-a",
|
|
||||||
session_id="session-a",
|
|
||||||
cell_id="cell-a",
|
|
||||||
principal_id="user-a",
|
|
||||||
)
|
|
||||||
clock.now += timedelta(days=2)
|
|
||||||
|
|
||||||
with self.assertRaises(ScopeError):
|
|
||||||
router.resolve(
|
|
||||||
channel="weixin",
|
|
||||||
channel_account_id="account-a",
|
|
||||||
principal_id="user-a",
|
|
||||||
session_id="session-a",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_self_reported_tenant_or_cell_mismatch_fails_against_authority(self) -> None:
|
|
||||||
self.router.bind_session(
|
|
||||||
tenant_id="tenant-a",
|
|
||||||
session_id="session-a",
|
|
||||||
cell_id="cell-a",
|
|
||||||
principal_id="user-a",
|
|
||||||
)
|
|
||||||
expected = "message claims do not match authoritative route"
|
|
||||||
with self.assertRaisesRegex(ScopeError, expected):
|
|
||||||
self.router.assert_message_route(canonical_message(tenant_id="tenant-b"))
|
|
||||||
with self.assertRaisesRegex(ScopeError, expected):
|
|
||||||
self.router.assert_message_route(canonical_message(cell_id="cell-b"))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
"""Security contracts for authenticated OpenClaw runtime identity binding."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from tempfile import TemporaryDirectory
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from aios_v2r1.runtime import RuntimeWiring
|
|
||||||
from aios_v2r1.tenant_context import canonical_json
|
|
||||||
|
|
||||||
|
|
||||||
class MutableClock:
|
|
||||||
def __init__(self, now: datetime) -> None:
|
|
||||||
self.now = now
|
|
||||||
|
|
||||||
def __call__(self) -> datetime:
|
|
||||||
return self.now
|
|
||||||
|
|
||||||
|
|
||||||
class RuntimeIdentityBindingTests(unittest.TestCase):
|
|
||||||
internal_key = b"internal-context-signing-key-is-32-bytes"
|
|
||||||
ingress_key = b"external-ingress-hmac-key-is-32-bytes"
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.directory = TemporaryDirectory()
|
|
||||||
self.addCleanup(self.directory.cleanup)
|
|
||||||
self.clock = MutableClock(datetime(2026, 7, 13, tzinfo=timezone.utc))
|
|
||||||
self.runtime = RuntimeWiring.create(
|
|
||||||
self.directory.name,
|
|
||||||
signing_key=self.internal_key,
|
|
||||||
ingress_hmac_key=self.ingress_key,
|
|
||||||
clock=self.clock,
|
|
||||||
)
|
|
||||||
self.runtime.router.register_tenant("tenant-a", "cell-a")
|
|
||||||
self.runtime.router.register_tenant("tenant-b", "cell-b")
|
|
||||||
self.runtime.router.bind_channel(
|
|
||||||
tenant_id="tenant-a", channel="weixin", channel_account_id="account-a"
|
|
||||||
)
|
|
||||||
self.runtime.router.bind_channel(
|
|
||||||
tenant_id="tenant-b", channel="weixin", channel_account_id="account-b"
|
|
||||||
)
|
|
||||||
self.runtime.router.bind_principal(
|
|
||||||
tenant_id="tenant-a", channel="weixin", channel_account_id="account-a",
|
|
||||||
principal_id="user-a",
|
|
||||||
)
|
|
||||||
self.runtime.router.bind_session(
|
|
||||||
tenant_id="tenant-a", session_id="session-a", cell_id="cell-a",
|
|
||||||
principal_id="user-a", ttl_seconds=60,
|
|
||||||
)
|
|
||||||
|
|
||||||
def message(self, **changes: object) -> dict[str, object]:
|
|
||||||
values: dict[str, object] = {
|
|
||||||
"tenant_id": "tenant-a", "cell_id": "cell-a", "principal_id": "user-a",
|
|
||||||
"principal_type": "authenticated_user", "origin_type": "channel_user",
|
|
||||||
"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-a", "message_id": "message-a", "reply_to_message_id": None,
|
|
||||||
"direction": "inbound", "command_capable": True,
|
|
||||||
"occurred_at": "2026-07-13T00:00:00+00:00",
|
|
||||||
"received_at": "2026-07-13T00:00:01+00:00",
|
|
||||||
"content": {"body": "hello", "quoted_body": None},
|
|
||||||
"adapter": {"name": "weixin", "version": "v2"},
|
|
||||||
}
|
|
||||||
values.update(changes)
|
|
||||||
return values
|
|
||||||
|
|
||||||
def authenticated_principal_type(self):
|
|
||||||
try:
|
|
||||||
from aios_v2r1.identity import AuthenticatedPrincipal
|
|
||||||
except ImportError as exc:
|
|
||||||
self.fail(f"Identity resolver contract is missing: {exc}")
|
|
||||||
return AuthenticatedPrincipal
|
|
||||||
|
|
||||||
def signed_request(self, **changes: object) -> dict[str, object]:
|
|
||||||
values: dict[str, object] = {
|
|
||||||
"principal_id": "user-a",
|
|
||||||
"tenant_id": "tenant-a",
|
|
||||||
"cell_id": "cell-a",
|
|
||||||
"channel": "weixin",
|
|
||||||
"channel_account_id": "account-a",
|
|
||||||
"session_id": "session-a",
|
|
||||||
"message": self.message(),
|
|
||||||
}
|
|
||||||
values.update(changes)
|
|
||||||
signed = canonical_json(values)
|
|
||||||
values["signature"] = hmac.new(
|
|
||||||
self.ingress_key, signed, hashlib.sha256
|
|
||||||
).hexdigest()
|
|
||||||
return values
|
|
||||||
|
|
||||||
def test_raw_payload_cannot_construct_authenticated_principal(self) -> None:
|
|
||||||
AuthenticatedPrincipal = self.authenticated_principal_type()
|
|
||||||
with self.assertRaises(TypeError):
|
|
||||||
AuthenticatedPrincipal("user-a") # type: ignore[call-arg]
|
|
||||||
|
|
||||||
def test_forged_tenant_claim_is_rejected_after_valid_hmac(self) -> None:
|
|
||||||
request = self.signed_request(
|
|
||||||
tenant_id="tenant-b", cell_id="cell-b",
|
|
||||||
message=self.message(tenant_id="tenant-b", cell_id="cell-b"),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = self.runtime.ingest(request=request)
|
|
||||||
|
|
||||||
self.assertEqual("AUTH_FAILED", result.status)
|
|
||||||
self.assertIsNone(result.result)
|
|
||||||
self.assertEqual(0, self.runtime.ledger.count())
|
|
||||||
|
|
||||||
def test_invalid_principal_is_rejected_after_valid_hmac(self) -> None:
|
|
||||||
request = self.signed_request(
|
|
||||||
principal_id="unknown-user",
|
|
||||||
message=self.message(principal_id="unknown-user"),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = self.runtime.ingest(request=request)
|
|
||||||
|
|
||||||
self.assertEqual("AUTH_FAILED", result.status)
|
|
||||||
self.assertIsNone(result.result)
|
|
||||||
self.assertEqual(0, self.runtime.ledger.count())
|
|
||||||
|
|
||||||
def test_registered_principal_is_resolved_before_internal_context_is_issued(self) -> None:
|
|
||||||
request = self.signed_request()
|
|
||||||
result = self.runtime.ingest(request=request)
|
|
||||||
|
|
||||||
self.assertEqual("ACCEPTED", result.status)
|
|
||||||
self.assertIsNotNone(result.result)
|
|
||||||
assert result.result is not None
|
|
||||||
self.assertEqual("tenant-a", result.result.route.tenant_id)
|
|
||||||
self.assertEqual("message-a", result.result.record.message_id)
|
|
||||||
self.assertEqual(1, self.runtime.ledger.count())
|
|
||||||
self.assertEqual(1, self.runtime.session_pool.active_count())
|
|
||||||
|
|
||||||
def test_cross_tenant_channel_claim_cannot_retarget_authenticated_principal(self) -> None:
|
|
||||||
request = self.signed_request(
|
|
||||||
tenant_id="tenant-b", cell_id="cell-b", channel_account_id="account-b",
|
|
||||||
message=self.message(
|
|
||||||
tenant_id="tenant-b", cell_id="cell-b", channel_account_id="account-b"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = self.runtime.ingest(request=request)
|
|
||||||
|
|
||||||
self.assertEqual("AUTH_FAILED", result.status)
|
|
||||||
self.assertEqual(0, self.runtime.ledger.count())
|
|
||||||
self.assertEqual(0, self.runtime.session_pool.active_count())
|
|
||||||
|
|
||||||
def test_hmac_binds_all_scope_claims(self) -> None:
|
|
||||||
request = self.signed_request()
|
|
||||||
request["tenant_id"] = "tenant-b"
|
|
||||||
|
|
||||||
result = self.runtime.ingest(request=request)
|
|
||||||
|
|
||||||
self.assertEqual("AUTH_FAILED", result.status)
|
|
||||||
self.assertEqual(0, self.runtime.ledger.count())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
"""Integration contracts for the V2R1 authenticated runtime composition root."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
from tempfile import TemporaryDirectory
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from aios_v2r1.runtime import RuntimeWiring
|
|
||||||
from aios_v2r1.tenant_context import canonical_json
|
|
||||||
|
|
||||||
|
|
||||||
class MutableClock:
|
|
||||||
def __init__(self, now: datetime) -> None:
|
|
||||||
self.now = now
|
|
||||||
|
|
||||||
def __call__(self) -> datetime:
|
|
||||||
return self.now
|
|
||||||
|
|
||||||
|
|
||||||
class RuntimeWiringIntegrationTests(unittest.TestCase):
|
|
||||||
internal_key = b"runtime-internal-context-key-is-32bytes"
|
|
||||||
ingress_key = b"runtime-external-ingress-key-is-32bytes"
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.directory = TemporaryDirectory()
|
|
||||||
self.addCleanup(self.directory.cleanup)
|
|
||||||
self.clock = MutableClock(datetime(2026, 7, 13, tzinfo=timezone.utc))
|
|
||||||
self.runtime = RuntimeWiring.create(
|
|
||||||
self.directory.name,
|
|
||||||
signing_key=self.internal_key,
|
|
||||||
ingress_hmac_key=self.ingress_key,
|
|
||||||
clock=self.clock,
|
|
||||||
)
|
|
||||||
self.runtime.router.register_tenant("tenant-a", "cell-a")
|
|
||||||
self.runtime.router.register_tenant("tenant-b", "cell-b")
|
|
||||||
self.runtime.router.bind_channel(
|
|
||||||
tenant_id="tenant-a", channel="weixin", channel_account_id="account-a"
|
|
||||||
)
|
|
||||||
self.runtime.router.bind_channel(
|
|
||||||
tenant_id="tenant-b", channel="weixin", channel_account_id="account-b"
|
|
||||||
)
|
|
||||||
self.runtime.router.bind_principal(
|
|
||||||
tenant_id="tenant-a", channel="weixin", channel_account_id="account-a",
|
|
||||||
principal_id="user-a",
|
|
||||||
)
|
|
||||||
self.runtime.router.bind_session(
|
|
||||||
tenant_id="tenant-a", session_id="session-a", cell_id="cell-a",
|
|
||||||
principal_id="user-a", ttl_seconds=60,
|
|
||||||
)
|
|
||||||
|
|
||||||
def message(
|
|
||||||
self, *, event_id: str = "event-a", message_id: str = "message-a",
|
|
||||||
body: str = "hello", **changes: object,
|
|
||||||
) -> dict[str, object]:
|
|
||||||
values: dict[str, object] = {
|
|
||||||
"tenant_id": "tenant-a", "cell_id": "cell-a", "principal_id": "user-a",
|
|
||||||
"principal_type": "authenticated_user", "origin_type": "channel_user",
|
|
||||||
"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": message_id, "reply_to_message_id": None,
|
|
||||||
"direction": "inbound", "command_capable": True,
|
|
||||||
"occurred_at": "2026-07-13T00:00:00+00:00",
|
|
||||||
"received_at": "2026-07-13T00:00:01+00:00",
|
|
||||||
"content": {"body": body, "quoted_body": None},
|
|
||||||
"adapter": {"name": "weixin", "version": "v2"},
|
|
||||||
}
|
|
||||||
values.update(changes)
|
|
||||||
return values
|
|
||||||
|
|
||||||
def request(self, **changes: object) -> dict[str, object]:
|
|
||||||
values: dict[str, object] = {
|
|
||||||
"principal_id": "user-a", "tenant_id": "tenant-a", "cell_id": "cell-a",
|
|
||||||
"channel": "weixin", "channel_account_id": "account-a", "session_id": "session-a",
|
|
||||||
"message": self.message(),
|
|
||||||
}
|
|
||||||
values.update(changes)
|
|
||||||
values["signature"] = hmac.new(
|
|
||||||
self.ingress_key, canonical_json(values), hashlib.sha256
|
|
||||||
).hexdigest()
|
|
||||||
return values
|
|
||||||
|
|
||||||
def ingest(self, **changes: object):
|
|
||||||
return self.runtime.ingest(request=self.request(**changes))
|
|
||||||
|
|
||||||
def test_real_ingress_appends_exact_record_and_reopens(self) -> None:
|
|
||||||
first = self.ingest()
|
|
||||||
self.assertEqual("ACCEPTED", first.status)
|
|
||||||
assert first.result is not None
|
|
||||||
self.assertEqual("message-a", first.result.record.message_id)
|
|
||||||
self.assertEqual("tenant-a", first.result.route.tenant_id)
|
|
||||||
self.assertEqual(1, self.runtime.ledger.count())
|
|
||||||
self.assertEqual(1, self.runtime.session_pool.active_count())
|
|
||||||
|
|
||||||
reopened = RuntimeWiring.create(
|
|
||||||
self.directory.name, signing_key=self.internal_key,
|
|
||||||
ingress_hmac_key=self.ingress_key, clock=self.clock,
|
|
||||||
)
|
|
||||||
repeated = reopened.ingest(request=self.request())
|
|
||||||
self.assertEqual("ACCEPTED", repeated.status)
|
|
||||||
assert repeated.result is not None
|
|
||||||
self.assertEqual(first.result.record, repeated.result.record)
|
|
||||||
self.assertEqual(1, reopened.ledger.count())
|
|
||||||
self.assertEqual(1, reopened.session_pool.active_count())
|
|
||||||
|
|
||||||
def test_authoritative_tenant_route_rejects_cross_tenant_ingress_without_mutation(self) -> None:
|
|
||||||
result = self.ingest(
|
|
||||||
tenant_id="tenant-b", cell_id="cell-b",
|
|
||||||
message=self.message(tenant_id="tenant-b", cell_id="cell-b"),
|
|
||||||
)
|
|
||||||
self.assertEqual("AUTH_FAILED", result.status)
|
|
||||||
self.assertIsNone(result.result)
|
|
||||||
self.assertEqual(0, self.runtime.ledger.count())
|
|
||||||
self.assertEqual(0, self.runtime.session_pool.active_count())
|
|
||||||
|
|
||||||
def test_identical_concurrent_ingress_is_one_exact_ledger_record_and_one_session(self) -> None:
|
|
||||||
with ThreadPoolExecutor(max_workers=24) as executor:
|
|
||||||
results = list(executor.map(lambda _: self.ingest(), range(24)))
|
|
||||||
self.assertEqual({"ACCEPTED"}, {result.status for result in results})
|
|
||||||
self.assertEqual(1, self.runtime.ledger.count())
|
|
||||||
self.assertEqual(1, self.runtime.session_pool.active_count())
|
|
||||||
|
|
||||||
def test_collision_is_sanitized_and_does_not_leave_partial_session_or_ledger_state(self) -> None:
|
|
||||||
self.assertEqual("ACCEPTED", self.ingest(
|
|
||||||
message=self.message(event_id="event-a", message_id="message-a", body="first")
|
|
||||||
).status)
|
|
||||||
collision = self.ingest(
|
|
||||||
message=self.message(event_id="event-a", message_id="message-b", body="second")
|
|
||||||
)
|
|
||||||
self.assertEqual("CONFLICT", collision.status)
|
|
||||||
self.assertIsNone(collision.result)
|
|
||||||
self.assertEqual(1, self.runtime.ledger.count())
|
|
||||||
self.assertEqual(1, self.runtime.session_pool.active_count())
|
|
||||||
|
|
||||||
def test_unknown_cross_and_expired_sessions_fail_closed_without_mutation(self) -> None:
|
|
||||||
unknown = self.ingest(
|
|
||||||
session_id="unknown-session", message=self.message(session_id="unknown-session")
|
|
||||||
)
|
|
||||||
self.assertEqual("AUTH_FAILED", unknown.status)
|
|
||||||
cross = self.ingest(
|
|
||||||
principal_id="user-b", message=self.message(principal_id="user-b")
|
|
||||||
)
|
|
||||||
self.assertEqual("AUTH_FAILED", cross.status)
|
|
||||||
self.clock.now += timedelta(seconds=61)
|
|
||||||
expired = self.ingest()
|
|
||||||
self.assertEqual("AUTH_FAILED", expired.status)
|
|
||||||
self.assertEqual(0, self.runtime.ledger.count())
|
|
||||||
self.assertEqual(0, self.runtime.session_pool.active_count())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
"""Gate 1 contract tests for canonical tenant-context signatures."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from aios_v2r1.tenant_context import TenantContext, ValidationError
|
|
||||||
|
|
||||||
|
|
||||||
class TenantContextSignatureContractTests(unittest.TestCase):
|
|
||||||
key = b"k" * 32
|
|
||||||
now = datetime(2026, 7, 13, tzinfo=timezone.utc)
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.context = TenantContext(
|
|
||||||
tenant_id="tenant-a",
|
|
||||||
cell_id="cell-a",
|
|
||||||
principal_id="user-a",
|
|
||||||
principal_type="user",
|
|
||||||
session_id="session-a",
|
|
||||||
task_id=None,
|
|
||||||
channel="hermes",
|
|
||||||
channel_account_id="account-a",
|
|
||||||
issued_at="2026-07-13T00:00:00+00:00",
|
|
||||||
expires_at="2026-07-13T00:05:00+00:00",
|
|
||||||
nonce="nonce-b",
|
|
||||||
)
|
|
||||||
self.token = self.context.sign(self.key)
|
|
||||||
|
|
||||||
def test_valid_canonical_token_verifies(self) -> None:
|
|
||||||
verified = TenantContext.verify(self.token, self.key, now=self.now)
|
|
||||||
|
|
||||||
self.assertEqual(verified, self.context)
|
|
||||||
|
|
||||||
def test_rejects_padded_signature_encoding(self) -> None:
|
|
||||||
with self.assertRaises(ValidationError):
|
|
||||||
TenantContext.verify(f"{self.token}=", self.key, now=self.now)
|
|
||||||
|
|
||||||
def test_rejects_standard_base64_signature_encoding(self) -> None:
|
|
||||||
payload, signature = self.token.rsplit(".", 1)
|
|
||||||
standard_signature = signature.replace("-", "+").replace("_", "/")
|
|
||||||
self.assertNotEqual(signature, standard_signature)
|
|
||||||
|
|
||||||
with self.assertRaises(ValidationError):
|
|
||||||
TenantContext.verify(
|
|
||||||
f"{payload}.{standard_signature}", self.key, now=self.now
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "V1R4C5",
|
|
||||||
"codename": "C5 reliability-security-admin",
|
|
||||||
"status": "active-baseline",
|
|
||||||
"tag": "V1R4C5",
|
|
||||||
"tag_ref": "refs/tags/V1R4C5",
|
|
||||||
"tag_object": "ac9bdacb19ff66cba8046d75358866f0f0fffb82",
|
|
||||||
"previous_version": "V1R4C3",
|
|
||||||
"rollback_version": "V1R4C3",
|
|
||||||
"baseline": "V1R4C3",
|
|
||||||
"release_reason": "reliability, watcher cleanup, user data isolation gate, and admin workbench read-only preparation; memory search disabled",
|
|
||||||
"purpose": "stabilize autonomous operations, enforce user isolation gate, prepare admin workbench read-only for C6",
|
|
||||||
"changes": [
|
|
||||||
"reliability: idle container GC with nixos-activate fallback retention policy and CI reset",
|
|
||||||
"watcher: remove redundant local async watcher, consolidate to server-pushed events",
|
|
||||||
"security: user data isolation gate (read-only admin panel, fb isolation check)",
|
|
||||||
"admin workbench: read-only preparation layout for V1R4C6 admin-search integration",
|
|
||||||
"governance: final owner approval via Owner Gate Web UI"
|
|
||||||
],
|
|
||||||
"artifacts": [
|
|
||||||
"VERSION",
|
|
||||||
"docs/releases/V1R4C5-acceptance-evidence.md",
|
|
||||||
"docs/releases/V1R4C5-final-owner-approval.md"
|
|
||||||
],
|
|
||||||
"validated_actions": [],
|
|
||||||
"secret_plaintext_written": false,
|
|
||||||
"owner_approval_required": true,
|
|
||||||
"owner_notification_channels": ["wechat", "web_ui_audit"],
|
|
||||||
"constraints_original": {
|
|
||||||
"memory_search": "remain disabled",
|
|
||||||
"sr04": "disable read-only visibility slice if RBAC or isolation check fails",
|
|
||||||
"gitea": "enter autonomous safe mode if branch consistency drift detected"
|
|
||||||
},
|
|
||||||
"promoted_at": "2026-07-11T02:17:31+08:00",
|
|
||||||
"promoted_by": "openclaw/admin",
|
|
||||||
"created_at": "2026-07-11T01:40:00+08:00",
|
|
||||||
"author": "openclaw"
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "V1R4C6",
|
|
||||||
"codename": "admin-search-agent-integration",
|
|
||||||
"status": "release-candidate-started",
|
|
||||||
"base_baseline": "V1R4C5",
|
|
||||||
"created_at": "2026-07-11",
|
|
||||||
"created_by": "chatgpt-gitops-bot",
|
|
||||||
"release_type": "controlled-bundled-integration",
|
|
||||||
"promotion_policy": "owner-gated",
|
|
||||||
"owner_decision": {
|
|
||||||
"accepted_scope": true,
|
|
||||||
"requested_delivery": "admin workbench, SR-03, and Agent Platform Integration in one release train",
|
|
||||||
"memory_search_enabled": false,
|
|
||||||
"force_push_allowed": false
|
|
||||||
},
|
|
||||||
"scope": {
|
|
||||||
"included_groups": [
|
|
||||||
{
|
|
||||||
"id": "V1R4C6-A",
|
|
||||||
"name": "Admin Workbench and Portal AIOS Read-Only Rollout",
|
|
||||||
"branches": [
|
|
||||||
"feature/v1r4c4-sr04-admin-workbench-visibility-20260711",
|
|
||||||
"feature/v1r4c4-sr04-portal-aios-visibility-20260710",
|
|
||||||
"patch/user-portal-me-route-diagnostics-20260709",
|
|
||||||
"patch/user-portal-rbac-ops-dashboard",
|
|
||||||
"patch/agent-observability-webui",
|
|
||||||
"patch/web-ui-complete-gitops-dashboard-20260706"
|
|
||||||
],
|
|
||||||
"risk": "medium",
|
|
||||||
"delivery": "administrator-visible task progress, process diagrams, health, agent observability, and portal AIOS entry points",
|
|
||||||
"guardrails": [
|
|
||||||
"read-only first",
|
|
||||||
"RBAC enforced",
|
|
||||||
"empty states explicit",
|
|
||||||
"no cross-user or cross-project data exposure"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "V1R4C6-B",
|
|
||||||
"name": "SR-03 Search Gateway Real Provider Binding",
|
|
||||||
"branches": [
|
|
||||||
"feature/v1r4c4-sr02-search-gateway-resilience-20260710",
|
|
||||||
"patch/search-gateway-governed-general-capability",
|
|
||||||
"patch/gitops-change-archive-audit"
|
|
||||||
],
|
|
||||||
"risk": "high",
|
|
||||||
"delivery": "real provider binding, governed search gateway, resilience, and audit trail",
|
|
||||||
"guardrails": [
|
|
||||||
"user data isolation gate required",
|
|
||||||
"scope filter before ranking",
|
|
||||||
"provenance required",
|
|
||||||
"no global memory/vector index"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "V1R4C6-C",
|
|
||||||
"name": "Agent Platform Integration",
|
|
||||||
"branches": [
|
|
||||||
"patch/agent-event-bus-unified",
|
|
||||||
"patch/agent-gpt-message-plane",
|
|
||||||
"patch/autonomous-controller-dispatch-20260709",
|
|
||||||
"patch/self-evolution-agent-orchestration-policy",
|
|
||||||
"patch/aios-autonomous-orchestrator-architecture-review-v1"
|
|
||||||
],
|
|
||||||
"risk": "high",
|
|
||||||
"delivery": "agent event bus, GPT message plane, dispatch, orchestration policy, and architecture record",
|
|
||||||
"guardrails": [
|
|
||||||
"controller safe-mode respected",
|
|
||||||
"event/message scopes enforced",
|
|
||||||
"owner gate for consequential operations",
|
|
||||||
"observability before autonomous expansion"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"excluded": [
|
|
||||||
"memory search activation",
|
|
||||||
"global vector index",
|
|
||||||
"full cross-session recall",
|
|
||||||
"unscoped search over memory",
|
|
||||||
"bulk merge of test/smoke/auth branches",
|
|
||||||
"Gitea DB mutation",
|
|
||||||
"force-push reconciliation"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"acceptance_required": [
|
|
||||||
"getRuntimeStatus active",
|
|
||||||
"getBaseline reports V1R4C5 before integration and V1R4C6 after promotion",
|
|
||||||
"Admin workbench RBAC read-only check passes",
|
|
||||||
"Portal AIOS manifest/dashboard/health routes verified",
|
|
||||||
"Flow/process diagram renders or returns explicit empty state",
|
|
||||||
"Search gateway provider binding returns scoped, provenanced results only",
|
|
||||||
"User data isolation acceptance remains green",
|
|
||||||
"Agent event bus and GPT message plane tests pass",
|
|
||||||
"Autonomous controller dispatch respects safe mode",
|
|
||||||
"Memory search remains disabled"
|
|
||||||
],
|
|
||||||
"deployment_constraints": {
|
|
||||||
"AIOS_MEMORY_ENABLED": false,
|
|
||||||
"memory_search_activation_requires_future_owner_approval": true,
|
|
||||||
"force_push_allowed": false,
|
|
||||||
"gitea_db_mutation_requires_owner_admin_approval": true,
|
|
||||||
"admin_workbench_mode": "read_only_first",
|
|
||||||
"search_provider_scope": "scoped_only"
|
|
||||||
},
|
|
||||||
"rollback_policy": {
|
|
||||||
"admin_workbench": "disable new menu/routes if RBAC or route acceptance fails",
|
|
||||||
"search_gateway": "fallback to disabled provider binding, not memory search",
|
|
||||||
"agent_platform": "disable dispatch expansion and keep event/message plane read-only if safety tests fail",
|
|
||||||
"memory_search": "remain disabled"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user