Compare commits

..
Author SHA1 Message Date
Steel Dan (AIOS) 8160c2b2f6 docs(weixin): record quoted text reference fix 2026-07-12 01:00:14 +08:00
Steel Dan (AIOS) 706c0b0096 fix(weixin): ignore unconfirmed text references 2026-07-12 00:22:36 +08:00
Steel Dan (AIOS) 2c08a9d0cf docs(weixin): update confirmed image reference recovery 2026-07-11 17:54:49 +08:00
Steel Dan (AIOS) 3d9782fc34 fix(weixin): require confirmed image reference context 2026-07-11 17:36:00 +08:00
Steel Dan (AIOS) 4a9c5138af docs(weixin): record text cache isolation recovery 2026-07-11 16:46:58 +08:00
Steel Dan (AIOS) 2de29c59bb fix(weixin): add process-only runtime patch mode 2026-07-11 15:23:36 +08:00
Steel Dan (AIOS) 66d637fc03 fix(weixin): isolate text from stale image cache 2026-07-11 14:24:06 +08:00
Steel Dan (AIOS) d19b307fff Apply safe-mode gate, h_branch_sync, sentinel wiring, create_patch fix
- safe_mode: check_safe_mode() raises if instability thresholds exceeded
- h_branch_sync: use git ls-remote + SQLite cache, no Gitea API dependency
- check_sentinel_drift: detect missing/inconsistent gitea-branch-consistency sentinel
- create_patch: return dict instead of bool for caller visibility
- tests: adapt test_autonomous_controller to new return signatures
- add: test_evolution_engine.py for core engine cycles and regression
2026-07-10 13:04:16 +08:00
Steel Dan (AIOS) 59565ebc25 feat(sr01): add consistency sentinel, safe-mode gate, and test suite
- Integrate gitea_branch_consistency_sentinel.py (bare repo refs ↔ Gitea SQLite ↔ API)
- Integrate autonomous_safe_mode.py for controller mutation gate
- Update autonomous_controller.py with safe-mode checks before writes
- Add tests/test_autonomous_controller.py (11 tests covering h_branch_sync, h_stuck, apply_approved_patches, diagnostics)
2026-07-10 09:19:28 +08:00
Steel Dan (AIOS) fd7abd84ba Merge remote-tracking branch 'origin/feature/gitea-reliability-guardrails-20260710' into feature/v1r4c4-sr01-gitea-branch-sync-fix-20260710 2026-07-10 09:18:57 +08:00
openclaw 12d09b1dea proposal: start V1R4C4 SR-01 Gitea branch sync fix 2026-07-10 01:42:12 +08:00
openclaw 6905629268 ops: add Gitea consistency sentinel timer unit 2026-07-10 01:27:26 +08:00
openclaw 44de4b9e7d feat: add autonomous safe mode mutation gate 2026-07-10 01:27:22 +08:00
openclaw dbc482e0c1 feat: add Gitea branch consistency sentinel 2026-07-10 01:27:18 +08:00
148 changed files with 1383 additions and 13638 deletions
@@ -1,397 +0,0 @@
# Personal-Weixin B3/B4 Owner Evidence Authorization
```yaml
authorization_id: AUTH-CODEX-V2R1-PERSONAL-WEIXIN-B3-B4-EVIDENCE-20260716
owner_approved: true
executor: Codex isolated runner
foundation_implementation_commit: 2fcab6f5833a405a9a26683811600b6e5f1aa7b1
foundation_result_commit: 64a71b6199a8e2bd609ba145349e67b874888505
r10_lineage_commit: 74e603c0c943501110577dbe3c7f6a0b18b8ec7d
evidence_gate_commit: 62a4067cf7a462ebe7cf29524238bc5d0a70b813
upstream_audit_commit: b2f5f531019a70801444987dbb97dc1f69cf47de
production_baseline: V1R4C6
production_changed: false
release_status: paused
force_push_authorized: false
```
## 1. 授权结论
Owner 明确授权 Codex:
1. 完成 B3 owner evidence packet;
2. 在 vendor `client_id → authoritative message_id` 证据缺失时,采用 B4 quote-authority-disabled amendment;
3. B3、B4 均 PASS 后,按已有 exact allowlist 继续 B5–B7;
4. B3–B7 全部 PASS 后才能开始 Release Preflight;
5. Release Preflight PASS 后才允许 Canary;
6. Codex 不得直接修改生产。
该授权不允许 Codex编造 raw-wire、credential、vendor 或 reviewer 证据。
## 2. B3 owner evidence 必须包含
B3 evidence manifest 必须引用上述五个权威提交,并包含以下五类证据。
### A. Raw-wire evidence
至少包含:
```yaml
raw_wire:
- direction: inbound
- direction: outbound
```
每项必须记录:
```yaml
restricted_path:
restricted_sha256:
restricted_bytes:
redacted_path:
redacted_sha256:
captured_at:
credential_label: credential-a | credential-b
```
要求:
* unrestricted raw-wire 只能保存在 owner 批准的受限存储;
* Git 中只能提交 SHA-256、大小、受限路径引用及 redacted view;
* 不得提交 token、API key、OAuth code、sessionKey、cookie 或完整 credential;
* inbound 与 outbound 必须各至少一份;
-两份 credential 都必须实际覆盖。
### B. Authority map
必须提供 commit-safe authority map,明确:
```yaml
vendor_message_id_field:
client_id_field:
client_id_authoritative: false
mapping_verified: true
```
Authority map 必须区分:
```yaml
authoritative:
- verified inbound vendor message_id
non_authoritative:
- local client_id
- outbound correlation ID
- local sequence
- timestamp
- content hash
- outbound receipt ID
```
禁止把任一非权威值提升为 message authority。
### C. 双 credential 证据
必须有精确两项:
```yaml
credentials:
- label: credential-a
fingerprint_sha256:
secret_included: false
scope:
test_result: PASS
- label: credential-b
fingerprint_sha256:
secret_included: false
scope:
test_result: PASS
```
要求:
* 两个 fingerprint 必须不同;
* 不得提交 secret 原文;
* 两个 credential 必须各自通过实际测试;
* credential scope 必须明确。
### D. Mode evidence
必须明确处于下列模式之一:
```yaml
mode:
name: sandbox | shadow | restricted-capture
production_traffic: false
writes_production: false
capture_only: true
```
不得使用生产流量或写生产数据来补证据。
### E. Reviewer evidence
必须由独立 reviewer 签署:
```yaml
review:
reviewer:
independent_from_executor: true
decision: PASS
reviewed_manifest_sha256:
reviewed_at:
```
Reviewer 必须核对:
* raw-wire 哈希;
* redacted view;
* authority map;
-两个 credential fingerprint;
* mode;
* manifest 完整 SHA-256。
## 3. B3 PASS 条件
只有以下全部成立才允许 B3 `status: PASS`:
```yaml
raw_wire_inbound: PASS
raw_wire_outbound: PASS
dual_credential: PASS
authority_map: PASS
client_id_authoritative: false
mode_non_production: PASS
reviewer_independent: PASS
reviewer_decision: PASS
secrets_committed: false
production_changed: false
baseline_after: V1R4C6
release_status: paused
```
真实证据缺失时必须继续:
```yaml
status: BLOCKED_EVIDENCE_REQUIRED
```
不得生成模拟证据代替 owner/vendor 证据。
## 4. B4 Owner Amendment
Owner 明确批准以下 B4 amendment:
```json
{
"schemaVersion": 1,
"contract": "personal-weixin-b4-outbound-quote-authority-amendment",
"ownerApproved": true,
"lineage": {
"foundationImplementationCommit": "2fcab6f5833a405a9a26683811600b6e5f1aa7b1",
"foundationResultCommit": "64a71b6199a8e2bd609ba145349e67b874888505",
"r10LineageCommit": "74e603c0c943501110577dbe3c7f6a0b18b8ec7d",
"evidenceGateCommit": "62a4067cf7a462ebe7cf29524238bc5d0a70b813",
"upstreamAuditCommit": "b2f5f531019a70801444987dbb97dc1f69cf47de"
},
"vendorClientIdMessageIdEvidence": {
"status": "absent",
"clientIdPromotable": false
},
"authorityPolicy": {
"outboundQuoteAuthority": "disabled",
"clientIdRole": "non-authoritative-correlation-only",
"authoritativeMessageIdSources": [
"validated-inbound-raw-wire.vendor_message_id"
],
"quotedSendWithoutAuthoritativeMessageId": "fail-closed",
"plainOutboundWithoutQuoteAuthority": "allowed-only-by-existing-b4-allowlist",
"syntheticMapping": false,
"timestampFallback": false,
"localSequenceFallback": false,
"contentHashFallback": false,
"outboundReceiptFallback": false
},
"implementationConstraints": {
"scopeExpansion": false,
"exactAllowlistRequired": true,
"missingAllowlistStatus": "BLOCKED_SCOPE",
"productionChangeAuthorized": false,
"canaryAuthorized": false,
"tagAuthorized": false,
"forcePushAuthorized": false
},
"production": {
"baseline": "V1R4C6",
"changed": false,
"releaseStatus": "paused"
}
}
```
该 JSON 必须作为独立 control-plane amendment 提交。
## 5. B4 强制语义
B4 实现必须遵守:
```yaml
client_id:
role: correlation-only
authoritative: false
vendor_message_id:
authoritative_only_when:
- observed in validated inbound raw-wire
- present in committed authority map
quoted_send:
without_authoritative_vendor_message_id: fail-closed
plain_outbound:
allowed_only_when:
- existing B4 exact allowlist permits it
- no quote/reply authority field is emitted
```
禁止:
* `client_id` → `message_id` alias;
* client ID hash fallback;
* timestamp fallback;
* local sequence fallback;
* content hash fallback;
* outbound receipt fallback;
* synthetic message ID;
-“先发送后补 authority”。
## 6. B4 路径授权
该 amendment 不增加任何实现路径。
Codex必须先找到已有 B4 exact allowlist。
若不存在或不完整:
```yaml
status: BLOCKED_SCOPE
```
不得由 Codex自行发明路径。
## 7. B5–B7 条件
只有 B3 和 B4 均有 qualifying PASS RESULT 后,才可继续 B5–B7。
每个阶段必须:
* 独立 branch;
* 独立 worktree;
* 已提交 exact allowlist;
* 一个非 merge implementation commit;
* 独立 RESULT;
* 依赖实际 F0 commit 与 immutable image digest;
* 不修改生产;
* 不 force push。
任一阶段缺少 exact allowlist:
```yaml
status: BLOCKED_SCOPE
```
## 8. Release Preflight
只有以下全部 PASS 后才能开始:
```yaml
F0: PASS
B3: PASS
B4: PASS
B5: PASS
B6: PASS
B7: PASS
R10_lineage: PASS
immutable_image_digest: present
gitleaks: PASS
rollback_contract: present
```
Preflight 不得创建 tag 或部署生产。
## 9. Canary
仅在 Release Preflight PASS 后授权一次 Canary。
Canary 必须:
* 使用固定 immutable image digest;
* 使用已验证 rollback target;
* 不修改生产 schema;
* 不暴露 secrets;
* 有明确成功指标;
* 有自动/立即 rollback 条件。
Canary 失败:
```yaml
rollback: required
release_status: paused
production_baseline: V1R4C6
```
不得继续 production promotion。
## 10. Production 边界
Codex不得直接执行生产部署。
只有以下全部提交并核验后,Codex才可创建 deployment request:
```yaml
F0_and_B3_B7: PASS
release_preflight: PASS
canary: PASS
immutable_release_digest: present
rollback_target: verified
rollback_procedure: verified
go_live_evidence: committed
```
实际 V2R1C0 部署只能由:
```text
owner/admin GitOps release action
```
执行。
在该动作前:
```yaml
production: V1R4C6
production_changed: false
release: paused
V2R1C0_tag: absent
```
必须保持不变。
## 11. Codex 下一步
立即执行顺序:
1. 在现有 control branch 提交本授权文件;
2. 提交 byte-exact B4 amendment;
3. 检查 B3 packet 中是否已有真实 owner raw-wire、双 credential、authority map、mode 和 reviewer evidence;
4. 有真实证据则完成 B3 manifest 与审核;
5. 无真实证据则继续 `BLOCKED_EVIDENCE_REQUIRED`,明确缺失项;
6. B3 PASS 后按已有 exact allowlist执行 B3;
7. B4 按 quote-authority-disabled 模式执行;
8. B3/B4 PASS 后再检查 B5–B7;
9. 不得跳到 Release Preflight、Canary 或生产。
本授权已解除 B4 vendor mapping 阻塞,但没有伪造或豁免 B3 owner evidence。
@@ -1,144 +0,0 @@
# Personal-Weixin B3/B4 Owner Authorization Result
```yaml
status: BLOCKED_EVIDENCE_REQUIRED
decision: STOP_BEFORE_B3_B4_IMPLEMENTATION
task: Personal-Weixin B3/B4 owner authorization intake
timestamp_utc8: 20260716T120210UTC8
control:
base_commit: 1d334b1ccaac54c3c3ec4dd84693ab68fc67cef3
branch: control/codex/v2r1-personal-weixin-r10-lineage-amendment-20260716T095245UTC8
authorization:
commit: a1f933e500151dc8afc1b25b30448a31899a8f64
path: .agent/authorizations/codex/AUTH-CODEX-V2R1-PERSONAL-WEIXIN-B3-B4-EVIDENCE-20260716.md
blob_oid: ba2ad7fffa4ad3393f1a6c19b15b1501c64db9cb
sha256: 8649aa212eda2f9c62da8bfef6149f3f2f9d0926dd5b37c4992ad745c38647c4
bytes: 9370
attachment_byte_exact: PASS
owner_approved: true
b4_amendment:
commit: 1d334b1ccaac54c3c3ec4dd84693ab68fc67cef3
path: release/v2r1c0-rc1/personal-weixin-b4-outbound-quote-authority-amendment.json
blob_oid: df8b31c684ea744685e37dd677141106cbc8ffa8
sha256: 7c5cb1e83b8009235f5b14046c21f5e571a655f42828c57d5c4280f65eefa02f
bytes: 1575
json_parse: PASS
owner_approved: true
vendor_client_id_message_id_evidence: absent
client_id_promotable: false
outbound_quote_authority: disabled
client_id_role: non-authoritative-correlation-only
quoted_send_without_authoritative_message_id: fail-closed
synthetic_mapping: false
status: PASS
lineage:
foundation_implementation_commit: 2fcab6f5833a405a9a26683811600b6e5f1aa7b1
foundation_result_commit: 64a71b6199a8e2bd609ba145349e67b874888505
r10_lineage_commit: 74e603c0c943501110577dbe3c7f6a0b18b8ec7d
evidence_gate_commit: 62a4067cf7a462ebe7cf29524238bc5d0a70b813
upstream_audit_commit: b2f5f531019a70801444987dbb97dc1f69cf47de
b3:
status: BLOCKED_EVIDENCE_REQUIRED
request_packet_present: true
request_packet_is_evidence: false
implementation_started: false
qualifying_result_present: false
missing:
raw_wire_inbound:
- restricted_path
- restricted_sha256
- restricted_bytes
- redacted_path
- redacted_sha256
- captured_at
- credential_label
raw_wire_outbound:
- restricted_path
- restricted_sha256
- restricted_bytes
- redacted_path
- redacted_sha256
- captured_at
- credential_label
dual_credentials:
- credential-a distinct fingerprint_sha256, explicit scope, and actual PASS test
- credential-b distinct fingerprint_sha256, explicit scope, and actual PASS test
authority_map:
- vendor_message_id_field
- client_id_field
- client_id_authoritative false
- mapping_verified true
- authoritative and non_authoritative classifications
mode:
- sandbox, shadow, or restricted-capture
- production_traffic false
- writes_production false
- capture_only true
independent_review:
- reviewer identity
- independent_from_executor true
- decision PASS
- reviewed_manifest_sha256
- reviewed_at
secrets_committed: false
action: stop_without_implementation_commit
b4:
prior_vendor_mapping_blocker: REPLACED_BY_OWNER_AMENDMENT
contract_gate: PASS
implementation_status: NOT_STARTED
qualifying_result_present: false
execution_condition: B3 qualifying PASS result
enforced_semantics:
client_id_authoritative: false
outbound_quote_authority: disabled
quoted_send_without_authoritative_vendor_message_id: fail-closed
plain_outbound: existing_exact_allowlist_only
fallback_identifiers: forbidden
later_stages:
B5: NOT_STARTED
B6: NOT_STARTED
B7: NOT_STARTED
release_preflight: NOT_STARTED
canary: NOT_STARTED
production_promotion: NOT_STARTED
production:
baseline: V1R4C6
changed: false
release_status: paused
v2r1c0_tag: absent
safety:
implementation_branch_modified: false
control_branch_modified_by_result: false
production_mutated: false
secret_values_read: false
secret_values_recorded: false
force_push: false
execution_counters:
permission_denied_count: 0
checkout_collision_count: 0
git_lock_collision_count: 0
```
## Decision
The owner authorization and the byte-exact B4 control-plane amendment are
present and valid. The amendment replaces the missing vendor
`client_id -> authoritative message_id` mapping requirement by permanently
disabling outbound quote authority. It does not create or waive B3 evidence.
The only discovered B3 packet is an unfilled request/template archive. No
restricted raw-wire references, commit-safe redacted views, two tested
credential fingerprints, verified authority map, non-production capture-mode
record, or independent reviewer signature was found. Therefore B3 remains
`BLOCKED_EVIDENCE_REQUIRED`, and neither B3 nor B4 implementation was started.
No secret value was inspected or committed. Production remains unchanged at
the declared `V1R4C6` baseline and the release remains paused.
@@ -1,156 +0,0 @@
# V2R1 Gate 1 — Clean-Room Final Evidence
## Summary
| Field | Value |
|---|---|
| Date | 2026-07-14 |
| Source commit | `502ef30642597fb0b70b90ae2e8a0b6af217d80e` |
| Source branch | `origin/work/v2r1-integration-quality-2000` |
| Result branch | `fix/v2r1-g1-cleanroom-final-evidence-r2-20260714` |
| Result commit reference | `HEAD` |
| Gate | `G1` |
| Variant | `openclaw-cleanroom` |
| Status | `PASS` |
The immutable report uses `HEAD` for post-commit verification. The resolved commit object ID is recorded by the external acceptance result after this file is committed; a Git commit cannot reliably embed its own object ID because changing the file changes the tree and commit identity.
## Machine-Readable Result
```yaml
gate: G1
variant: openclaw-cleanroom
date: 2026-07-14
status: PASS
source_commit: 502ef30642597fb0b70b90ae2e8a0b6af217d80e
source_branch: origin/work/v2r1-integration-quality-2000
result_branch: fix/v2r1-g1-cleanroom-final-evidence-r2-20260714
result_commit_ref: HEAD
tests_total: 32
tests_passed: 32
tests_failed: 0
cleanup_scan: PASS
secret_scan: PASS
rollback_status: PASS
recovery_verified: true
workspace_clean_at_test_completion: true
merge_performed: false
tag_created: false
baseline_promoted: false
deployment_performed: false
```
## Evidence Provenance
The test, cleanup, secret-scan, rollback, and recovery results below are execution evidence supplied by OpenClaw for the Gate 1 run. This correction commit changes only the evidence report and does not claim that the GitOps API reran the suites.
## HMAC Test-Key Length Verification
| Test file | Variable | Test-only value | Length |
|---|---|---|---:|
| `tests/v2r1/test_runtime_wiring.py:24` | `internal_key` | `b"runtime-internal-context-key-is-32bytes"` | 39 bytes |
| `tests/v2r1/test_runtime_wiring.py:25` | `ingress_key` | `b"runtime-external-ingress-key-is-32bytes"` | 39 bytes |
Both values are test fixtures. The supplied evidence reports that no production secret was introduced.
## Test Results Supplied by OpenClaw
### V2R1 suite — 29 of 29 passed
```shell
PYTHONPATH=active-runtime/app python3 -m pytest \
tests/v2r1/test_hermes_fabric.py \
tests/v2r1/test_message_ledger_append.py \
tests/v2r1/test_router_isolation.py \
tests/v2r1/test_runtime_identity_binding.py \
tests/v2r1/test_runtime_wiring.py \
tests/v2r1/test_tenant_context.py -v
# Reported result: 29 passed, exit code 0
```
### Integration suite — 3 of 3 passed
```shell
PYTHONPATH=active-runtime/app python3 -m pytest tests/integration/test_runtime_ingress_flow.py -v
# Reported result: 3 passed, exit code 0
```
**Reported total: 32 of 32 passed.**
## Cleanup, Security, and Recovery Evidence
| Check | Supplied result |
|---|---|
| Conflict-marker scan | PASS |
| Cleanup scan | PASS |
| Secret scan | PASS |
| Legitimate hardcoded test keys | Two 39-byte fixtures in `test_runtime_wiring.py` |
| Rollback rehearsal | PASS; 13 of 13 core tests reported after rollback to `6077331` |
| Recovery verification | PASS |
## Deployment State
| Operation | Performed |
|---|---|
| Merge | No |
| Tag creation | No |
| Baseline promotion | No |
| Staging deployment | No |
| Production deployment | No |
## Post-Commit Verification
Run from the repository root after the commit is created:
```shell
set -eu
SOURCE=502ef30642597fb0b70b90ae2e8a0b6af217d80e
RESULT_FILE=.agent/results/openclaw/RESULT-V2R1-G1-OPENCLAW-FINAL-EVIDENCE-20260714.md
git rev-parse HEAD
test "$(git diff --name-only "$SOURCE" HEAD | wc -l | tr -d ' ')" = "1"
test "$(git diff --name-only "$SOURCE" HEAD)" = "$RESULT_FILE"
git ls-tree -r HEAD -- "$RESULT_FILE"
python3 - "$RESULT_FILE" <<'PY'
from pathlib import Path
import re
import sys
path = Path(sys.argv[1])
text = path.read_text(encoding="utf-8")
encoded_tokens = [
[84, 66, 68],
[80, 69, 78, 68, 73, 78, 71],
[84, 79, 68, 79],
[70, 73, 88, 77, 69],
[91, 123, 99, 111, 109, 109, 105, 116, 125, 93],
]
tokens = ["".join(chr(value) for value in item) for item in encoded_tokens]
angle_token = re.compile(chr(60) + r"[^>\n]+" + chr(62))
hits = [token for token in tokens if token in text]
if angle_token.search(text):
hits.append("angle-delimited-token")
if hits:
print("TOKEN_SCAN=FAIL", hits)
raise SystemExit(1)
print("TOKEN_SCAN=PASS")
PY
```
## Acceptance Conditions
Gate 1 is eligible to proceed only when the external acceptance record confirms all of the following against the resolved `HEAD` commit:
- the commit exists and has source commit `502ef30642597fb0b70b90ae2e8a0b6af217d80e` as its parent;
- exactly this one report file changed;
- the committed report contains no unresolved legacy token;
- the result tree is not identical to rejected commit `71239b4d187e683e05fd22fc9d5df8ae324e3390`;
- no merge, tag, baseline promotion, or deployment occurred.
-4
View File
@@ -11,7 +11,3 @@ runtime/tmp/
runtime/cache/
logs/
config/test_write_perms
/data/webops.sqlite3
/data/webops.sqlite3-wal
/data/webops.sqlite3-shm
/data/webops.sqlite3-journal
+1 -1
View File
@@ -1 +1 @@
V1R4C6
V1R4C3
-3
View File
@@ -1,3 +0,0 @@
"""Canonical AIOS V2R1 runtime package."""
__version__ = "2.1.0-rc.1"
-119
View File
@@ -1,119 +0,0 @@
"""Append-only, tamper-evident audit chain for AIOS V2R1."""
from __future__ import annotations
from collections.abc import Callable
from contextlib import closing
import hashlib
import json
import os
from pathlib import Path
import sqlite3
from typing import Any, TypeVar
from .tenant_context import ConflictError, canonical_json, isoformat, require_ident
T = TypeVar("T")
class AuditLog:
def __init__(self, database: str | Path) -> None:
self.database = Path(database)
self.database.parent.mkdir(parents=True, exist_ok=True)
self._initialize()
os.chmod(self.database, 0o600)
def _connect(self) -> sqlite3.Connection:
db = sqlite3.connect(str(self.database), timeout=30)
db.execute("PRAGMA journal_mode=WAL")
db.execute("PRAGMA synchronous=FULL")
db.execute("PRAGMA busy_timeout=30000")
return db
def _storage(self, operation: Callable[[], T]) -> T:
failure: ConflictError | None = None
try:
return operation()
except sqlite3.Error:
failure = ConflictError("audit storage unavailable")
raise failure
def _initialize(self) -> None:
def initialize() -> None:
with closing(self._connect()) as connection, connection as db:
db.execute(
"""
CREATE TABLE IF NOT EXISTS audit_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id TEXT NOT NULL,
trace_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload TEXT NOT NULL,
previous_hash TEXT NOT NULL,
event_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL
)
"""
)
self._storage(initialize)
def append(
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)
def write() -> str:
with closing(self._connect()) as connection, connection as db:
db.execute("BEGIN IMMEDIATE")
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"],
),
)
db.commit()
return digest
return self._storage(write)
def verify(self) -> bool:
def check() -> bool:
previous = "GENESIS"
with closing(self._connect()) as connection, connection 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:
try:
payload = json.loads(row[3])
except (json.JSONDecodeError, TypeError):
return False
body = {
"tenant_id": row[0], "trace_id": row[1],
"event_type": row[2], "payload": payload,
"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
return self._storage(check)
@@ -1,162 +0,0 @@
"""AIOS V2R1 Tenant Cell lifecycle and atomic registry primitives."""
from __future__ import annotations
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
import fcntl
import json
import os
from pathlib import Path
import tempfile
import threading
from typing import Iterator
from .tenant_context import ValidationError, require_ident
_THREAD_LOCKS_GUARD = threading.Lock()
_THREAD_LOCKS: dict[str, threading.RLock] = {}
_STATES = {"created", "starting", "healthy", "upgrading", "rollback", "failed"}
_TRANSITIONS = {
"created": {"starting", "failed"},
"starting": {"healthy", "failed"},
"healthy": {"upgrading", "failed"},
"upgrading": {"healthy", "rollback", "failed"},
"rollback": {"healthy", "failed"},
"failed": {"starting"},
}
def _lock_for(path: Path) -> threading.RLock:
key = str(path.resolve())
with _THREAD_LOCKS_GUARD:
return _THREAD_LOCKS.setdefault(key, threading.RLock())
@dataclass(frozen=True)
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)
self._lock_file = self.registry.with_name(self.registry.name + ".lock")
self._thread_lock = _lock_for(self.registry)
with self._exclusive():
if not self.registry.exists():
self._save({"cells": {}})
else:
self._load()
@contextmanager
def _exclusive(self) -> Iterator[None]:
with self._thread_lock:
with self._lock_file.open("a+", encoding="utf-8") as lock:
os.chmod(self._lock_file, 0o600)
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
def _load(self) -> dict[str, dict[str, dict[str, str]]]:
failure: ValidationError | None = None
try:
value = json.loads(self.registry.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
failure = ValidationError("cell registry unavailable")
if failure is not None:
raise failure
if not isinstance(value, dict) or set(value) != {"cells"}:
raise ValidationError("cell registry unavailable")
cells = value["cells"]
if not isinstance(cells, dict):
raise ValidationError("cell registry unavailable")
expected = {
"tenant_id", "cell_id", "version", "state",
"config_digest", "created_at",
}
for tenant_id, item in cells.items():
if (
not isinstance(item, dict) or set(item) != expected
or item.get("tenant_id") != tenant_id
or item.get("state") not in _STATES
or any(not isinstance(item.get(name), str) for name in expected)
):
raise ValidationError("cell registry unavailable")
return value
def _save(self, value: dict[str, object]) -> None:
descriptor, temporary_name = tempfile.mkstemp(
prefix=f".{self.registry.name}.", dir=self.registry.parent, text=True
)
temporary = Path(temporary_name)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as output:
json.dump(value, output, ensure_ascii=False, indent=2)
output.write("\n")
output.flush()
os.fsync(output.fileno())
os.chmod(temporary, 0o600)
os.replace(temporary, self.registry)
directory = os.open(self.registry.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
if temporary.exists():
temporary.unlink()
def create(
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)
version = require_ident("version", version)
digest = require_ident("digest", digest)
with self._exclusive():
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:
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
with self._exclusive():
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:
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
if state not in _STATES:
raise ValidationError("invalid cell state")
with self._exclusive():
data = self._load()
item = data["cells"].get(tenant_id)
if not item:
raise ValidationError("unknown tenant")
current = item["state"]
if state == current:
return CellRecord(**item)
if state not in _TRANSITIONS[current]:
raise ValidationError(f"invalid cell transition: {current}->{state}")
item["state"] = state
self._save(data)
return CellRecord(**item)
@@ -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)
-81
View File
@@ -1,81 +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
from .ingress_service import serve
from .tenant_context import V2R1Error, ValidationError
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 build_serve_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="aios-cell serve")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=18792)
return parser
def _domain_failure(error: V2R1Error) -> int:
code = "VALIDATION_ERROR" if isinstance(error, ValidationError) else "RUNTIME_ERROR"
print(
json.dumps({"code": code, "error": str(error)}, ensure_ascii=False, sort_keys=True),
file=sys.stderr,
)
return 3
def main(argv: list[str] | None = None) -> int:
values = list(sys.argv[1:] if argv is None else argv)
service_mode = (
bool(values) and values[0] == "serve"
and (len(values) == 1 or values[1].startswith("-"))
)
if service_mode:
serve_args = build_serve_parser().parse_args(values[1:])
serve(serve_args.host, serve_args.port)
return 0
args = build_parser().parse_args(values)
try:
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
except V2R1Error as error:
return _domain_failure(error)
print(json.dumps(result.__dict__, ensure_ascii=False, sort_keys=True))
return 0
if __name__ == "__main__":
sys.exit(main())
-106
View File
@@ -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,164 +0,0 @@
"""Tenant-partitioned Hermes shared agent fabric primitives."""
from __future__ import annotations
from collections.abc import Callable
from contextlib import closing
from dataclasses import dataclass
from datetime import datetime, timezone
import os
from pathlib import Path
import sqlite3
from typing import Any, TypeVar
from .tenant_context import (
CanonicalMessage, ConflictError, ScopeError, ValidationError,
isoformat, require_ident,
)
T = TypeVar("T")
@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)
Path(self.database).parent.mkdir(parents=True, exist_ok=True)
self._initialize()
os.chmod(self.database, 0o600)
def _connect(self) -> sqlite3.Connection:
db = sqlite3.connect(self.database, timeout=30)
db.execute("PRAGMA journal_mode=WAL")
db.execute("PRAGMA synchronous=FULL")
db.execute("PRAGMA busy_timeout=30000")
return db
def _storage(self, operation: Callable[[], T]) -> T:
failure: ConflictError | None = None
try:
return operation()
except sqlite3.Error:
failure = ConflictError("fabric storage unavailable")
raise failure
def _initialize(self) -> None:
def initialize() -> None:
with closing(self._connect()) as connection, connection as db:
db.execute(
"""
CREATE TABLE IF NOT EXISTS fabric_jobs (
job_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
cell_id TEXT NOT NULL,
profile_id TEXT NOT NULL,
session_id TEXT NOT NULL,
event_id TEXT NOT NULL UNIQUE,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
db.execute(
"CREATE INDEX IF NOT EXISTS idx_fabric_tenant_queue "
"ON fabric_jobs(tenant_id, cell_id, status)"
)
self._storage(initialize)
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()
failure: ConflictError | None = None
try:
with closing(self._connect()) as connection, connection as db:
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:
failure = ConflictError(
"fabric event already exists or violates queue uniqueness"
)
except sqlite3.Error:
failure = ConflictError("fabric storage unavailable")
if failure is not None:
raise failure
return job
def claim(
self, tenant_id: str, cell_id: str, profile_id: str,
) -> FabricJob | None:
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
cell_id = require_ident("cell_id", cell_id)
profile_id = require_ident("profile_id", profile_id)
def claim_one() -> FabricJob | None:
with closing(self._connect()) 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
updated = db.execute(
"UPDATE fabric_jobs SET status='running', updated_at=? "
"WHERE job_id=? AND status='queued'",
(isoformat(), row[0]),
)
if updated.rowcount != 1:
db.rollback()
return None
db.commit()
return FabricJob(
job_id=row[0], tenant_id=row[1], cell_id=row[2],
profile_id=row[3], session_id=row[4], event_id=row[5],
status="running",
)
return self._storage(claim_one)
def health(self) -> dict[str, Any]:
def query() -> int:
with closing(self._connect()) as connection, connection as db:
return int(db.execute("SELECT count(*) FROM fabric_jobs").fetchone()[0])
return {
"component": "hermes_fabric", "jobs": self._storage(query),
"checked_at": datetime.now(timezone.utc).isoformat(),
}
@@ -1,67 +0,0 @@
"""Hermes shared-fabric profile isolation primitives."""
from __future__ import annotations
from dataclasses import dataclass
import os
from pathlib import Path
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 isinstance(self.hermes_home, Path)
or not isinstance(self.workspace, Path)
or not self.hermes_home.is_absolute()
or not self.workspace.is_absolute()
):
raise ValidationError("profile paths must be absolute")
@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,
}
@staticmethod
def _reject_symlink_components(root: Path) -> None:
current = Path(root.anchor)
for part in root.parts[1:]:
current = current / part
if current.is_symlink():
raise ValidationError("profile root contains symlink")
def _validate_roots(self) -> None:
for root in (self.hermes_home, self.workspace):
self._reject_symlink_components(root)
def prepare(self) -> None:
self._validate_roots()
for root in (self.hermes_home, self.workspace):
root.mkdir(parents=True, exist_ok=True)
self._validate_roots()
for root in (self.hermes_home, self.workspace):
os.chmod(root, 0o700)
def assert_path_allowed(self, path: str | Path) -> None:
self._validate_roots()
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")
-158
View File
@@ -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,512 +0,0 @@
"""Fail-closed loopback HTTP shell for V2R1 ingress processors."""
from __future__ import annotations
import ipaddress
import json
import socket
import threading
import time
from collections.abc import Callable, Mapping
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import urlsplit
MAX_REQUEST_BYTES = 1024 * 1024
MAX_RESPONSE_BYTES = 4096
REQUEST_IO_TIMEOUT_SECONDS = 0.5
REQUEST_NETWORK_DEADLINE_SECONDS = 1.0
HEX_DIGITS = frozenset("0123456789abcdefABCDEF")
HEADER_NAME_BYTES = frozenset(
b"!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
)
INTERNAL_ERROR_BODY = b'{"status":"INTERNAL_ERROR"}'
MUTATION_ROUTES = frozenset(
{"/v1/ingress", "/v1/receipts/status", "/v1/outbound-receipts"}
)
Processor = Callable[[str, bytes], tuple[int, Mapping[str, Any]]]
class RequestRejected(Exception):
def __init__(self, status: int, payload_status: str) -> None:
super().__init__(payload_status)
self.status = status
self.payload_status = payload_status
class IngressHTTPServer(ThreadingHTTPServer):
"""Threaded loopback server with an optional externally supplied processor."""
daemon_threads = False
block_on_close = True
def __init__(
self,
address: tuple[str, int],
handler: type[BaseHTTPRequestHandler],
processor: Processor | None,
) -> None:
self._closing = threading.Event()
self._lifecycle_lock = threading.RLock()
self._connections_lock = threading.Lock()
self._connections: set[socket.socket] = set()
self._server_close_lock = threading.Lock()
self.processor = processor
super().__init__(address, handler)
@property
def is_closing(self) -> bool:
return self._closing.is_set()
def get_request(self) -> tuple[socket.socket, tuple[str, int]]:
"""Register each accepted socket before handler scheduling can begin."""
connection, client_address = super().get_request()
with self._lifecycle_lock:
if self._closing.is_set():
reject = True
else:
with self._connections_lock:
self._connections.add(connection)
reject = False
if reject:
self._shutdown_and_close_connection(connection)
raise OSError("server is closing")
return connection, client_address
def unregister_connection(self, connection: socket.socket) -> None:
with self._connections_lock:
self._connections.discard(connection)
def _shutdown_and_close_connection(self, connection: socket.socket) -> None:
try:
connection.shutdown(socket.SHUT_RDWR)
except OSError:
pass
try:
connection.close()
except OSError:
pass
finally:
self.unregister_connection(connection)
def _close_accepted_connections(self) -> None:
with self._connections_lock:
connections = tuple(self._connections)
for connection in connections:
self._shutdown_and_close_connection(connection)
def process_request(
self,
request: socket.socket,
client_address: tuple[str, int],
) -> None:
"""Linearize request admission with the transition to closing."""
with self._lifecycle_lock:
if self._closing.is_set():
reject = True
else:
reject = False
try:
super().process_request(request, client_address)
except Exception:
self._shutdown_and_close_connection(request)
raise
if reject:
self._shutdown_and_close_connection(request)
def shutdown_request(self, request: socket.socket) -> None:
try:
request.shutdown(socket.SHUT_RDWR)
except OSError:
pass
try:
super().shutdown_request(request)
finally:
self.unregister_connection(request)
def call_processor(self, path: str, body: bytes) -> tuple[bool, Any]:
"""Start processing atomically with respect to server shutdown."""
with self._lifecycle_lock:
if self._closing.is_set() or self.processor is None:
return False, None
return True, self.processor(path, body)
def _begin_closing(self) -> None:
with self._lifecycle_lock:
self._closing.set()
def shutdown(self) -> None:
self._begin_closing()
self._close_accepted_connections()
super().shutdown()
def server_close(self) -> None:
"""Interrupt accepted sockets before the base class joins handlers."""
with self._server_close_lock:
self._begin_closing()
self._close_accepted_connections()
super().server_close()
def handle_error(
self,
request: socket.socket,
client_address: tuple[str, int],
) -> None:
"""Close unexpected handler failures without printing request data."""
try:
request.shutdown(socket.SHUT_RDWR)
except OSError:
pass
class _ValidatingHeaderReader:
"""Validate raw header lines before the standard library normalizes them."""
def __init__(self, handler: "IngressRequestHandler", reader: Any) -> None:
self._handler = handler
self._reader = reader
def readline(self, limit: int = -1) -> bytes:
try:
line = self._reader.readline(limit)
except TimeoutError as exc:
raise RequestRejected(408, "REQUEST_TIMEOUT") from exc
self._handler._ensure_network_read_active()
self._validate(line)
return line
@staticmethod
def _validate(line: bytes) -> None:
if line == b"\r\n":
return
if not line or not line.endswith(b"\r\n"):
raise RequestRejected(400, "BAD_REQUEST")
raw = line[:-2]
if not raw or raw[:1] in {b" ", b"\t"}:
raise RequestRejected(400, "BAD_REQUEST")
separator = raw.find(b":")
if separator <= 0:
raise RequestRejected(400, "BAD_REQUEST")
name = raw[:separator]
value = raw[separator + 1 :]
if any(byte not in HEADER_NAME_BYTES for byte in name):
raise RequestRejected(400, "BAD_REQUEST")
if any((byte < 0x20 and byte != 0x09) or byte == 0x7F for byte in value):
raise RequestRejected(400, "BAD_REQUEST")
if name.lower() == b"content-length":
numeric = value[1:] if value.startswith(b" ") else value
if not numeric or any(byte < 0x30 or byte > 0x39 for byte in numeric):
raise RequestRejected(400, "BAD_REQUEST")
class IngressRequestHandler(BaseHTTPRequestHandler):
"""Bounded JSON transport; application processing is injected later."""
server: IngressHTTPServer
def setup(self) -> None:
super().setup()
self.connection.settimeout(REQUEST_IO_TIMEOUT_SECONDS)
self._network_read_lock = threading.Lock()
self._network_read_expired = threading.Event()
self._network_read_deadline: float | None = None
self._network_read_timer: threading.Timer | None = None
self._network_read_active = False
def finish(self) -> None:
self._complete_network_read()
try:
super().finish()
except OSError:
pass
def _start_network_read(self) -> None:
self._complete_network_read()
timer = threading.Timer(
REQUEST_NETWORK_DEADLINE_SECONDS,
self._expire_network_read,
)
timer.name = f"aios-ingress-read-deadline-{id(self)}"
timer.daemon = True
with self._network_read_lock:
self._network_read_expired.clear()
self._network_read_deadline = (
time.monotonic() + REQUEST_NETWORK_DEADLINE_SECONDS
)
self._network_read_active = True
self._network_read_timer = timer
timer.start()
def _expire_network_read(self) -> None:
with self._network_read_lock:
if not self._network_read_active:
return
self._network_read_expired.set()
self.close_connection = True
try:
self.connection.shutdown(socket.SHUT_RD)
except OSError:
pass
def _complete_network_read(self) -> None:
timer: threading.Timer | None
with getattr(self, "_network_read_lock", threading.Lock()):
self._network_read_active = False
self._network_read_deadline = None
timer = self._network_read_timer
self._network_read_timer = None
if timer is not None:
timer.cancel()
if timer is not threading.current_thread():
timer.join(timeout=REQUEST_IO_TIMEOUT_SECONDS)
def _ensure_network_read_active(self) -> None:
if self.server.is_closing:
raise RequestRejected(503, "SERVICE_UNAVAILABLE")
deadline = self._network_read_deadline
if self._network_read_expired.is_set() or (
deadline is not None and time.monotonic() >= deadline
):
self._network_read_expired.set()
raise RequestRejected(408, "REQUEST_TIMEOUT")
def parse_request(self) -> bool:
original_reader = self.rfile
self.rfile = _ValidatingHeaderReader(self, original_reader) # type: ignore[assignment]
try:
return super().parse_request()
finally:
self.rfile = original_reader
def handle_one_request(self) -> None:
self._start_network_read()
try:
super().handle_one_request()
except RequestRejected as rejection:
self.close_connection = True
self._send_safely(rejection.status, {"status": rejection.payload_status})
except TimeoutError:
self.close_connection = True
self._send_safely(408, {"status": "REQUEST_TIMEOUT"})
except (BrokenPipeError, ConnectionResetError, OSError):
self.close_connection = True
except Exception:
self.close_connection = True
self._send_safely(500, {"status": "INTERNAL_ERROR"})
finally:
self._complete_network_read()
def send_error(
self,
code: int,
message: str | None = None,
explain: str | None = None,
) -> None:
"""Replace BaseHTTPRequestHandler HTML errors with bounded JSON."""
self.close_connection = True
if code == 501:
self._method_not_allowed()
return
self._send(int(code), {"status": "HTTP_ERROR"})
def log_message(self, format: str, *args: object) -> None:
return
def _send_safely(self, status: int, payload: Mapping[str, Any]) -> None:
try:
self._send(status, payload)
except OSError:
self.close_connection = True
def _path(self) -> str:
target = self.path
if not target or any(
ord(character) < 0x20 or ord(character) == 0x7F for character in target
):
raise RequestRejected(400, "BAD_REQUEST")
for index, character in enumerate(target):
if character == "%" and (
index + 2 >= len(target)
or any(value not in HEX_DIGITS for value in target[index + 1 : index + 3])
):
raise RequestRejected(400, "BAD_REQUEST")
try:
parts = urlsplit(target)
except ValueError as exc:
raise RequestRejected(400, "BAD_REQUEST") from exc
if parts.fragment:
raise RequestRejected(400, "BAD_REQUEST")
if target.startswith("/"):
if parts.scheme or parts.netloc:
raise RequestRejected(400, "BAD_REQUEST")
elif parts.scheme in {"http", "https"} and parts.netloc:
if parts.hostname is None:
raise RequestRejected(400, "BAD_REQUEST")
try:
_ = parts.port
except ValueError as exc:
raise RequestRejected(400, "BAD_REQUEST") from exc
else:
raise RequestRejected(400, "BAD_REQUEST")
return parts.path or "/"
@staticmethod
def _encoded_response(
status: object,
payload: object,
) -> tuple[int, bytes]:
if (
type(status) is not int
or not 200 <= status <= 599
or status in {204, 205, 304}
or not isinstance(payload, Mapping)
):
return 500, INTERNAL_ERROR_BODY
try:
body = json.dumps(
dict(payload),
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
allow_nan=False,
).encode("utf-8")
except Exception:
return 500, INTERNAL_ERROR_BODY
if len(body) > MAX_RESPONSE_BYTES:
return 500, INTERNAL_ERROR_BODY
return status, body
def _send(self, status: int, payload: Mapping[str, Any]) -> None:
self._complete_network_read()
status, body = self._encoded_response(status, payload)
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
if getattr(self, "command", None) != "HEAD":
self.wfile.write(body)
def _method_not_allowed(self) -> None:
self._send(405, {"status": "METHOD_NOT_ALLOWED"})
def _reject(self, status: int, payload_status: str) -> None:
self.close_connection = True
self._send(status, {"status": payload_status})
def _content_length(self) -> int:
if self.headers.get_all("Transfer-Encoding", []):
raise RequestRejected(400, "BAD_REQUEST")
values = self.headers.get_all("Content-Length", [])
if len(values) > 1:
raise RequestRejected(400, "BAD_REQUEST")
if not values:
return 0
raw_length = values[0]
if (
not raw_length
or not raw_length.isascii()
or not raw_length.isdigit()
):
raise RequestRejected(400, "BAD_REQUEST")
try:
length = int(raw_length)
except ValueError as exc:
raise RequestRejected(400, "BAD_REQUEST") from exc
if length > MAX_REQUEST_BYTES:
raise RequestRejected(413, "REQUEST_TOO_LARGE")
return length
def _read_exact_body(self, length: int) -> bytes:
try:
body = self.rfile.read(length)
except TimeoutError as exc:
raise RequestRejected(408, "REQUEST_TIMEOUT") from exc
self._ensure_network_read_active()
if len(body) != length:
raise RequestRejected(400, "BAD_REQUEST")
return body
def do_GET(self) -> None:
path = self._path()
if path == "/health/live":
self._send(200, {"status": "LIVE"})
elif path == "/health/ready":
if self.server.processor is None:
self._send(503, {"status": "SERVICE_NOT_CONFIGURED"})
else:
self._send(200, {"status": "READY"})
elif path in MUTATION_ROUTES:
self._method_not_allowed()
else:
self._send(404, {"status": "NOT_FOUND"})
def do_POST(self) -> None:
path = self._path()
if path not in MUTATION_ROUTES:
if path in {"/health/live", "/health/ready"}:
self._method_not_allowed()
else:
self._send(404, {"status": "NOT_FOUND"})
return
try:
length = self._content_length()
except RequestRejected as rejection:
self._reject(rejection.status, rejection.payload_status)
return
processor = self.server.processor
if processor is None:
self._send(503, {"status": "SERVICE_NOT_CONFIGURED"})
return
if self.server.is_closing:
self._reject(503, "SERVICE_UNAVAILABLE")
return
try:
body = self._read_exact_body(length)
except RequestRejected as rejection:
self._reject(rejection.status, rejection.payload_status)
return
self._complete_network_read()
try:
started, result = self.server.call_processor(path, body)
if not started:
self._reject(503, "SERVICE_UNAVAILABLE")
return
status, payload = result
except Exception:
self._send(500, {"status": "INTERNAL_ERROR"})
return
self._send(status, payload)
do_DELETE = _method_not_allowed
do_HEAD = _method_not_allowed
do_OPTIONS = _method_not_allowed
do_PATCH = _method_not_allowed
do_PUT = _method_not_allowed
def build_server(
host: str = "127.0.0.1",
port: int = 18792,
*,
processor: Processor | None = None,
) -> IngressHTTPServer:
"""Build a loopback-only HTTP server without starting it."""
try:
address = ipaddress.ip_address(host)
except ValueError as exc:
raise ValueError("host must be a loopback IP address") from exc
if address.version != 4 or not address.is_loopback:
raise ValueError("host must be an IPv4 loopback address")
return IngressHTTPServer((host, port), IngressRequestHandler, processor)
def serve(host: str = "127.0.0.1", port: int = 18792) -> None:
"""Run the unconfigured loopback shell until interrupted."""
server = build_server(host, port)
try:
server.serve_forever()
except KeyboardInterrupt:
return
finally:
server.server_close()
@@ -1,63 +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.kind != requested.kind:
raise ScopeError("cross-kind memory access denied")
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:
normalized = require_ident("memory_key", key)
if normalized != key:
raise ValidationError("memory_key must be canonical")
return f"{scope.namespace}/{normalized}"
@@ -1,227 +0,0 @@
"""Tenant-scoped append-only message ledger and exact quote resolver."""
from __future__ import annotations
from collections.abc import Callable
from contextlib import closing
from dataclasses import dataclass
from datetime import timedelta
import os
from pathlib import Path
import sqlite3
from typing import TypeVar
from .tenant_context import (
CanonicalMessage, ConflictError, ScopeError, ValidationError,
isoformat, normalize_message_id, utc_now,
)
T = TypeVar("T")
@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 with no fuzzy, prefix, or time-nearest fallback."""
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 _storage(self, operation: Callable[[], T]) -> T:
failure: ConflictError | None = None
try:
return operation()
except sqlite3.Error:
failure = ConflictError("ledger storage unavailable")
# Raise after the driver exception handler, keeping SQLite details out of
# both the public exception and its implicit context.
raise failure
def _initialize(self) -> None:
def initialize() -> 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
);
"""
)
self._storage(initialize)
def append(self, message: CanonicalMessage) -> LedgerRecord:
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,
)
def write() -> LedgerRecord:
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()
return LedgerRecord(
*key, message.event_id, message.body, message.occurred_at, expires
)
return self._storage(write)
def remember(self, message: CanonicalMessage) -> LedgerRecord:
"""Backward-compatible API wrapper outside the runtime write path."""
return self.append(message)
def matches(self, message: CanonicalMessage) -> bool:
message_id = normalize_message_id(message.message_id)
def query() -> bool:
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
)
return self._storage(query)
def remove(self, record: LedgerRecord) -> None:
def delete() -> None:
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,
),
)
self._storage(delete)
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"
)
def query() -> list[sqlite3.Row]:
with closing(self._connect()) as connection, connection as db:
return 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()
rows = self._storage(query)
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:
def purge() -> int:
with closing(self._connect()) as connection, connection as db:
cursor = db.execute(
"DELETE FROM message_ledger WHERE expires_at<=?", (isoformat(),)
)
return cursor.rowcount
return self._storage(purge)
def integrity_check(self) -> str:
def check() -> str:
with closing(self._connect()) as connection, connection as db:
return str(db.execute("PRAGMA integrity_check").fetchone()[0])
return self._storage(check)
def count(self) -> int:
def query() -> int:
with closing(self._connect()) as connection, connection as db:
return int(db.execute("SELECT count(*) FROM message_ledger").fetchone()[0])
return self._storage(query)
-336
View File
@@ -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
-136
View File
@@ -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,61 +0,0 @@
"""Tenant and Cell scoped, value-free secret reference model."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Literal
from .tenant_context import ScopeError, 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"):
value = getattr(self, name)
if require_ident(name, value) != value:
raise ValidationError(f"{name} must be canonical")
if require_ident("tenant_id", self.tenant_id, tenant=True) != self.tenant_id:
raise ValidationError("tenant_id must be canonical")
if self.kind not in {"channel", "model", "tool", "runtime"}:
raise ValidationError("invalid secret kind")
self._validate_broker_path()
def _validate_broker_path(self) -> None:
if not isinstance(self.broker_path, str) or not self.broker_path.startswith("secret://"):
raise ValidationError("secret broker path required")
suffix = self.broker_path[len("secret://"):]
if (
not suffix or "?" in suffix or "#" in suffix or chr(92) in suffix
or any(part in {"", ".", ".."} for part in suffix.split("/"))
):
raise ValidationError("invalid secret broker path")
for part in suffix.split("/"):
if require_ident("secret broker path segment", part) != part:
raise ValidationError("invalid secret broker path")
def can_be_used_by(self, *, tenant_id: str, cell_id: str) -> bool:
return self.tenant_id == tenant_id and self.cell_id == cell_id
def resolve(self, broker: Any) -> str:
"""Resolve through one explicit broker boundary without leaking provider errors."""
failure: ScopeError | None = None
value: object = None
try:
reader = getattr(broker, "read_secret")
if not callable(reader):
raise TypeError("invalid broker")
value = reader(self)
if not isinstance(value, str) or not value:
raise ValueError("invalid broker result")
except Exception:
failure = ScopeError("secret broker unavailable")
if failure is not None:
raise failure
return value
@@ -1,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",
)
-17
View File
@@ -1,17 +0,0 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "aios-v2r1-runtime"
version = "2.1.0rc1"
description = "AIOS V2R1 canonical runtime package"
requires-python = ">=3.11"
dependencies = []
[project.scripts]
aios-cell = "aios_v2r1.cli:main"
[tool.setuptools]
package-dir = {"" = "app"}
packages = ["aios_v2r1"]
+29 -18
View File
@@ -1,7 +1,8 @@
<<<<<<< HEAD
# AIOS Agent Command Plane Registry
# Git is the single source of truth for all agent commands.
version: 1.0.0
created_at: "2026-07-08T15:20:00+08:00"
created_at: 2026-07-08T15:20:00+08:00
baseline: V1R3C1
commands:
@@ -11,22 +12,6 @@ commands:
completed_path: "agents/commands/completed/"
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:
path: "agents/policies/"
source: "git"
@@ -53,4 +38,30 @@ audit:
agent_runs: "audit/agent-runs/"
version_history: "audit/version-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)
+108 -26
View File
@@ -7,7 +7,7 @@ Pipeline: Detect -> Diagnose -> Fix Plan -> Patch Generate -> Owner Approval ->
Complete autonomous cycle with notification integration.
"""
import json, os, hashlib, time, sys, logging, subprocess, requests
import json, os, hashlib, time, sys, logging, subprocess, requests, sqlite3
from pathlib import Path
WORKSPACE = Path(os.environ.get("AIOS_WORKSPACE", "/data/aios-gitops/workspace"))
@@ -18,6 +18,9 @@ REJECTED_DIR = WORKSPACE / "agents" / "patches" / "rejected/"
AUDIT_DIR = WORKSPACE / "audit" / "controller-runs/"
STATE_FILE = WORKSPACE / "agents" / "state" / "controller-state.json"
HEALTH_FILE = WORKSPACE / "agents" / "state" / "health.json"
BARE_REPO_PATH = Path("/data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios-v23.git")
GITEA_DB_PATH = Path("/data/aios-gitops/runtime/gitea-bin/data/gitea.db")
REPO_ID = 1
NOTIF_DIR = WORKSPACE / "agents" / "notifications/"
ACTIVE_DIR = WORKSPACE / "agents" / "commands" / "active/"
RUNNING_DIR = WORKSPACE / "agents" / "commands" / "running/"
@@ -76,22 +79,49 @@ def h_api():
except Exception as e: return {"ok": False, "err": str(e)}
def h_branch_sync():
bare = Path("/data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios-v23.git")
"""Compare bare repo refs with Gitea DB live branches using git ls-remote (no Gitea API)."""
bare = BARE_REPO_PATH
db_path = GITEA_DB_PATH
if not bare.exists():
return {"ok": False, "err": f"bare repo path not found: {bare}"}
if not db_path.exists():
return {"ok": False, "err": f"gitea db path not found: {db_path}"}
try:
r = subprocess.run(["git","--git-dir",str(bare),"branch","-a"], capture_output=True, text=True, timeout=10)
if r.returncode != 0: return {"ok": False, "err": r.stderr[:200]}
bb = set(l.strip().replace("remotes/origin/","").replace("* ","") for l in r.stdout.strip().split("\n") if l.strip())
gb = []; pg = 1
while True:
r2 = requests.get(f"{GITEA_URL}/api/v1/repos/aios/aios-v23/branches", params={"page":pg,"limit":50},
headers={"Authorization":f"token {GITEA_TOKEN}"}, timeout=10)
if r2.status_code != 200: break
d = r2.json()
if not d: break
gb.extend(b["name"] for b in d); pg += 1
gs = set(gb); miss = bb - gs
return {"ok": len(miss)==0, "bare": len(bb), "gitea": len(gs), "missing": list(miss)[:10]}
except Exception as e: return {"ok": False, "err": str(e)}
# Use git ls-remote which reads directly from the bare repo with no API dependency
r = subprocess.run(
["git", "--git-dir", str(bare), "ls-remote", "--refs", str(bare), "refs/heads/*"],
capture_output=True, text=True, timeout=10
)
if r.returncode != 0:
return {"ok": False, "err": f"git ls-remote failed: {r.stderr[:200]}"}
bare_names = set()
for line in r.stdout.strip().split("\n"):
if not line.strip():
continue
parts = line.strip().split("\t")
if len(parts) == 2:
ref = parts[1]
if ref.startswith("refs/heads/"):
bare_names.add(ref[len("refs/heads/"):])
# Read live branches directly from SQLite (no Gitea API pagination)
import urllib.parse
uri = f"file:{urllib.parse.quote(str(db_path.resolve()))}?mode=ro"
conn = sqlite3.connect(uri, uri=True, timeout=5)
try:
cursor = conn.execute(
"SELECT name FROM branch WHERE repo_id = ? AND is_deleted = 0 ORDER BY name",
(REPO_ID,),
)
db_names = {row[0] for row in cursor.fetchall()}
finally:
conn.close()
miss = bare_names - db_names
return {"ok": len(miss) == 0, "bare": len(bare_names), "gitea": len(db_names), "missing": list(miss)[:10]}
except Exception as e:
return {"ok": False, "err": str(e)}
def h_stuck():
s = []
@@ -123,11 +153,62 @@ def diagnose(results):
"stuck": s[:3], "fix": "move_to_failed"})
return plans
# === Safe Mode Gate ===
SAFE_MODE_FILE = Path("/data/aios-gitops/runtime/state/autonomous-safe-mode.json")
SENTINEL_REPORT_FILE = Path("/data/aios-gitops/runtime/state/gitea-branch-consistency-report.json")
def check_safe_mode():
"""Return True if safe mode is active (no mutations allowed)."""
if not SAFE_MODE_FILE.exists():
return False
try:
marker = json.loads(SAFE_MODE_FILE.read_text())
if marker.get("status") == "active":
log.warning(f"Safe mode active since {marker.get('entered_at','?')}: {marker.get('reason','?')}")
return True
except Exception as e:
log.warning(f"Failed to read safe mode marker: {e}")
return False
def check_sentinel_drift():
"""Read the consistency sentinel report and log any drift. Returns True if recent report shows drift."""
if not SENTINEL_REPORT_FILE.exists():
return False
try:
report = json.loads(SENTINEL_REPORT_FILE.read_text())
drift_counts = report.get("drift_counts", {})
total_drift = sum(drift_counts.values())
if total_drift > 0:
log.warning(f"Consistency sentinel reports {total_drift} drifts (report: {SENTINEL_REPORT_FILE})")
return True
return False
except Exception as e:
log.warning(f"Failed to read sentinel report: {e}")
return False
# === Apply Patches ===
def apply_approved_patches():
applied = []
if not APPROVED_DIR.exists(): return applied
if not APPROVED_DIR.exists():
return applied
# Gate: if safe mode is active, refuse all mutations
if check_safe_mode():
log.error("Safe mode is ACTIVE — refusing to apply patches until cleared")
notify("Safe Mode Active — Patches Blocked",
"Gitea branch consistency safe mode is active. Refusing to apply approved patches.",
"critical")
return applied
# Gate: if sentinel report shows recent drift, log warning
if check_sentinel_drift():
log.warning("Sentinel drift detected — proceeding with current approved patches")
for f in sorted(APPROVED_DIR.iterdir()):
if not f.name.endswith(".json"): continue
try:
@@ -183,7 +264,7 @@ def run_cycle():
# Diagnose and create patches
plans = diagnose(results)
for pl in plans:
pending = create_patch(pl)
pending = create_patch(pl['check'], pl.get('diagnosis', 'No diagnosis'))
if pending:
notify(f"⚠️ Issue Detected: {pl['check']}", pl['diagnosis'], "warning")
@@ -203,16 +284,17 @@ def run_cycle():
return results, plans, applied
def create_patch(pl):
patch = {"patch_id": f"patch-{int(time.time())}", "created": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"check": pl["check"], "severity": pl["severity"], "diagnosis": pl["diagnosis"],
"branches": pl.get("branches",[]), "stuck": pl.get("stuck",[]), "fix": pl.get("fix","")}
def create_patch(title, content):
"""Create a patch file in PENDING_DIR. Returns dict with ok/id/title/content."""
pid = f"patch-{int(time.time())}-{hashlib.md5(title.encode()).hexdigest()[:8]}"
patch = {"title": title, "content": content, "id": pid, "created": time.strftime("%Y-%m-%dT%H:%M:%S%z")}
PENDING_DIR.mkdir(parents=True, exist_ok=True)
f = PENDING_DIR / f"{patch['patch_id']}.json"
if f.exists(): return False
f = PENDING_DIR / f"{pid}.json"
if f.exists():
return {"ok": False, "err": "already exists", "id": pid, "title": title, "content": content}
f.write_text(json.dumps(patch, indent=2, ensure_ascii=False))
log.info(f"Patch created: {patch['patch_id']}")
return True
log.info(f"Patch created: {pid}")
return {"ok": True, "id": pid, "title": title, "content": content}
def run_forever():
+7 -12
View File
@@ -1,13 +1,8 @@
{
"current_baseline": "V1R4C6",
"previous_baseline": "V1R4C5",
"promoted_at": "2026-07-11T21:00:00+08:00",
"git": "e204a90398bf646d46245b3088226749fa056edd",
"promoted_by": "openclaw/admin",
"promotion_reason": "V1R4C6 admin search agent integration, search gateway, agent platform, VERSION bump, merge tests deploy pipeline — not yet deployed to production",
"constraints": {
"memory_search_enabled": false,
"force_push_allowed": false,
"gitea_db_mutation_requires_owner_approval": true
}
}
"current_baseline": "V1R4C3",
"previous_baseline": "V1R3C1",
"promoted_at": "2026-07-09T17:44:57+08:00",
"git": "f5435e308794c737f3e39de3f6b79807486943c3",
"promoted_by": "openclaw",
"promotion_reason": "V1R4C3 autonomous operations, search gateway, task center, owner gate, and blocker self-optimization baseline promotion"
}
-39
View File
@@ -1,39 +0,0 @@
# Personal Weixin v1 security contract
Status: normative design contract for R10 B6/B7. It authorizes no runtime or production change.
## Trust boundaries and ownership
The browser is untrusted. The Portal authenticates the human user and resolves tenant identity from the server session. The Portal adapter is least-privileged: it may request account-scoped operations but must not store provider credentials, tokens, raw iLink state, or OpenClaw protocol state. OpenClaw owns provider credentials and protocol state. The Gateway transport authenticator, not an event-body boolean or identity field, establishes whether an inbound event is authentic.
Every operation is keyed by server-resolved `(tenantId, accountId)`. Entitlements are allow-only, server-owned records. A default account is display preference, never authorization. The browser-visible `accountId` is an untrusted reference to a server-preallocated account slot: the server MUST resolve it through the authenticated user's tenant and current entitlement on list, readiness, login start, login wait, status, inbound challenge, canary, and rollback checks. Start and every wait poll re-run that resolution. Missing, arbitrary, stale, cross-tenant, or no-longer-entitled values fail closed before account existence or state is disclosed; there is no default/first/configured-account fallback.
## Login state and secret handling
`sessionKey` is a required, nonempty opaque value returned by OpenClaw. The adapter MUST forward its exact bytes unchanged. It MUST NOT trim, normalize, case-fold, derive, regenerate, alias, or substitute the value. A missing or empty `sessionKey` fails closed and MUST NOT fall back to `accountId` or any identifier. Durable state stores only a one-way session-key digest where correlation is required; raw `sessionKey` is not persisted.
`verifyCode` is accepted only as an authenticated, CSRF-protected wait POST test/input field, matches 4-8 decimal digits, is forwarded once, and is discarded. `challengeCode` is accepted only as an authenticated Gateway event test/input field, is compared through a one-way digest, and is discarded. Neither secret may be persisted in raw form or appear in URLs, responses, logs, traces, metrics labels, analytics, audit payloads, exceptions, or background-job arguments. Golden fixtures containing these values are synthetic test vectors only and MUST NOT be copied into production telemetry or responses.
QR data URLs are transient no-store response data with a 1 MiB bound and MUST NOT be logged or persisted. Canonical login outcomes are `qr_pending`, `need_verifycode`, `invalid_verifycode`, `expired`, `failed`, `already_connected`, and `confirmed`. Any unknown upstream status maps fail-closed to canonical `failed` with `errorCode=unknown_upstream_status`; raw unknown status is bounded evidence only. Upstream `binded_redirect` maps only to `already_connected` with `connected=false`, `alreadyConnected=true`, and `terminal=true`, never to `confirmed`.
## Canonical readiness
Readiness is one live, authenticated snapshot from the Gateway/OpenClaw boundary with one unified response shape for `ready`, `not_ready`, and `error`. It MUST include Gateway reachability/error, exact plugin id/version/digest, selected account identity, configured/enabled/running/last-error state, current tenant/account entitlement, reasons, observation time, and freshness deadline. `ready=true` is legal only when every required field is present, the snapshot is still fresh, expected plugin version/digest match, the selected account is entitled/configured/enabled/running with null error, and the Gateway is reachable. Missing fields, an old snapshot, digest mismatch, no entitlement, not-running state, unauthenticated evidence, or any contradiction returns `ready=false` with `code=not_ready` or `code=error` and at least one stable reason. Cached, synthetic, file-only, default-account, or service-up-only evidence cannot produce ready.
## Authenticated inbound-event challenge
The event body contains exactly `eventId`, `accountId`, `senderId`, `chatId`, `challengeCode`, and `observedAt`. It does not and cannot assert authentication, tenant id, Portal user id, or session key. Those values are absent by contract. Transport authentication MUST cover HTTP method, path, timestamp header, nonce header, and raw body digest; enforce a bounded clock window; durably reserve nonce/event replay keys for at least that window; compare signatures in constant time; and complete before body values reach logs or mutable business state.
After transport authentication, the server hashes the ephemeral `challengeCode` and resolves the pending durable challenge. That server-created record already owns tenant id, Portal user id, entitled account id, session-key digest, issued/expiry/consumed times, and state (`issued`, `consumed`, or `expired`). The body `accountId` must exactly match the record and current entitlement; it never supplies tenant/user/session authority. The first valid authenticated event atomically binds its provider `senderId` and `chatId`.
Issuance and consumption MUST run in a database transaction with row locking or an equivalent compare-and-swap primitive. `issued -> consumed` is single-use and atomic. An exact replay of the same authenticated event is idempotent. A changed event payload, sender, chat, account, durable tenant/user/account/session binding, expired or already-consumed record, duplicate event with different payload, missing entitlement, or unauthenticated transport fails closed with zero mutation. Raw challenge codes and raw session keys are never stored.
## Unsupported unbind
End-to-end unbind is unsupported in R10. Every public or internal unbind entry point MUST return the exact schema response with HTTP 501, `ok=false`, `supported=false`, `code=unbind_not_supported`, and `mutationCount=0`. The 501 decision occurs before token deletion, account update, session or challenge mutation, Gateway/OpenClaw call, business-state audit mutation, or background-job enqueue.
## Web, canary, and rollback controls
Only exact B6/B7 allowlisted paths in the R10 amendment may change. Portal endpoints require authenticated Django sessions, CSRF protection for mutations, server-scoped object lookup, IDOR tests, rate limits, request-size limits, `Cache-Control: no-store`, and structured redaction of session keys, verification codes, challenge codes, QR data, tokens, and provider payloads. Stable public errors never expose upstream bodies or cross-tenant existence.
The canary uses one explicitly entitled tenant/account pair and proves restart, concurrent waits, atomic challenge consumption, exact replay, altered replay denial, cross-tenant denial, all canonical statuses/readiness shapes, and zero-mutation 501 behavior. Promotion requires byte-identical artifacts and digests from the verified canary. Rollback restores the immediately preceding byte-identical artifacts/configuration and re-runs canonical readiness; database rollback is forbidden unless an explicitly approved manifest proves backward incompatibility and supplies a separate recovery plan.
@@ -1 +0,0 @@
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"tenant.account.entitlement","tenantId":"tenant-canary","accountId":"account-canary","effect":"allow","policyVersion":"policy-v1","issuedAtMs":1784068000000}
@@ -1 +0,0 @@
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"personal.weixin.readiness","tenantId":"tenant-canary","selectedAccountId":"account-canary","gateway":{"reachable":true,"errorCode":null},"plugin":{"id":"openclaw-weixin","loaded":true,"version":"2.4.6","digestSha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"accounts":[{"accountId":"account-canary","name":"Canary Personal Weixin","configured":true,"enabled":true,"running":true,"lastError":null,"isDefault":false,"entitled":true}],"ready":true,"code":"ready","reasons":[],"observedAtMs":1784068060000,"freshUntilMs":1784068120000}
@@ -1 +0,0 @@
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"personal.weixin.readiness","tenantId":"tenant-canary","selectedAccountId":"account-canary","gateway":{"reachable":true,"errorCode":null},"plugin":{"id":"openclaw-weixin","loaded":true,"version":"2.4.6","digestSha256":null},"accounts":[{"accountId":"account-canary","name":"Canary Personal Weixin","configured":true,"enabled":true,"running":true,"lastError":null,"isDefault":false,"entitled":true}],"ready":false,"code":"not_ready","reasons":[{"code":"field_missing","field":"plugin.digestSha256"}],"observedAtMs":1784068060000,"freshUntilMs":1784068120000}
@@ -1 +0,0 @@
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"personal.weixin.readiness","tenantId":"tenant-canary","selectedAccountId":"account-canary","gateway":{"reachable":true,"errorCode":null},"plugin":{"id":"openclaw-weixin","loaded":true,"version":"2.4.6","digestSha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"accounts":[{"accountId":"account-canary","name":"Canary Personal Weixin","configured":true,"enabled":true,"running":true,"lastError":null,"isDefault":false,"entitled":true}],"ready":false,"code":"not_ready","reasons":[{"code":"snapshot_stale","field":"freshUntilMs"}],"observedAtMs":1784060000000,"freshUntilMs":1784060060000}
@@ -1 +0,0 @@
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"personal.weixin.readiness","tenantId":"tenant-canary","selectedAccountId":"account-canary","gateway":{"reachable":true,"errorCode":null},"plugin":{"id":"openclaw-weixin","loaded":true,"version":"2.4.6","digestSha256":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"accounts":[{"accountId":"account-canary","name":"Canary Personal Weixin","configured":true,"enabled":true,"running":true,"lastError":null,"isDefault":false,"entitled":true}],"ready":false,"code":"not_ready","reasons":[{"code":"plugin_digest_mismatch","field":"plugin.digestSha256"}],"observedAtMs":1784068060000,"freshUntilMs":1784068120000}
@@ -1 +0,0 @@
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"personal.weixin.readiness","tenantId":"tenant-canary","selectedAccountId":"account-canary","gateway":{"reachable":true,"errorCode":null},"plugin":{"id":"openclaw-weixin","loaded":true,"version":"2.4.6","digestSha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"accounts":[{"accountId":"account-canary","name":"Canary Personal Weixin","configured":true,"enabled":true,"running":true,"lastError":null,"isDefault":false,"entitled":false}],"ready":false,"code":"not_ready","reasons":[{"code":"account_not_entitled","field":"accounts[0].entitled"}],"observedAtMs":1784068060000,"freshUntilMs":1784068120000}
@@ -1 +0,0 @@
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"personal.weixin.readiness","tenantId":"tenant-canary","selectedAccountId":"account-canary","gateway":{"reachable":true,"errorCode":null},"plugin":{"id":"openclaw-weixin","loaded":true,"version":"2.4.6","digestSha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"accounts":[{"accountId":"account-canary","name":"Canary Personal Weixin","configured":true,"enabled":true,"running":false,"lastError":"plugin_not_running","isDefault":false,"entitled":true}],"ready":false,"code":"not_ready","reasons":[{"code":"account_not_running","field":"accounts[0].running"}],"observedAtMs":1784068060000,"freshUntilMs":1784068120000}
@@ -1 +0,0 @@
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"personal.weixin.readiness","tenantId":"tenant-canary","selectedAccountId":"account-canary","gateway":{"reachable":false,"errorCode":"gateway_unreachable"},"plugin":{"id":"openclaw-weixin","loaded":false,"version":null,"digestSha256":null},"accounts":[{"accountId":"account-canary","name":"Canary Personal Weixin","configured":true,"enabled":true,"running":false,"lastError":"gateway_unreachable","isDefault":false,"entitled":true}],"ready":false,"code":"error","reasons":[{"code":"gateway_error","field":"gateway.reachable"}],"observedAtMs":1784068060000,"freshUntilMs":1784068060000}
@@ -1 +0,0 @@
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.start.request","accountId":"account-canary","force":false,"timeoutMs":30000}
@@ -1 +0,0 @@
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.wait.request","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","verifyCode":"4321","currentQrDataUrl":"data:image/png;base64,iVBORw0KGgo=","timeoutMs":30000}
@@ -1 +0,0 @@
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.result","status":"qr_pending","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","connected":false,"alreadyConnected":false,"terminal":false,"needsVerifyCode":false,"qrDataUrl":"data:image/png;base64,iVBORw0KGgo="}
@@ -1 +0,0 @@
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.result","status":"need_verifycode","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","connected":false,"alreadyConnected":false,"terminal":false,"needsVerifyCode":true}
@@ -1 +0,0 @@
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.result","status":"invalid_verifycode","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","connected":false,"alreadyConnected":false,"terminal":false,"needsVerifyCode":true,"errorCode":"invalid_verifycode"}
@@ -1 +0,0 @@
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.result","status":"expired","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","connected":false,"alreadyConnected":false,"terminal":true,"needsVerifyCode":false,"errorCode":"login_expired"}
@@ -1 +0,0 @@
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.result","status":"failed","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","connected":false,"alreadyConnected":false,"terminal":true,"needsVerifyCode":false,"errorCode":"upstream_failed"}
@@ -1 +0,0 @@
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.result","status":"already_connected","upstreamStatus":"binded_redirect","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","connected":false,"alreadyConnected":true,"terminal":true,"needsVerifyCode":false}
@@ -1 +0,0 @@
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.result","status":"confirmed","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","connected":true,"alreadyConnected":false,"terminal":true,"needsVerifyCode":false,"userId":"sender-canary"}
@@ -1 +0,0 @@
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.result","status":"failed","upstreamStatus":"future_unknown_state","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","connected":false,"alreadyConnected":false,"terminal":true,"needsVerifyCode":false,"errorCode":"unknown_upstream_status"}
@@ -1 +0,0 @@
{"eventId":"event-20260715-A1","accountId":"account-canary","senderId":"sender-canary","chatId":"chat-canary","challengeCode":"A1B2C3D4","observedAt":1784068120000}
@@ -1 +0,0 @@
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.unbind.result","httpStatus":501,"ok":false,"supported":false,"code":"unbind_not_supported","mutationCount":0}
@@ -1,986 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://aios.imruyi.top/contracts/personal-weixin/v1/golden-contract.schema.json",
"title": "AIOS Personal Weixin Golden Contract v1",
"oneOf": [
{
"$ref": "#/$defs/entitlement"
},
{
"$ref": "#/$defs/readiness"
},
{
"$ref": "#/$defs/startRequest"
},
{
"$ref": "#/$defs/waitRequest"
},
{
"$ref": "#/$defs/loginResult"
},
{
"$ref": "#/$defs/inboundEvent"
},
{
"$ref": "#/$defs/unbindResult"
}
],
"$defs": {
"identifier": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9._~:@-]+$"
},
"sessionKey": {
"type": "string",
"minLength": 1,
"maxLength": 256,
"pattern": "^[A-Za-z0-9._~:-]+$"
},
"schemaVersion": {
"const": "aios.personal-weixin.golden.v1"
},
"entitlement": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"kind",
"tenantId",
"accountId",
"effect",
"policyVersion",
"issuedAtMs"
],
"properties": {
"schemaVersion": {
"$ref": "#/$defs/schemaVersion"
},
"kind": {
"const": "tenant.account.entitlement"
},
"tenantId": {
"$ref": "#/$defs/identifier"
},
"accountId": {
"$ref": "#/$defs/identifier"
},
"effect": {
"const": "allow"
},
"policyVersion": {
"$ref": "#/$defs/identifier"
},
"issuedAtMs": {
"type": "integer",
"minimum": 0
}
}
},
"readinessReason": {
"type": "object",
"additionalProperties": false,
"required": [
"code",
"field"
],
"properties": {
"code": {
"enum": [
"field_missing",
"snapshot_stale",
"plugin_digest_mismatch",
"account_not_entitled",
"account_not_running",
"gateway_error"
]
},
"field": {
"type": "string",
"minLength": 1,
"maxLength": 128
}
}
},
"readiness": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"kind",
"tenantId",
"selectedAccountId",
"gateway",
"plugin",
"accounts",
"ready",
"code",
"reasons",
"observedAtMs",
"freshUntilMs"
],
"properties": {
"schemaVersion": {
"$ref": "#/$defs/schemaVersion"
},
"kind": {
"const": "personal.weixin.readiness"
},
"tenantId": {
"$ref": "#/$defs/identifier"
},
"selectedAccountId": {
"$ref": "#/$defs/identifier"
},
"gateway": {
"type": "object",
"additionalProperties": false,
"required": [
"reachable",
"errorCode"
],
"properties": {
"reachable": {
"type": "boolean"
},
"errorCode": {
"type": [
"string",
"null"
],
"minLength": 1,
"maxLength": 128
}
}
},
"plugin": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"loaded",
"version",
"digestSha256"
],
"properties": {
"id": {
"const": "openclaw-weixin"
},
"loaded": {
"type": "boolean"
},
"version": {
"type": [
"string",
"null"
],
"minLength": 1,
"maxLength": 64
},
"digestSha256": {
"anyOf": [
{
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
{
"type": "null"
}
]
}
}
},
"accounts": {
"type": "array",
"minItems": 1,
"maxItems": 100,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"accountId",
"name",
"configured",
"enabled",
"running",
"lastError",
"isDefault",
"entitled"
],
"properties": {
"accountId": {
"$ref": "#/$defs/identifier"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"configured": {
"type": "boolean"
},
"enabled": {
"type": "boolean"
},
"running": {
"type": "boolean"
},
"lastError": {
"type": [
"string",
"null"
],
"minLength": 1,
"maxLength": 256
},
"isDefault": {
"type": "boolean"
},
"entitled": {
"type": "boolean"
}
}
}
},
"ready": {
"type": "boolean"
},
"code": {
"enum": [
"ready",
"not_ready",
"error"
]
},
"reasons": {
"type": "array",
"maxItems": 32,
"items": {
"$ref": "#/$defs/readinessReason"
}
},
"observedAtMs": {
"type": "integer",
"minimum": 0
},
"freshUntilMs": {
"type": "integer",
"minimum": 0
}
},
"allOf": [
{
"if": {
"properties": {
"ready": {
"const": true
}
},
"required": [
"ready"
]
},
"then": {
"properties": {
"code": {
"const": "ready"
},
"reasons": {
"maxItems": 0
},
"gateway": {
"properties": {
"reachable": {
"const": true
},
"errorCode": {
"const": null
}
}
},
"plugin": {
"properties": {
"loaded": {
"const": true
},
"version": {
"type": "string"
},
"digestSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
}
}
},
"accounts": {
"items": {
"properties": {
"configured": {
"const": true
},
"enabled": {
"const": true
},
"running": {
"const": true
},
"lastError": {
"const": null
},
"entitled": {
"const": true
}
}
}
}
}
},
"else": {
"properties": {
"code": {
"enum": [
"not_ready",
"error"
]
},
"reasons": {
"minItems": 1
}
}
}
}
]
},
"startRequest": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"kind",
"accountId",
"force",
"timeoutMs"
],
"properties": {
"schemaVersion": {
"$ref": "#/$defs/schemaVersion"
},
"kind": {
"const": "web.login.start.request"
},
"accountId": {
"$ref": "#/$defs/identifier"
},
"force": {
"const": false
},
"timeoutMs": {
"type": "integer",
"minimum": 1000,
"maximum": 120000
}
}
},
"waitRequest": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"kind",
"accountId",
"sessionKey",
"timeoutMs"
],
"properties": {
"schemaVersion": {
"$ref": "#/$defs/schemaVersion"
},
"kind": {
"const": "web.login.wait.request"
},
"accountId": {
"$ref": "#/$defs/identifier"
},
"sessionKey": {
"$ref": "#/$defs/sessionKey"
},
"verifyCode": {
"type": "string",
"pattern": "^[0-9]{4,8}$"
},
"currentQrDataUrl": {
"type": "string",
"minLength": 1,
"maxLength": 1048576,
"pattern": "^data:image/png;base64,"
},
"timeoutMs": {
"type": "integer",
"minimum": 1000,
"maximum": 120000
}
}
},
"loginResult": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"kind",
"status",
"accountId",
"sessionKey",
"connected",
"alreadyConnected",
"terminal",
"needsVerifyCode"
],
"properties": {
"schemaVersion": {
"$ref": "#/$defs/schemaVersion"
},
"kind": {
"const": "web.login.result"
},
"status": {
"enum": [
"qr_pending",
"need_verifycode",
"invalid_verifycode",
"expired",
"failed",
"already_connected",
"confirmed"
]
},
"accountId": {
"$ref": "#/$defs/identifier"
},
"sessionKey": {
"$ref": "#/$defs/sessionKey"
},
"connected": {
"type": "boolean"
},
"alreadyConnected": {
"type": "boolean"
},
"terminal": {
"type": "boolean"
},
"needsVerifyCode": {
"type": "boolean"
},
"qrDataUrl": {
"type": "string",
"minLength": 1,
"maxLength": 1048576,
"pattern": "^data:image/png;base64,"
},
"userId": {
"$ref": "#/$defs/identifier"
},
"errorCode": {
"enum": [
"invalid_verifycode",
"login_expired",
"upstream_failed",
"unknown_upstream_status"
]
},
"upstreamStatus": {
"type": "string",
"minLength": 1,
"maxLength": 128
}
},
"allOf": [
{
"if": {
"properties": {
"status": {
"const": "qr_pending"
}
}
},
"then": {
"required": [
"qrDataUrl"
],
"properties": {
"connected": {
"const": false
},
"alreadyConnected": {
"const": false
},
"terminal": {
"const": false
},
"needsVerifyCode": {
"const": false
}
},
"not": {
"anyOf": [
{
"required": [
"userId"
]
},
{
"required": [
"errorCode"
]
},
{
"required": [
"upstreamStatus"
]
}
]
}
}
},
{
"if": {
"properties": {
"status": {
"const": "need_verifycode"
}
}
},
"then": {
"properties": {
"connected": {
"const": false
},
"alreadyConnected": {
"const": false
},
"terminal": {
"const": false
},
"needsVerifyCode": {
"const": true
}
},
"not": {
"anyOf": [
{
"required": [
"qrDataUrl"
]
},
{
"required": [
"userId"
]
},
{
"required": [
"errorCode"
]
},
{
"required": [
"upstreamStatus"
]
}
]
}
}
},
{
"if": {
"properties": {
"status": {
"const": "invalid_verifycode"
}
}
},
"then": {
"required": [
"errorCode"
],
"properties": {
"errorCode": {
"const": "invalid_verifycode"
},
"connected": {
"const": false
},
"alreadyConnected": {
"const": false
},
"terminal": {
"const": false
},
"needsVerifyCode": {
"const": true
}
},
"not": {
"anyOf": [
{
"required": [
"qrDataUrl"
]
},
{
"required": [
"userId"
]
},
{
"required": [
"upstreamStatus"
]
}
]
}
}
},
{
"if": {
"properties": {
"status": {
"const": "expired"
}
}
},
"then": {
"required": [
"errorCode"
],
"properties": {
"errorCode": {
"const": "login_expired"
},
"connected": {
"const": false
},
"alreadyConnected": {
"const": false
},
"terminal": {
"const": true
},
"needsVerifyCode": {
"const": false
}
},
"not": {
"anyOf": [
{
"required": [
"qrDataUrl"
]
},
{
"required": [
"userId"
]
},
{
"required": [
"upstreamStatus"
]
}
]
}
}
},
{
"if": {
"properties": {
"status": {
"const": "failed"
}
}
},
"then": {
"required": [
"errorCode"
],
"properties": {
"connected": {
"const": false
},
"alreadyConnected": {
"const": false
},
"terminal": {
"const": true
},
"needsVerifyCode": {
"const": false
}
},
"not": {
"anyOf": [
{
"required": [
"qrDataUrl"
]
},
{
"required": [
"userId"
]
}
]
}
}
},
{
"if": {
"required": [
"upstreamStatus"
],
"properties": {
"status": {
"const": "failed"
}
}
},
"then": {
"properties": {
"errorCode": {
"const": "unknown_upstream_status"
}
},
"not": {
"anyOf": [
{
"required": [
"qrDataUrl"
]
},
{
"required": [
"userId"
]
}
]
}
}
},
{
"if": {
"required": [
"status",
"errorCode"
],
"properties": {
"status": {
"const": "failed"
},
"errorCode": {
"const": "unknown_upstream_status"
}
}
},
"then": {
"required": [
"upstreamStatus"
]
}
},
{
"if": {
"required": [
"status",
"errorCode"
],
"properties": {
"status": {
"const": "failed"
},
"errorCode": {
"const": "upstream_failed"
}
}
},
"then": {
"not": {
"required": [
"upstreamStatus"
]
}
}
},
{
"if": {
"properties": {
"status": {
"const": "already_connected"
}
}
},
"then": {
"required": [
"upstreamStatus"
],
"properties": {
"upstreamStatus": {
"const": "binded_redirect"
},
"connected": {
"const": false
},
"alreadyConnected": {
"const": true
},
"terminal": {
"const": true
},
"needsVerifyCode": {
"const": false
}
},
"not": {
"anyOf": [
{
"required": [
"qrDataUrl"
]
},
{
"required": [
"userId"
]
},
{
"required": [
"errorCode"
]
}
]
}
}
},
{
"if": {
"properties": {
"status": {
"const": "confirmed"
}
}
},
"then": {
"required": [
"userId"
],
"properties": {
"connected": {
"const": true
},
"alreadyConnected": {
"const": false
},
"terminal": {
"const": true
},
"needsVerifyCode": {
"const": false
}
},
"not": {
"anyOf": [
{
"required": [
"qrDataUrl"
]
},
{
"required": [
"errorCode"
]
},
{
"required": [
"upstreamStatus"
]
}
]
}
}
}
]
},
"inboundEvent": {
"type": "object",
"additionalProperties": false,
"required": [
"eventId",
"accountId",
"senderId",
"chatId",
"challengeCode",
"observedAt"
],
"properties": {
"eventId": {
"$ref": "#/$defs/identifier"
},
"accountId": {
"$ref": "#/$defs/identifier"
},
"senderId": {
"$ref": "#/$defs/identifier"
},
"chatId": {
"$ref": "#/$defs/identifier"
},
"challengeCode": {
"type": "string",
"minLength": 6,
"maxLength": 64,
"pattern": "^[A-Z0-9]+$"
},
"observedAt": {
"type": "integer",
"minimum": 0
}
}
},
"unbindResult": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"kind",
"httpStatus",
"ok",
"supported",
"code",
"mutationCount"
],
"properties": {
"schemaVersion": {
"$ref": "#/$defs/schemaVersion"
},
"kind": {
"const": "web.unbind.result"
},
"httpStatus": {
"const": 501
},
"ok": {
"const": false
},
"supported": {
"const": false
},
"code": {
"const": "unbind_not_supported"
},
"mutationCount": {
"const": 0
}
}
}
}
}
Binary file not shown.
-20
View File
@@ -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'
-29
View File
@@ -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"
-27
View File
@@ -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"
-7
View File
@@ -1,7 +0,0 @@
# V1R4C6 Backup Result
- version: V1R4C6
- status: NOT_STARTED
- reason: No confirmed production Stack, persistent volume, database migration target, or V1R4C5 image digest is bound to this source release. A backup cannot be claimed or executed safely without an identified target datastore.
- prohibited_actions_not_performed: production database deletion; persistent-volume deletion; docker system prune --volumes; direct copy of live database files.
- required_before_resume: confirmed Stack identity and its documented backup/restore command.
-14
View File
@@ -1,14 +0,0 @@
# V1R4C6 Deployment Result
- version: V1R4C6
- source_commit: e204a90398bf646d46245b3088226749fa056edd
- tag_commit: d8cf6058e99f5c7f2dfd2b45a406c0a7edf955b2
- image_repository: UNRESOLVED
- image_digest: UNRESOLVED
- previous_image_digest: UNRESOLVED
- portainer_environment: UNRESOLVED
- portainer_stack: UNRESOLVED
- deployment_action: NOT_EXECUTED
- reason: No source-to-image-to-Portainer-Stack mapping exists. Existing Portainer containers and known Docker workloads do not expose a V1R4C6/e204 deployment target or Portainer stack labels. The active GitOps API is a direct systemd Python process, not a Portainer-managed Docker Stack.
- rollback_status: NOT_APPLICABLE_NO_CHANGE
- final_status: BLOCKED_MISSING_CONFIRMED_PRODUCTION_STACK
-15
View File
@@ -1,15 +0,0 @@
# V1R4C6 Production Preflight Result
- version: V1R4C6
- started_at_utc: 2026-07-11T16:57:04.0596039Z
- source_tag_object: 356a8a8c1860b94b7d61a456c29228c88e57d963
- source_tag_commit: d8cf6058e99f5c7f2dfd2b45a406c0a7edf955b2
- source_commit: e204a90398bf646d46245b3088226749fa056edd
- tag_to_source_relation: tag commit is direct parent of source commit (verified)
- production_host_identifier: cn-network-mkt (hw / 1.95.164.183)
- host_preflight: Docker 29.5.3; root filesystem 29 percent used; memory available approximately 56 GiB
- GitOps_runtime: active at /data/aios-gitops/workspace via aios-gitops-model-api.service
- runtime_base: current_baseline V1R4C6; previous_baseline V1R4C5; git e204a90398bf646d46245b3088226749fa056edd; service status ok
- Portainer discovery: Portainer instances are reachable on ubuntu-admin and ubuntu-aihost, but no confirmed Portainer Environment/Stack maps to V1R4C6 or e204a90398bf646d46245b3088226749fa056edd.
- source_build_contract: target source contains only deploy/gitea/docker-compose.yml and no application Dockerfile/Containerfile or V1R4C6 image repository declaration.
- status: BLOCKED_MISSING_CONFIRMED_PRODUCTION_STACK
-7
View File
@@ -1,7 +0,0 @@
# V1R4C6 Rollback Result
- version: V1R4C6
- rollback_required: No
- rollback_executed: No
- reason: No container, Stack, database, image, or production configuration was changed during this run.
- captured_v1r4c5_digest: UNRESOLVED because no matching current production Stack was confirmed.
-11
View File
@@ -1,11 +0,0 @@
# V1R4C6 Verification Result
- version: V1R4C6
- healthcheck_result: GitOps adapter /health = 200 and /api/base = 200; this verifies the control-plane API only, not a V1R4C6 production deployment.
- gate_a_result: NOT_RUN (production admin workbench target and authenticated test path unresolved)
- gate_b_result: NOT_RUN (production Search Gateway target/provider binding unresolved)
- gate_c_result: NOT_RUN (production Agent Platform target/runtime request path unresolved)
- runtime_acceptance_result: NOT_RUN (0/8; cannot reuse non-production results)
- critical_control_plane_finding: gitea-branch-consistency-sentinel is failed with five Git-ref-to-Gitea-metadata drift entries and autonomous safe mode emitted. This is a hard safety gate failure.
- no_unresolved_critical_errors: false
- final_status: BLOCKED_MISSING_CONFIRMED_PRODUCTION_STACK
+52 -41
View File
@@ -1,3 +1,4 @@
<<<<<<< HEAD
# AIOS Agent Audit Plane Architecture
## Overview
@@ -24,10 +25,9 @@ Agent Command Plane (agents/commands/)
+---> Audit
| |
| v
| audit/agent-runs/ (per-agent log)
| audit/command-history/ (per-command log)
| audit/version-history/ (per-baseline log)
| audit/interaction-history/ (agent conversation traces)
| audit/agent-runs/ (per-agent log)
| audit/command-history/ (per-command log)
| audit/version-history/ (per-baseline log)
|
+---> Git Commit
|
@@ -45,39 +45,8 @@ Agent Command Plane (agents/commands/)
5. Git commit locks the audit record
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
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
{
"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
- 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
Records baseline promotions, rollbacks, and version changes.
@@ -113,9 +127,6 @@ Conversation logs between agent and owner for each command execution.
## Audit Trail Requirement
All changes are tracked via Git with signed commits where possible. Every commit references the originating Command ID.
## Related
- [Agent Command Plane](../agents/README.md)
- [Baseline System](baseline-system.md)
All changes tracked via Git with signed commits where possible.
Every commit message references the originating Command ID.
>>>>>>> 4d18ee0 (feat(cmd): enable Agent Command Plane, audit plane, and Web UI dashboard)
@@ -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.
@@ -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
@@ -51,16 +51,6 @@
- 首次部署因为 `-- No entries --` 门禁误判而回滚,随后纠正部署在线,PID 为 `2496603`。
- 在线修复后的 hashes 为 `86dfe...` / `a9b854...`,`channel 4` accounts healthy,cache unchanged。
## 主线合并与生产发布
- 主线 fast-forward source rev 为 `9269e819972c2e709e0c27bed7eab590da6961c9`,本次不 tag、不 baseline。
- 生产发布结论为 PASS,release dir 为 `/var/backups/aios/weixin-mainline-release-20260711T181842Z`。
- old PID 为 `2655072`,new PID 为 `2656106`。
- TS hash 为 `86dfe4543c4414bc86778a4b218c244a742a139c66e3c9f19cf6f0de871234c3`,JS hash 为 `a9b854015108020458ce6f31b9d9187eff82e170c4c214259b0afb9be6a7336f`。
- `18789` 监听,`channel 4` accounts healthy,`error_count=0`,cache unchanged。
- 一次参数门禁失败已回滚,且不影响最终发布。
- owner quoted-text live acceptance 仍为 PENDING。
## 运行态验证
- 第一轮 gateway PID 由 `4025932` 变为 `1723576`;v2 部署后由 `1723576` 变为 `1876713`。
@@ -77,36 +67,6 @@
以上四类消息仍需要由 owner 真实 inbound 到达,才能把这次“文本隔离”结论升格为完整的线上接受;本报告明确保留该项为 pending,不把它们写成已经完成。
## 引用文字语义修复与发布(2026-07-12)
- 后续 owner 实测证明图片误路由已消失,但模型在没有被引正文时根据 `chat_id/message_id/timestamp` 编造解释,因此图片隔离 PASS、语义验收 FAIL。
- 现场 payload 的 `ref_msg.message_item.type=0/NONE` 只有 quoted ID 与时间字段,没有 `text_item`;session transcript 又没有原始 Weixin 数字 message ID,不能精确回表。
- 出站 `clientId` 与引用的 19 位数字 `msg_id` 格式不同,发送 API 也不返回服务端 message ID。本次明确禁止 recent-message、时间窗口、session 顺序和近似 ID 猜测。
- `1136b4186d7578cd77f255297204b10152c47dab` 增加证据优先处理:payload 明确含 `text_item.text` 时才构造引用上下文;引用存在但正文缺失时,经标准 dispatcher 确定性回复“无法读取被引用文字,请复制原文后再提问。”,并在模型 dispatch 前返回。
- TDD 先出现两项预期 RED(quoted-text resolver 不存在),最终 GREEN 为 `17 passed`;`py_compile`、`git diff --check`、`node --check` 均通过。
- Scratch 双跑验证只改变 `src/messaging/process-message.ts` 与 `dist/src/messaging/process-message.js`,第二次应用 diff 为 0;slash/channel 文件保持不变。
- 主线为 `1136b4186d7578cd77f255297204b10152c47dab`,生产发布目录为 `/var/backups/aios/weixin-quoted-text-failclosed-release-20260712T054451Z`。
- 发布后 PID 从 `2656106` 变为 `3670157`;TS hash 为 `1a73e735e9c8fb40a1f82d2a66bc022396c8105ed989aac95e11b66326e9fdf1`,JS hash 为 `1c76bf6ec3b45021e68b932cbf52222ba2130a867b95e41d7a91f261b991a304`。
- `18789`、4 个 Weixin accounts、post-start error 0 和图片 cache unchanged 均通过。新的 owner live acceptance 仍为 PENDING。
## Owner 引用文字 Fail-Closed 验收(2026-07-12)
- Owner 引用一条微信未提供正文的文字消息并发送“是什么意思”,收到固定回复“无法读取被引用文字,请复制原文后再提问。”。
- 日志窗口为 `2026-07-12 14:06:23.424-14:06:23.893 +08:00`:入站 `types=1`、`bodyLen=6`、`hasMedia=false`,出站 `textLen=20` 且发送成功。
- 同一窗口 `model-fetch=0`、`implicit_image_message=0`、Hermes route=0,`quoted_text_unavailable=1` 且 `quoted_message_id=present`。
- 该证据确认无正文引用不会进入模型猜测或图片缓存路径。Owner quoted-text no-body live acceptance 为 **PASS**。
- 微信 payload 明确携带 `text_item.text` 时的 grounded explanation 和 genuine image follow-up 仍作为独立兼容性验收项保留。
## 可见引用摘要解析修复(2026-07-12)
- Owner 进一步确认微信客户端能够显示被引用正文,因此上一节的“未提供正文”只适用于旧 resolver 的观察结果,不代表协议 payload 没有可用文本。
- 根因是插件 `inbound.ts` 已将协议字段 `ref_msg.title` 定义为引用摘要并拼入 `ctx.Body`,而 `extractHermesReferencedText()` 只读取 `ref_msg.message_item.text_item.text`,导致客户端可见摘要被错误丢弃。
- `13c2a182860131abcad0f5a70ae8263477578588` 在真实 `text_item.text` 缺失时,仅从 reference envelope 读取 `ref_msg.title`;不会读取 `message_item.title` 或任意嵌套通用 `title`,也不改变图片、slash 或 channel 路由。
- TDD 先得到 `18 passed / 1 failed`,失败项为可见 `ref_msg.title` 仍落到 `quoted_text_unavailable`;修复后为 `19 passed`,`py_compile`、`node --check` 与 `git diff --check` 通过。
- 生产发布目录为 `/var/backups/aios/weixin-quoted-text-title-resolution-20260712T155112+0800`,PID 从 `3859376` 变为 `3860863`,post-start error 为 0,图片 cache unchanged。
- 部署后 TS hash 为 `8e77ce61214688a2507b07f0cf1ee85401b58198b8271bebd69efc2681872726`,JS hash 为 `663db8d30a5cd5eb80fe46cf941cce6e6f5d04cb7e4b8d9b4d0086d87faba290`。
- 可见正文引用的 owner live acceptance 尚待下一条真实微信消息确认;预期日志必须命中 `quoted_text_resolved`,且同一窗口不得出现 `quoted_text_unavailable` 或图片路由。
## 安全说明
- 本次仅更新本报告,没有修改代码、runtime、services 或 secrets。
@@ -0,0 +1,131 @@
# V1R4C4 SR-01: Gitea Branch Sync Fix
Status: started
Priority: P0
Risk: medium
Baseline dependency: V1R4C3
## Decision
Start V1R4C4 with SR-01 only.
SR-04 Portal AIOS Integration Merge remains deferred and is not part of this execution scope.
## Background
V1R4C3 completed the immediate branch_sync mitigation by changing branch synchronization to use `git ls-remote` instead of depending on the Gitea branch API pagination path.
That mitigation avoids the user-visible failure path, but it does not fully certify or eliminate the root reliability issue:
```text
bare repository refs
!=
Gitea branch metadata / DB index
!=
Gitea branch API visible state
```
SR-01 exists to validate and fix the root problem category: Gitea branch metadata/index drift from the bare repository state.
## Scope
SR-01 includes:
1. Confirm current branch_sync no longer depends on Gitea API pagination.
2. Validate that Gitea DB branch metadata is consistent with bare repository refs.
3. Add or activate consistency verification around branch metadata drift.
4. Ensure autonomous operations do not continue mutating state when drift is detected.
5. Produce a repeatable acceptance test for future incidents.
SR-01 excludes:
- Portal AIOS Integration Merge.
- Search Gateway real provider binding.
- Broad P1 capability merges.
- Bulk patch merge or branch deletion.
## Required Checks
### Branch sync path
Verify branch_sync source of truth:
```text
git ls-remote / bare repository refs
```
The Gitea branch API must not be the mandatory source of truth for synchronization.
### Gitea consistency path
Verify equality between:
```text
bare repository refs count
Gitea branch live metadata count
Gitea branch API paginated visible count
```
Any mismatch must be treated as reliability drift.
### Safe-mode behavior
When drift is detected:
1. Stop autonomous mutations.
2. Preserve read-only diagnostics.
3. Emit safe-mode marker.
4. Require controlled recovery before mutation resumes.
## Acceptance Criteria
SR-01 is complete when all of the following are true:
1. branch_sync continues to work without Gitea branch API pagination dependency.
2. Gitea branch metadata drift can be detected deterministically.
3. Drift causes autonomous safe mode instead of continued mutation.
4. Recovery procedure includes minimal repair, not default restart.
5. Runtime acceptance passes:
```text
getRuntimeStatus
->
getBaseline
->
createBranch
->
createPatch
```
Expected results:
- runtime status is active;
- baseline reads V1R4C3 or later;
- createBranch returns commit/rev/sync status;
- createPatch returns commit/rev/sync status.
## Execution Order
1. Open SR-01 patch branch.
2. Review V1R4C3 branch_sync behavior.
3. Integrate Gitea branch consistency sentinel and safe-mode gate.
4. Run runtime acceptance.
5. If clean, prepare V1R4C4 reliability baseline proposal.
## Follow-on Gates
SR-02 Watcher State Cleanup may start after SR-01 acceptance is green.
SR-03 Search Gateway Real Provider Binding may start after SR-01 confirms GitOps/Gitea reliability is stable enough for non-P0 work.
SR-04 Portal AIOS Integration Merge remains deferred pending explicit approval.
## Safety Rules
- Do not bulk merge old patches.
- Do not delete remote branches during SR-01 unless the branch consistency sentinel is already deployed and healthy.
- Do not restart Gitea unless process-level failure is proven.
- Do not modify Gitea DB without backup and owner/admin approval.
-212
View File
@@ -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.
-107
View File
@@ -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
```
@@ -1,67 +0,0 @@
# Weixin Quoted Text Fail-Closed Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Resolve explicit quoted text safely and fail closed without a model call when Weixin omits the quoted body.
**Architecture:** Add pure JS/TS reference-text helpers and one process hook guard to the existing patcher. Keep the patch surface limited to generated `process-message.ts/js`; do not introduce a persistent cache or modify send/monitor/API modules.
**Tech Stack:** Python patch generator, TypeScript/JavaScript, Node test harness, pytest.
## Global Constraints
- Sub-agents use `gpt-5.3-codex-spark`.
- No force push, tag, baseline promotion, credential persistence, recent-message fallback, or approximate text matching.
- Existing confirmed image and ordinary text behavior must remain unchanged.
---
### Task 1: Add Quoted Text Resolver With TDD
**Files:**
- Modify: `tests/test_weixin_image_followup_intent.py`
- Modify: `fixes/patch_openclaw_weixin_hermes.py`
**Interfaces:**
- Produces: `extractHermesReferencedText(items)` and `resolveHermesQuotedTextContext(items, currentText)` in TS and JS helper templates.
- [ ] Add a Node-backed failing test with `ref_msg.message_item.type=1` and explicit `text_item.text="被引用的正文"`; assert `reason="quoted_text_resolved"`, `handled=false`, and a prompt containing only delimited quoted/current text.
- [ ] Add a Node-backed failing test with `type=0`, quoted ID, no text, and no confirmed image; assert `reason="quoted_text_unavailable"`, `handled=true`, and exact deterministic `replyText`.
- [ ] Run `pytest -q tests/test_weixin_image_followup_intent.py -k 'quoted_text'` and record expected RED because the resolver is absent.
- [ ] Implement the smallest recursive explicit-text extractor. Accept only known referenced text fields; cap each extracted string at 4000 characters and reject blank values.
- [ ] Implement the resolver in both TS and JS templates. Do not read session history, image latest cache, timestamps, or metadata values.
- [ ] Re-run the focused tests and record GREEN.
- [ ] Commit with `fix(weixin): fail closed on unavailable quoted text`.
### Task 2: Guard The Real Process Hook
**Files:**
- Modify: `tests/test_weixin_image_followup_intent.py`
- Modify: `fixes/patch_openclaw_weixin_hermes.py`
**Interfaces:**
- Consumes: `resolveHermesQuotedTextContext(items, currentText)`.
- Produces: process hook behavior that replies deterministically or replaces only the ordinary text prompt.
- [ ] Add a failing JS hook test that stubs the ordinary model dispatch and reply function. For type 0 without text, assert reply called once and model called zero times.
- [ ] Add a failing JS hook test for explicit quoted text. Assert model called once with delimited context and no message metadata.
- [ ] Add regression assertions for plain text, current image, and exact quoted image.
- [ ] Run focused tests and record RED for the missing guard.
- [ ] Insert the resolver after reference/image evidence extraction and before ordinary model dispatch. Confirmed image references must bypass the text fail-closed branch.
- [ ] Emit structured reason-only diagnostics without message bodies.
- [ ] Run focused tests and record GREEN.
- [ ] Commit with `fix(weixin): guard quoted text model dispatch`.
### Task 3: Idempotence, Review, And Release
**Files:**
- Modify: `docs/incidents/WEIXIN_TEXT_IMAGE_CACHE_ISOLATION_20260711.md`
- [ ] Run `pytest -q tests/test_weixin_image_followup_intent.py` and require all tests pass.
- [ ] Run `python3 -m py_compile fixes/patch_openclaw_weixin_hermes.py tests/test_weixin_image_followup_intent.py`.
- [ ] Run `git diff --check`.
- [ ] Apply `--process-only` twice to a scratch production-plugin copy and require identical second-run hashes; require slash/channel files unchanged.
- [ ] Obtain independent spec and code review.
- [ ] Update the incident document with RED/GREEN evidence, main revision, production hashes, rollback directory, and owner live acceptance state.
- [ ] Merge by non-force fast-forward from a clean main-based branch, deploy only the two generated process-message files, restart only `openclaw-gateway.service`, and verify service, port 18789, four accounts, zero post-start errors, and unchanged image cache.
- [ ] Owner live acceptance: quote a type 0 text message and verify deterministic no-body response; quote a payload that includes explicit text and verify grounded explanation.
@@ -1,635 +0,0 @@
# V2R1 Authenticated Runtime Release Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Close exactly five reviewed blockers between the replayed V2R1 tree and an evidence-backed, authenticated, digest-bound release candidate.
**Architecture:** Use one canonical `aios_v2r1` Python package; authenticate a Node-to-Python loopback envelope before server-side Weixin projection and authority resolution; make `ACCEPTED` terminal; dispatch through a durable outbox and the verified Hermes HTTP router; stage an isolated OpenClaw candidate with three services; promote only byte-identical artifacts after live reconciliation evidence.
**Tech Stack:** Python 3.11 and pytest, SQLite, Node.js, OpenClaw 2026.6.11, loopback HTTP, systemd credentials and units, SHA-256 manifests.
## Global gates and dynamic blocker ledger
- B1 and B2 may proceed only after review accepts the corrected design and plan.
- B3, B4, and B5 are evidence-gated. Unresolved evidence stops that task before implementation, commit, or ledger append.
- Current outbound evidence is `EVIDENCE_REQUIRED`; B4 and B5 remain stopped until a verified provider echo exactly binds `client_id -> message_id`.
- Keep exactly five blocker commits, one each for B1 through B5. The docs-correction commit is the reviewed starting point, not a blocker.
- Do not create a tag, edit `VERSION` or `baseline/current.json`, push `main`, deploy/restart a live service, modify active OpenClaw configuration, or invoke Fleet.
- All OpenClaw loader work uses an isolated temporary or candidate config/state directory.
- `ACCEPTED` is terminal. Legacy handling is reachable only when bridge mode is explicitly `disabled`.
- Never claim exactly-once Hermes delivery or use time/approximation as message authority.
- D0 is a non-blocker documentation correction. Its reviewed commit is the R9 `START`; it does not consume a blocker label or relax any B3/B4/B5 evidence gate.
- B2 is both the canonical-package blocker and the executable completeness gate for Ledger, Memory, Secret, Audit, Hermes Profile, Cell Manager/CLI, and Fabric. Existing correct behavior is recorded as characterization; a production edit requires an observed contract RED first.
After docs review, initialize the stable per-worktree ledger from the then-current tip rather than a hardcoded SHA:
```bash
test -z "$(git status --porcelain)"
test "$(git branch --show-current)" = "integration/v2r1-runtime-20260715-r9"
start_sha="$(git rev-parse HEAD)"
start_parent="$(git rev-parse HEAD^)"
start_tree="$(git rev-parse HEAD^{tree})"
ledger="$(git rev-parse --git-path v2r1-blocker-ledger.tsv)"
umask 077
test ! -e "$ledger"
printf 'START\t%s\t%s\t%s\n' "$start_sha" "$start_parent" "$start_tree" >"$ledger"
```
Expected: the clean reviewed docs-correction tip is `START`. At the beginning of every blocker, rehydrate and verify the latest ledger row before work:
```bash
ledger="$(git rev-parse --git-path v2r1-blocker-ledger.tsv)"
test -s "$ledger"
IFS=$'\t' read -r last_label last_sha last_parent last_tree < <(tail -n 1 "$ledger")
test "$(git rev-parse HEAD)" = "$last_sha"
test "$(git rev-parse HEAD^)" = "$last_parent"
test "$(git rev-parse HEAD^{tree})" = "$last_tree"
```
Each blocker appends `label, SHA, parent, tree` only after its review passes. The ledger stays in the worktree Git directory and is never staged.
---
## Task B1: Prove the real OpenClaw plugin loader and permission contract
**Files:**
- Modify: `openclaw/plugins/aios-tenant-boundary/package.json`
- Modify: `openclaw/plugins/aios-tenant-boundary/openclaw.plugin.json`
- Modify only on demonstrated hook failure: `openclaw/plugins/aios-tenant-boundary/dist/index.js`
- Modify only on demonstrated policy failure: `openclaw/plugins/aios-tenant-boundary/dist/policy.js`
- Create: `openclaw/plugins/aios-tenant-boundary/test/compat.test.js`
- Create: `openclaw/plugins/aios-tenant-boundary/test/policy.test.js`
- Create: `openclaw/plugins/aios-tenant-boundary/test/loader.test.mjs`
**Exact contract:** package/manifest versions `2.1.0-rc.1`; `blockUnknownAgentRuns` boolean default `false`; `approvalTimeoutMs` integer min `1000`, max `300000`, default `60000`; every runtime-read property declared. Default permission proves only `before_tool_call` is usable. `before_agent_run` and `agent_end` require `plugins.entries.aios-tenant-boundary.hooks.allowConversationAccess=true`. Fake API tests are supplemental; real runtime inspection is mandatory.
- [ ] Add Node tests for missing/blocked/allowlisted senders, automation, read-only tools, absent provenance, approval, exact timeout bounds, cleanup, age pruning, versions, and schema.
- [ ] Add `loader.test.mjs` to parse real inspect JSON and assert linked path, version, diagnostics, hooks, and permission state.
- [ ] Run initial RED:
```bash
cd openclaw/plugins/aios-tenant-boundary
npm test
```
Expected RED: current package has no tracked tests; version and missing-schema assertions fail.
- [ ] Normalize versions/schema; change runtime only when an executable test proves incompatibility. Capture TAP and prove at least one test executed:
```bash
tmp="$(mktemp -d)"
set -o pipefail
npm test -- --test-reporter=tap | tee "$tmp/plugin.tap"
test "$(grep -c '^ok [0-9]' "$tmp/plugin.tap")" -gt 0
test -z "$(grep '^not ok ' "$tmp/plugin.tap" || true)"
```
Expected GREEN: npm exits 0, TAP contains one or more `ok` test records, and contains no `not ok` record.
- [ ] Run the real loader twice in isolated state:
```bash
repo="$(git rev-parse --show-toplevel)"
tmp="$(mktemp -d)"
export OPENCLAW_STATE_DIR="$tmp/state"
export OPENCLAW_CONFIG_PATH="$tmp/state/openclaw.json"
mkdir -p "$OPENCLAW_STATE_DIR"
openclaw plugins install --link "$repo/openclaw/plugins/aios-tenant-boundary"
openclaw plugins inspect aios-tenant-boundary --runtime --json >"$tmp/default-inspect.json"
node "$repo/openclaw/plugins/aios-tenant-boundary/test/loader.test.mjs" default "$tmp/default-inspect.json"
openclaw config set plugins.entries.aios-tenant-boundary.hooks.allowConversationAccess true --strict-json
openclaw config validate
set +e
openclaw doctor --lint --json >"$tmp/doctor-lint.json"
doctor_rc=$?
set -e
test "$doctor_rc" = 0 || test "$doctor_rc" = 1
python3 - "$tmp/doctor-lint.json" <<'PY'
import json, sys
data = json.load(open(sys.argv[1]))
def walk(value):
if isinstance(value, dict):
yield value
for child in value.values(): yield from walk(child)
elif isinstance(value, list):
for child in value: yield from walk(child)
bad = [item for item in walk(data) if str(item.get("severity", "")).lower() in {"error", "fatal"} and "plugin" in json.dumps(item).lower()]
assert not bad, bad
PY
openclaw doctor --post-upgrade --json >"$tmp/doctor-plugin.json"
openclaw plugins inspect aios-tenant-boundary --runtime --json >"$tmp/opt-in-inspect.json"
node "$repo/openclaw/plugins/aios-tenant-boundary/test/loader.test.mjs" opt-in "$tmp/opt-in-inspect.json"
```
Expected GREEN: config validation and post-upgrade doctor exit 0; lint doctor may exit 0 or 1, warnings are allowed, parsed JSON contains zero error/fatal plugin findings; default inspection proves only `before_tool_call`; opt-in proves `before_agent_run`, `before_tool_call`, `agent_end`.
- [ ] Run carried-forward tests:
```bash
PYTHONDONTWRITEBYTECODE=1 PYTEST_ADDOPTS='-p no:cacheprovider' PYTHONPATH=active-runtime/app python3 -m pytest tests/v2r1 tests/integration -q
```
Expected GREEN: `32 passed`.
- [ ] Commit and append ledger:
```bash
git add openclaw/plugins/aios-tenant-boundary/package.json openclaw/plugins/aios-tenant-boundary/openclaw.plugin.json \
openclaw/plugins/aios-tenant-boundary/dist/index.js openclaw/plugins/aios-tenant-boundary/dist/policy.js \
openclaw/plugins/aios-tenant-boundary/test/compat.test.js openclaw/plugins/aios-tenant-boundary/test/policy.test.js \
openclaw/plugins/aios-tenant-boundary/test/loader.test.mjs
git commit -m "fix(v2r1): prove OpenClaw plugin compatibility"
```
### Review B1
- [ ] `git diff --check HEAD^ HEAD` has no output; commit contains only named B1 paths and unchanged optional `dist` files do not appear.
- [ ] Re-run Node tests, both real loader permission modes, validation/doctor, and 32 Python tests.
- [ ] Prove B1 parent equals ledger `START` and, before the final append step, the ledger still contains only `START`.
- [ ] Stop if real inspection does not prove default-only `before_tool_call` and explicit conversation-access hooks.
- [ ] After every prior B1 review check passes, append B1 and verify the recorded commit identity:
```bash
ledger="$(git rev-parse --git-path v2r1-blocker-ledger.tsv)"
printf 'B1\t%s\t%s\t%s\n' "$(git rev-parse HEAD)" "$(git rev-parse HEAD^)" "$(git rev-parse HEAD^{tree})" >>"$ledger"
IFS=$'\t' read -r label sha parent tree < <(tail -n 1 "$ledger")
test "$label" = B1
test "$sha" = "$(git rev-parse HEAD)"
test "$parent" = "$(git rev-parse HEAD^)"
test "$tree" = "$(git rev-parse HEAD^{tree})"
```
---
## Task B2: Package only `aios_v2r1` and repair the exact legacy import set
**Files:**
- Create: `active-runtime/pyproject.toml`
- Create: `active-runtime/app/aios_v2r1/__init__.py`
- Create: `active-runtime/app/aios_v2r1/ingress_service.py`
- Modify: `active-runtime/app/aios_v2r1/cli.py`
- Create: `tests/v2r1/test_ingress_service.py`
- Create: `tests/test_v2r1_package_identity.py`
- Modify: the exact 23 Appendix A test files, and no unlisted legacy import file
- Modify only after an observed matrix RED: `active-runtime/app/aios_v2r1/message_ledger.py`
- Modify only after an observed matrix RED: `active-runtime/app/aios_v2r1/memory_scope.py`
- Modify only after an observed matrix RED: `active-runtime/app/aios_v2r1/secret_scope.py`
- Modify only after an observed matrix RED: `active-runtime/app/aios_v2r1/audit.py`
- Modify only after an observed matrix RED: `active-runtime/app/aios_v2r1/hermes_profile.py`
- Modify only after an observed matrix RED: `active-runtime/app/aios_v2r1/cell_manager.py`
- Modify only after an observed matrix RED: `active-runtime/app/aios_v2r1/hermes_fabric.py`
- Expand: `tests/v2r1/test_message_ledger_append.py`
- Create: `tests/v2r1/test_memory_scope.py`
- Create: `tests/v2r1/test_secret_scope.py`
- Create: `tests/v2r1/test_audit.py`
- Create: `tests/v2r1/test_hermes_profile.py`
- Create: `tests/v2r1/test_cell_manager.py`
- Expand: `tests/v2r1/test_hermes_fabric.py`
**Exact contract:** V2R1 imports only as `aios_v2r1`; no `active_runtime` shim/alias/namespace extension/copied tree. Wheel includes `aios_v2r1`, excludes `active_runtime` and legacy `app`. Legacy repository tests change `active_runtime.app.*` to `app.*` and run with `PYTHONPATH='active-runtime:active-runtime/app'`. Mutation routes return bounded `SERVICE_NOT_CONFIGURED` until B3/B4 processors exist. Existing cell CLI stays compatible and `serve` is separate. B2 changes exactly 43 paths: the original 29 package/import paths plus the seven named runtime modules and seven named matrix-test paths above.
**Executable completeness matrix:** Message Ledger proves exact-ID query with no fallback, idempotency, collision conflict, tenant/cell/direction isolation, expiry/purge, restart, concurrency, and sanitized SQLite failures. Memory proves every namespace, cross-scope denial, shared-artifact read-only access, qualified keys, traversal rejection, and disabled search. Secret proves reference/path/scope/kind/version/identifier validation, no value leakage, and fail-closed broker boundaries. Audit proves genesis, tamper/delete/reorder detection, Unicode canonicalization, concurrency, restart, validation, tenant isolation, and sanitized SQLite failures. Hermes Profile proves exact environment, absolute roots, `0700` modes, traversal/symlink denial, scope, idempotent prepare, and no inherited environment. Cell Manager/CLI proves one cell per tenant, an explicit state graph, atomic/concurrent persistence, corrupt-registry fail-closed behavior, stable CLI exit codes/JSON, and restart. Fabric proves claimed jobs return `running` consistently with durable state, tenant/cell/profile isolation, one concurrent claimant, rejection of non-claimable states, restart, and sanitized SQLite failures.
- [ ] Rehydrate the stable blocker ledger using the global snippet and require its latest label is `B1`.
- [ ] Rehydrate the exact explorer manifest path and compare sorted actual/expected lists between exact marker lines. The marker names are assembled from split literals so this command cannot match itself:
```bash
actual="$(git rev-parse --git-path v2r1-active-runtime-imports.actual)"
expected="$(git rev-parse --git-path v2r1-active-runtime-imports.expected)"
plan=docs/superpowers/plans/2026-07-14-v2r1-runtime-release-plan.md
begin="$(printf '%s' 'APPENDIX_A_''BEGIN')"
end="$(printf '%s' 'APPENDIX_A_''END')"
tick="$(printf '\140')"
git grep -l -E '^(from|import) active_runtime\.app' -- tests | sort >"$actual"
awk -v begin="${tick}${begin}${tick}" -v end="${tick}${end}${tick}" '
$0 == begin {inside=1; next}
$0 == end {inside=0; next}
inside && /^- tests\// {sub(/^- /, ""); print}
' "$plan" | sort >"$expected"
test "$(wc -l < "$actual" | tr -d ' ')" = 23
test "$(wc -l < "$expected" | tr -d ' ')" = 23
cmp -s "$actual" "$expected"
```
Expected: exact match to 23 paths. Drift requires another docs review.
- [ ] Add package/service/CLI tests and the seven-component matrix before any production repair. Run each focused case and record the exact command plus its expected contract failure. If current behavior already satisfies the contract, record it as characterization and do not manufacture a failure or edit that production module.
- [ ] Run the aggregate initial RED/characterization command:
```bash
PYTHONDONTWRITEBYTECODE=1 PYTEST_ADDOPTS='-p no:cacheprovider' PYTHONPATH='active-runtime:active-runtime/app' \
python3 -m pytest tests/v2r1/test_ingress_service.py tests/test_v2r1_package_identity.py \
tests/v2r1/test_message_ledger_append.py tests/v2r1/test_memory_scope.py \
tests/v2r1/test_secret_scope.py tests/v2r1/test_audit.py \
tests/v2r1/test_hermes_profile.py tests/v2r1/test_cell_manager.py \
tests/v2r1/test_hermes_fabric.py -q
```
Expected: package metadata, ingress service, and identity tests begin RED because their implementation is absent. Each matrix case is either a recorded contract RED or a recorded characterization; no production repair precedes its corresponding RED.
- [ ] Implement package/disabled processor boundary and replace imports in exactly Appendix A. Apply only the minimum runtime repairs demonstrated by matrix REDs; do not delete or weaken a matrix test. Prove no shim/old import:
```bash
test ! -e active_runtime
test -z "$(git grep -l -E '^(from|import) active_runtime\.app' -- tests || true)"
```
- [ ] Run full collect and full suite:
```bash
PYTHONDONTWRITEBYTECODE=1 PYTEST_ADDOPTS='-p no:cacheprovider' PYTHONPATH='active-runtime:active-runtime/app' \
python3 -m pytest tests/v2r1 tests/integration tests/test_v2r1_package_identity.py -q
PYTHONDONTWRITEBYTECODE=1 PYTEST_ADDOPTS='-p no:cacheprovider' PYTHONPATH='active-runtime:active-runtime/app' python3 -m pytest --collect-only -q
PYTHONDONTWRITEBYTECODE=1 PYTEST_ADDOPTS='-p no:cacheprovider' PYTHONPATH='active-runtime:active-runtime/app' python3 -m pytest -q
PYTHONDONTWRITEBYTECODE=1 python3 -m compileall -q active-runtime/app/aios_v2r1
(cd openclaw/plugins/aios-tenant-boundary && npm test)
```
Expected GREEN: the focused carried suite reports at least `160 passed`, including every matrix bullet; full collection has zero import errors; the complete pytest suite, 15 Node tests, and Python compileall pass. Re-run B1's real isolated default/opt-in loader sequence and require the same hook evidence. Pre-existing failures still block B2.
- [ ] Build and inspect isolated wheel:
```bash
tmp="$(mktemp -d)"
python3 -m pip wheel --no-deps --no-build-isolation active-runtime -w "$tmp/wheels"
wheel="$(find "$tmp/wheels" -name '*.whl' -print -quit)"
python3 -m venv "$tmp/venv"
"$tmp/venv/bin/python" -m pip install --no-deps "$wheel"
"$tmp/venv/bin/python" - "$wheel" <<'PY'
import sys, zipfile
names = zipfile.ZipFile(sys.argv[1]).namelist()
assert any(n.endswith("aios_v2r1/ingress_service.py") for n in names)
assert not any(n.startswith("active_runtime/") or n.startswith("app/") for n in names)
import aios_v2r1
assert aios_v2r1.__name__ == "aios_v2r1"
PY
```
Expected GREEN: one wheel, one V2R1 namespace, installed identity `aios_v2r1`.
- [ ] Stage the exact 43 paths (six package paths, Appendix A's 23 import paths, seven matrix runtime modules, and seven matrix-test paths), commit, append ledger:
```bash
actual="$(git rev-parse --git-path v2r1-active-runtime-imports.actual)"
git add active-runtime/pyproject.toml active-runtime/app/aios_v2r1/__init__.py \
active-runtime/app/aios_v2r1/ingress_service.py active-runtime/app/aios_v2r1/cli.py \
tests/v2r1/test_ingress_service.py tests/test_v2r1_package_identity.py \
active-runtime/app/aios_v2r1/message_ledger.py active-runtime/app/aios_v2r1/memory_scope.py \
active-runtime/app/aios_v2r1/secret_scope.py active-runtime/app/aios_v2r1/audit.py \
active-runtime/app/aios_v2r1/hermes_profile.py active-runtime/app/aios_v2r1/cell_manager.py \
active-runtime/app/aios_v2r1/hermes_fabric.py tests/v2r1/test_message_ledger_append.py \
tests/v2r1/test_memory_scope.py tests/v2r1/test_secret_scope.py tests/v2r1/test_audit.py \
tests/v2r1/test_hermes_profile.py tests/v2r1/test_cell_manager.py tests/v2r1/test_hermes_fabric.py
while IFS= read -r path; do git add -- "$path"; done <"$actual"
git commit -m "feat(v2r1): package canonical authenticated ingress"
```
### Review B2
- [ ] Require `git diff --check`, exactly 43 paths (the original 29 plus the exact 14 matrix paths), exact-list/no-shim checks, focused matrix/carried tests, full collect/full pytest, Node 15, real loader default/opt-in, compileall, and wheel identity.
- [ ] Require B2 parent equals B1 in ledger.
- [ ] Prove mutation routes remain fail-closed until authenticated processors are installed.
- [ ] Prove every production matrix edit has a recorded pre-edit RED and every already-correct case is recorded as characterization. The partial R9 ledger must contain exactly `START,B1,B2`; B3/B4/B5 remain absent.
- [ ] After all prior B2 review checks pass, append B2 and verify the recorded commit identity:
```bash
ledger="$(git rev-parse --git-path v2r1-blocker-ledger.tsv)"
printf 'B2\t%s\t%s\t%s\n' "$(git rev-parse HEAD)" "$(git rev-parse HEAD^)" "$(git rev-parse HEAD^{tree})" >>"$ledger"
IFS=$'\t' read -r label sha parent tree < <(tail -n 1 "$ledger")
test "$label" = B2
test "$sha" = "$(git rev-parse HEAD)"
test "$parent" = "$(git rev-parse HEAD^)"
test "$tree" = "$(git rev-parse HEAD^{tree})"
```
---
## Task B3: Implement authenticated Node transport and server-side Weixin authority
**Evidence gate before code:** create `release/v2r1c0-rc1/weixin-bridge-evidence.json` only after recording installed TS/JS paths and SHA-256, exact inbound bigint/raw fields, authenticated account/sender/conversation sources, authority-map owner/mode/schema, candidate credential contract, and `disabled|enforced` mode owner. Stop if any source is inferred, Node would need the authority map, or patch targets are not byte-idempotent.
**Files after evidence acceptance:**
- Modify: `active-runtime/app/aios_v2r1/identity.py`
- Modify: `active-runtime/app/aios_v2r1/tenant_context.py`
- Modify: `active-runtime/app/aios_v2r1/ingress_service.py`
- Create: `active-runtime/app/aios_v2r1/bridge_auth.py`
- Create: `active-runtime/app/aios_v2r1/weixin_projection.py`
- Create: `fixes/patch_openclaw_weixin_v2r1_ingress.py`
- Create: `tests/v2r1/test_bridge_auth.py`
- Create: `tests/v2r1/test_weixin_projection.py`
- Create: `tests/integration/test_v2r1_bridge_transport.py`
- Create: `tests/test_weixin_v2r1_ingress_bridge.py`
- Create: four `tests/fixtures/weixin_v2r1/*.json` files named in the commit command
- Create: `release/v2r1c0-rc1/weixin-bridge-evidence.json`
**Exact contract:** Every authenticated POST has wire shape `{"unsigned_body":{"schema_version":"aios.signed-request.v1","key_id":"bridge-current","issued_at_ms":1784000000000,"nonce":"base64url-16-byte-value","route_payload":{}},"signature":"lowercase-64-hex"}`. `unsigned_body` has exactly those five fields; `route_payload` holds only route-specific content. The signing object is exactly `{"method":"POST","path":"<exact route>","body":unsigned_body}`. Canonical JSON is UTF-8 with recursive Unicode-codepoint-sorted object keys, no whitespace, no ASCII escaping, no floats, and string IDs; the outer `signature` is the lowercase 64-hex HMAC-SHA256 of those bytes. There is no authorization header, nested signature, or alternate route form. Python verifies before projection. Skew 30 seconds; replay five minutes; active+previous rotation; fresh nonce retry. Node reads bridge-ingress and the separate receipt-read keyring through distinct `$CREDENTIALS_DIRECTORY` files. Python owns projection/authority/internal signing. `POST /v1/receipts/status` accepts `account_id` plus exactly one of `event_id|receipt_id`, resolves only same-account/event scope, and uses receipt-read. Response max 4096 bytes. `REPLAY_REJECTED` is transport/service-only, never `RuntimeIngressResponse`. Accepted is terminal; legacy disabled-only. `Date.now()` and every local `_ms` timestamp are telemetry only.
- [ ] Rehydrate the stable ledger with the global snippet and require latest label `B2` before evidence collection.
- [ ] Add RED tests for the one exact wire/signing contract on `/v1/ingress` and `/v1/receipts/status`, including rejection of extra/missing fields, signature-inside-body, authorization-header variants, nonce lengths, skew boundaries, replay, key rotation, fresh retry, roles, bounded routes, terminal accepted, disabled legacy, bigint, and exact quote/attachment.
- [ ] Run initial RED:
```bash
PYTHONDONTWRITEBYTECODE=1 PYTEST_ADDOPTS='-p no:cacheprovider' PYTHONPATH='active-runtime:active-runtime/app' \
python3 -m pytest tests/v2r1/test_bridge_auth.py tests/v2r1/test_weixin_projection.py \
tests/integration/test_v2r1_bridge_transport.py tests/test_weixin_v2r1_ingress_bridge.py -q
```
Expected RED: authenticator, server projection, processor, patcher absent.
- [ ] Implement authenticated transport/server projection and byte-idempotent TS/JS patch. Never embed authority or internal key in Node.
- [ ] Run focused, existing Weixin, full collect, and full pytest. Expected GREEN: all pass.
- [ ] Commit exactly 15 paths and append ledger:
```bash
git add active-runtime/app/aios_v2r1/identity.py active-runtime/app/aios_v2r1/tenant_context.py \
active-runtime/app/aios_v2r1/ingress_service.py active-runtime/app/aios_v2r1/bridge_auth.py \
active-runtime/app/aios_v2r1/weixin_projection.py fixes/patch_openclaw_weixin_v2r1_ingress.py \
tests/v2r1/test_bridge_auth.py tests/v2r1/test_weixin_projection.py \
tests/integration/test_v2r1_bridge_transport.py tests/test_weixin_v2r1_ingress_bridge.py \
tests/fixtures/weixin_v2r1/text.json tests/fixtures/weixin_v2r1/quoted-text.json \
tests/fixtures/weixin_v2r1/image-with-text.json tests/fixtures/weixin_v2r1/unresolved-reference.json \
release/v2r1c0-rc1/weixin-bridge-evidence.json
git commit -m "feat(v2r1): bridge authenticated Weixin authority"
```
### Review B3
- [ ] Require exactly 15 named paths, `git diff --check`, focused/full tests, and a second patcher pass with empty diff.
- [ ] Prove Node transports evidence only; Python owns projection/authority; RuntimeWiring re-resolves.
- [ ] Prove fresh nonce on retry, terminal accepted, and legacy unreachable in `enforced`.
- [ ] Require B3 parent equals B2. Stop without commit/ledger append when evidence is unresolved.
- [ ] After all prior B3 review checks pass, append B3 and verify the recorded commit identity:
```bash
ledger="$(git rev-parse --git-path v2r1-blocker-ledger.tsv)"
printf 'B3\t%s\t%s\t%s\n' "$(git rev-parse HEAD)" "$(git rev-parse HEAD^)" "$(git rev-parse HEAD^{tree})" >>"$ledger"
IFS=$'\t' read -r label sha parent tree < <(tail -n 1 "$ledger")
test "$label" = B3
test "$sha" = "$(git rev-parse HEAD)"
test "$parent" = "$(git rev-parse HEAD^)"
test "$tree" = "$(git rev-parse HEAD^{tree})"
```
---
## Task B4: Connect the durable outbox to the verified Hermes HTTP router
**Evidence gate before code:** create `release/v2r1c0-rc1/hermes-http-contract-evidence.json` from live listener/unit/source/schema inspection and `release/v2r1c0-rc1/weixin-outbound-binding-evidence.json` from raw protocol evidence. Hermes evidence resolves schemas, `aios-hermes-router.service`, source digest, exact 140-second timeout, tenant/user ownership, and repeated-`route_id` behavior as idempotent/non-idempotent. Outbound evidence must prove exact provider echo `client_id -> message_id`. Current ACK `ret/errmsg` plus reference `ref_msg.message_item.msg_id/ref_msg.message_item.create_time_ms` is `EVIDENCE_REQUIRED` and hard-stops B4; timestamp cannot unlock it.
**Files only after both gates pass:**
- Create: `active-runtime/app/aios_v2r1/outbox.py`
- Create: `active-runtime/app/aios_v2r1/hermes_http.py`
- Create: `active-runtime/app/aios_v2r1/hermes_worker.py`
- Create: `active-runtime/app/aios_v2r1/outbound_receipt.py`
- Modify: `active-runtime/app/aios_v2r1/ingress_service.py`
- Modify: `active-runtime/app/aios_v2r1/message_ledger.py`
- Create: `tests/v2r1/test_outbox.py`
- Create: `tests/v2r1/test_hermes_http.py`
- Create: `tests/v2r1/test_outbound_receipt.py`
- Create: `tests/integration/test_v2r1_delivery_flow.py`
- Modify: `fixes/patch_openclaw_weixin_outbound_quotes.py`
- Create: `fixes/patch_openclaw_weixin_outbound_receipts.py`
- Create: `openclaw/plugins/aios-tenant-boundary/dist/receipt-client.js`
- Create: `openclaw/plugins/aios-tenant-boundary/dist/outbound-receipt-journal.js`
- Create: `openclaw/plugins/aios-tenant-boundary/test/receipt-client.test.js`
- Modify: `tests/test_weixin_outbound_quote_timestamp.py`
- Create: `tests/test_weixin_outbound_receipt_boundary.py`
- Create: both evidence JSON files named above
**Exact contract:** readiness `GET http://127.0.0.1:18791/health/ready`; dispatch `POST http://127.0.0.1:18791/internal/hermes/route`; downstream 140 seconds; worker deadline 150 seconds; 120-second direct timeout forbidden. States `pending|leased|delivered|delivery_unknown|dead`. No exactly-once. Unknown idempotency stops; known false prevents ambiguous retry; known true retries same `route_id`/digest. After B4 evidence, the actual Node Weixin sender receives a separate `outbound-receipt-write` `LoadCredential=` and uses the same one POST signing contract for `/v1/outbound-receipts`: exact five-field `unsigned_body`, exact `{method,path,body}` signing object, canonical JSON, and outer lowercase 64-hex HMAC-SHA256 signature.
Node alone owns its callback journal at `$STATE_DIRECTORY/outbound-receipts`. The gateway unit supplies a gateway-specific `StateDirectory=` with `StateDirectoryMode=0700`; the directory and its mode-`0600` files are owned and readable only by that gateway's service user. The send-boundary patch persists one sanitized per-client-ID JSON entry using a safe fixed-width digest filename: same-directory temporary file, file `fsync`, atomic rename to `.pending`, then directory `fsync`. Records contain only bounded IDs, send-success, attempt count, and next-attempt metadata—no bodies, quotes, attachments, signatures, credentials, key material, or provider payloads. Startup recovery and a background worker perform bounded authenticated callback retries without invoking Weixin send again. A pending record is deleted and the directory `fsync`ed only after an authenticated application ACK. Python owns receipt/outbox/worker storage and has no second send journal. Missing exact binding records `EVIDENCE_REQUIRED`, null provider ID, hard stop; `Date.now()` and local `_ms` fields remain telemetry only.
- [ ] Rehydrate the stable ledger and require latest label `B3` before evidence collection.
- [ ] Execute evidence collectors first. Current expected result is `EVIDENCE_REQUIRED`; stop with no runtime edit, B4 commit, or ledger append.
- [ ] After future exact binding acceptance, add RED tests for replay/collision/lease/unknown/dead, HTTP schema, 140/150 budgets, idempotency branches, the exact receipt signing role, exact provider mapping, ledger quote, `$STATE_DIRECTORY/outbound-receipts` resolution, directory/file ownership and `0700`/`0600` modes, file/directory `fsync`, atomic `.pending` rename, startup/background bounded retry, no-body/no-secret persistence, no content resend, and authenticated-ACK-only delete plus directory `fsync`.
- [ ] Run initial RED:
```bash
PYTHONDONTWRITEBYTECODE=1 PYTEST_ADDOPTS='-p no:cacheprovider' PYTHONPATH='active-runtime:active-runtime/app' \
python3 -m pytest tests/v2r1/test_outbox.py tests/v2r1/test_hermes_http.py \
tests/v2r1/test_outbound_receipt.py tests/integration/test_v2r1_delivery_flow.py \
tests/test_weixin_outbound_quote_timestamp.py tests/test_weixin_outbound_receipt_boundary.py -q
node --test openclaw/plugins/aios-tenant-boundary/test/receipt-client.test.js
```
Expected RED after evidence acceptance: modules absent.
- [ ] Implement outbox, bounded HTTP adapter/worker, authenticated callback client, Node-owned durable callback journal, actual Weixin send-boundary patch, role-authenticated receipt route, exact Python receipt store, and outbound ledger append only after exact binding. Run Python, Node, both patchers twice for byte-idempotence, actual send-boundary, full, and loader tests; expected GREEN, with callback failure journaled without resend, timeout ambiguity `delivery_unknown`, and no exactly-once assertion.
- [ ] Commit exactly 19 paths:
```bash
git add active-runtime/app/aios_v2r1/outbox.py active-runtime/app/aios_v2r1/hermes_http.py \
active-runtime/app/aios_v2r1/hermes_worker.py active-runtime/app/aios_v2r1/outbound_receipt.py \
active-runtime/app/aios_v2r1/ingress_service.py active-runtime/app/aios_v2r1/message_ledger.py \
tests/v2r1/test_outbox.py tests/v2r1/test_hermes_http.py tests/v2r1/test_outbound_receipt.py \
tests/integration/test_v2r1_delivery_flow.py fixes/patch_openclaw_weixin_outbound_quotes.py \
fixes/patch_openclaw_weixin_outbound_receipts.py openclaw/plugins/aios-tenant-boundary/dist/receipt-client.js \
openclaw/plugins/aios-tenant-boundary/dist/outbound-receipt-journal.js \
openclaw/plugins/aios-tenant-boundary/test/receipt-client.test.js tests/test_weixin_outbound_quote_timestamp.py \
tests/test_weixin_outbound_receipt_boundary.py release/v2r1c0-rc1/hermes-http-contract-evidence.json \
release/v2r1c0-rc1/weixin-outbound-binding-evidence.json
git commit -m "feat(v2r1): dispatch through evidenced Hermes outbox"
```
### Review B4
- [ ] Require exactly 19 paths, accepted evidence digests, `git diff --check`, Python/Node/patcher/send-boundary/full/loader tests.
- [ ] Prove 150 exceeds exact 140 and no 120-second production deadline remains.
- [ ] Search changed files for exactly-once claims and timestamp/approximate binding; require none.
- [ ] Require B4 parent equals B3. If either evidence remains unresolved, correct result is STOP with no commit.
- [ ] After all prior B4 review checks pass, append B4 and verify the recorded commit identity:
```bash
ledger="$(git rev-parse --git-path v2r1-blocker-ledger.tsv)"
printf 'B4\t%s\t%s\t%s\n' "$(git rev-parse HEAD)" "$(git rev-parse HEAD^)" "$(git rev-parse HEAD^{tree})" >>"$ledger"
IFS=$'\t' read -r label sha parent tree < <(tail -n 1 "$ledger")
test "$label" = B4
test "$sha" = "$(git rev-parse HEAD)"
test "$parent" = "$(git rev-parse HEAD^)"
test "$tree" = "$(git rev-parse HEAD^{tree})"
```
---
## Task B5: Build digest-bound isolated candidate staging and rollback
**Evidence gate before code:** accepted B3/B4 ledger entries. `capture_live_preflight.py` creates one evidence file containing a reconciled Gitea branch-consistency sentinel (`status=healthy`, `drift_count=0`, refs matching candidate) and the V1R4C6 live snapshot (host identity, capture time, Git SHA, units, commands, listeners, OpenClaw version, config/plugin digests, credential metadata, state/database paths, mounts, active artifact, rollback target). `release_ctl.py --preflight-file` validates the schema, exact current host, sentinel, all required fields, and freshness no older than 300 seconds. Stop if either record is missing, stale, host-mismatched, malformed, or inconsistent before staging or promotion.
**Files:**
- Create: `deploy/v2r1/release_ctl.py`
- Create: `deploy/v2r1/capture_live_preflight.py`
- Modify: `deploy/v2r1/install.sh`, `deploy/v2r1/validate.sh`, `deploy/v2r1/healthcheck.sh`
- Create: `deploy/v2r1/rollback.sh`
- Create: `deploy/v2r1/aios-v2r1-candidate-gateway.service`
- Create: `deploy/v2r1/aios-v2r1-production-gateway.service`
- Create: `deploy/v2r1/aios-v2r1-ingress.service`
- Create: `deploy/v2r1/aios-v2r1-hermes-worker.service`
- Create: `deploy/v2r1/openclaw-candidate.json5`
- Create: `release/v2r1c0-rc1/FLEET_UNSUPPORTED`
- Create: `tests/deploy/test_v2r1_release_contract.py`
- Modify: `release/v2r1c0-rc1/RELEASE.md`
**Exact contract:** `python3 deploy/v2r1/release_ctl.py` exposes only `build`, `stage`, `verify-stage`, `promote`, `status`, and `rollback`. Forward states are exactly `BUILT -> STAGED -> STAGE_VERIFIED -> PRODUCTION`; rollback states are `ROLLBACK_STARTED -> ROLLED_BACK|ROLLBACK_FAILED`. Forward commands accept `--preflight-file`; callers supply no artifact, reconciled-sentinel, or runtime-snapshot digest strings. The controller validates the evidence and computes/persists all three digests internally. `verify-stage` and `promote` each recapture live evidence with the collector, revalidate it, recompute the triplet, and reject any drift from the recorded candidate state. Deterministic same bytes stage/prod; isolated users/state/config/ports/credentials; `LoadCredential=` least-role delivery; linked candidate plugin with runtime inspect, `allowConversationAccess=true`, config validate, doctor, inspect; `NOOP_DIGEST_MATCH` zero mutation/controller calls. Candidate and production gateway units use distinct dedicated users and distinct `StateDirectory=` values (`aios-v2r1-candidate-gateway` versus `aios-v2r1-production-gateway`), both set `StateDirectoryMode=0700`, and resolve journals only below their own `$STATE_DIRECTORY/outbound-receipts`; sharing, symlinking, or bind-mounting journal state is forbidden. The current root-run production gateway remains root-owned only until its audited migration; candidate staging never reads it, and migrated mode-`0600` records become readable only by the production gateway user. STOP blocks forward actions but permits only `rollback --rollback-only --to-previous-recorded --reason-file "$reason_file"`; exact prior digest/snapshot, no forward migration, cannot remove STOP, audited.
- [ ] Rehydrate the stable ledger and require latest label `B4` before live evidence capture.
- [ ] Add RED release tests for deterministic builds, dirty/secret rejection, rejection of caller digest arguments, preflight schema/required fields/exact-host/sentinel/freshness-at-300-seconds, tamper and recapture drift, isolated config, loader sequence, unit credentials, candidate/production service-user ownership, exact distinct `StateDirectory=` names and paths, `StateDirectoryMode=0700`, journal file mode `0600`, no shared/symlink/bind-mounted journal state, root-production transition only during audited migration, no-op, STOP forward block, constrained rollback, readiness timeout, and prior integrity.
- [ ] Run initial RED:
```bash
PYTHONDONTWRITEBYTECODE=1 PYTEST_ADDOPTS='-p no:cacheprovider' python3 -m pytest tests/deploy/test_v2r1_release_contract.py -q
```
Expected RED: driver, collector, units/config, rollback wrapper, lifecycle absent.
- [ ] Implement deterministic artifact and the executable state machine; wrappers never switch branches/create tags/edit baseline/version/suppress errors. Exercise exact commands:
```bash
preflight_file="$(mktemp)"
python3 deploy/v2r1/capture_live_preflight.py --output "$preflight_file"
python3 deploy/v2r1/release_ctl.py build --source "$source_sha" --output "$build_dir" --preflight-file "$preflight_file"
python3 deploy/v2r1/release_ctl.py stage --artifact "$artifact" --preflight-file "$preflight_file" --root "$release_root"
python3 deploy/v2r1/release_ctl.py verify-stage --preflight-file "$preflight_file" --root "$release_root"
python3 deploy/v2r1/release_ctl.py promote --preflight-file "$preflight_file" --root "$release_root"
python3 deploy/v2r1/release_ctl.py status --root "$release_root"
python3 deploy/v2r1/release_ctl.py rollback --root "$release_root" --rollback-only --to-previous-recorded --reason-file "$reason_file"
```
Expected GREEN: the collector creates the evidence file; the controller accepts no caller digest strings, validates it, and computes the artifact/sentinel/runtime triplet internally. State records advance only through `BUILT,STAGED,STAGE_VERIFIED,PRODUCTION`; `verify-stage` and `promote` recapture current live state and require the same triplet; rollback records `ROLLBACK_STARTED` then `ROLLED_BACK` or `ROLLBACK_FAILED`.
- [ ] Execute candidate loader in isolated environment:
```bash
tmp="$(mktemp -d)"
candidate_release="$tmp/release/current"
export OPENCLAW_STATE_DIR="$tmp/openclaw-state"
export OPENCLAW_CONFIG_PATH="$tmp/openclaw-state/openclaw.json"
mkdir -p "$OPENCLAW_STATE_DIR"
openclaw plugins install --link "$candidate_release/openclaw/plugins/aios-tenant-boundary"
openclaw plugins inspect aios-tenant-boundary --runtime --json >"$tmp/plugin-before.json"
openclaw config set plugins.entries.aios-tenant-boundary.hooks.allowConversationAccess true --strict-json
openclaw config validate
set +e
openclaw doctor --lint --json >"$tmp/doctor-lint.json"
doctor_rc=$?
set -e
test "$doctor_rc" = 0 || test "$doctor_rc" = 1
python3 - "$tmp/doctor-lint.json" <<'PY'
import json, sys
data = json.load(open(sys.argv[1]))
def walk(value):
if isinstance(value, dict):
yield value
for child in value.values(): yield from walk(child)
elif isinstance(value, list):
for child in value: yield from walk(child)
bad = [item for item in walk(data) if str(item.get("severity", "")).lower() in {"error", "fatal"} and "plugin" in json.dumps(item).lower()]
assert not bad, bad
PY
openclaw doctor --post-upgrade --json >"$tmp/doctor-plugin.json"
openclaw plugins inspect aios-tenant-boundary --runtime --json >"$tmp/plugin-after.json"
```
Expected GREEN: plugin path inside candidate; config validation and post-upgrade doctor exit 0; lint doctor exits 0 or 1, warnings are allowed, and parsed JSON has zero error/fatal plugin findings; final inspect proves three hooks/permission; active V1R4C6 paths are absent.
- [ ] Run release/full Python/Node/loader tests, two byte-identical builds, and refreshed sentinel/snapshot comparisons. Expected GREEN: all pass.
- [ ] Commit exactly 14 paths and append ledger:
```bash
git add deploy/v2r1/release_ctl.py deploy/v2r1/capture_live_preflight.py deploy/v2r1/install.sh \
deploy/v2r1/validate.sh deploy/v2r1/healthcheck.sh deploy/v2r1/rollback.sh \
deploy/v2r1/aios-v2r1-candidate-gateway.service deploy/v2r1/aios-v2r1-production-gateway.service \
deploy/v2r1/aios-v2r1-ingress.service \
deploy/v2r1/aios-v2r1-hermes-worker.service deploy/v2r1/openclaw-candidate.json5 \
release/v2r1c0-rc1/FLEET_UNSUPPORTED tests/deploy/test_v2r1_release_contract.py release/v2r1c0-rc1/RELEASE.md
git commit -m "deploy(v2r1): bind candidate release to live evidence"
```
### Review B5
- [ ] Require exactly 14 paths, no live secret/snapshot payload in Git, `git diff --check`, complete tests, two identical builds.
- [ ] Inspect both gateway units and acceptance-test distinct service users, distinct `StateDirectory=` names/absolute paths, `StateDirectoryMode=0700`, mode-`0600` journal files, and no shared, symlinked, or bind-mounted receipt journal.
- [ ] Prove candidate paths never resolve active V1R4C6; STOP blocks forward while rollback selects only recorded previous state and cannot remove STOP.
- [ ] Prove staging validates the collector-created file and `verify-stage`/`promote` each recapture current live sentinel/runtime state, compute digests internally, and compare the same triplet; prove no caller-supplied digest option exists.
- [ ] Require B5 parent equals B4.
- [ ] After all prior B5 review checks pass, append B5 and verify the recorded commit identity:
```bash
ledger="$(git rev-parse --git-path v2r1-blocker-ledger.tsv)"
printf 'B5\t%s\t%s\t%s\n' "$(git rev-parse HEAD)" "$(git rev-parse HEAD^)" "$(git rev-parse HEAD^{tree})" >>"$ledger"
IFS=$'\t' read -r label sha parent tree < <(tail -n 1 "$ledger")
test "$label" = B5
test "$sha" = "$(git rev-parse HEAD)"
test "$parent" = "$(git rev-parse HEAD^)"
test "$tree" = "$(git rev-parse HEAD^{tree})"
```
## Final five-commit verification
Run only after all evidence/task reviews pass:
```bash
ledger="$(git rev-parse --git-path v2r1-blocker-ledger.tsv)"
python3 - "$ledger" <<'PY'
from pathlib import Path
import subprocess, sys
rows = [line.split("\t") for line in Path(sys.argv[1]).read_text().splitlines()]
assert [row[0] for row in rows] == ["START", "B1", "B2", "B3", "B4", "B5"]
for i in range(1, len(rows)):
label, sha, recorded_parent, recorded_tree = rows[i]
parent = subprocess.check_output(["git", "rev-parse", sha + "^"], text=True).strip()
tree = subprocess.check_output(["git", "rev-parse", sha + "^{tree}"], text=True).strip()
assert parent == rows[i - 1][1]
assert parent == recorded_parent and tree == recorded_tree, label
start, head = rows[0][1], rows[-1][1]
assert subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() == head
assert subprocess.check_output(["git", "rev-list", "--count", f"{start}..{head}"], text=True).strip() == "5"
assert not subprocess.check_output(["git", "rev-list", "--merges", f"{start}..{head}"], text=True).strip()
PY
test -z "$(git status --porcelain)"
PYTHONDONTWRITEBYTECODE=1 PYTEST_ADDOPTS='-p no:cacheprovider' PYTHONPATH='active-runtime:active-runtime/app' python3 -m pytest --collect-only -q
PYTHONDONTWRITEBYTECODE=1 PYTEST_ADDOPTS='-p no:cacheprovider' PYTHONPATH='active-runtime:active-runtime/app' python3 -m pytest -q
(cd openclaw/plugins/aios-tenant-boundary && npm test)
```
Expected: labels exactly `START,B1,B2,B3,B4,B5`; current HEAD/parent/tree match recorded values; `START..HEAD` has exactly five commits and no merge; clean tree; full collect/full Python/Node/loader/release tests pass.
Review authority roles, status spelling, timeout units, routes, response bounds, terminal semantics, evidence states, types, and sentinel behavior. Scan unresolved markers with split literals:
```bash
marker_pattern="$(printf '%s' 'TO''DO|T''BD|FIX''ME|CHANGE''_ME|INSERT''_')"
git grep -nE "$marker_pattern" -- active-runtime deploy/v2r1 openclaw/plugins/aios-tenant-boundary release/v2r1c0-rc1 tests
```
Expected: no matches. Any contradiction, type mismatch, unresolved evidence, extra blocker commit, or missing review stops release preparation.
## Appendix A: Exact legacy `active_runtime.app` import files
`APPENDIX_A_BEGIN`
- tests/test_agent_event_bus.py
- tests/test_agent_observability_loop.py
- tests/test_architect_governance.py
- tests/test_architect_research_timeline.py
- tests/test_architecture_change_classifier.py
- tests/test_architecture_owner_gate.py
- tests/test_architect_weekly_research.py
- tests/test_change_governance.py
- tests/test_governance_event_bridge.py
- tests/test_governance_timeline.py
- tests/test_search_agent_adapter.py
- tests/test_search_architect_integration.py
- tests/test_search_gateway_dashboard.py
- tests/test_search_gateway_general_capability.py
- tests/test_search_gateway_orchestrator.py
- tests/test_search_gateway.py
- tests/test_search_provider_registry.py
- tests/test_task_center.py
- tests/test_task_dashboard_view.py
- tests/test_task_event_and_statistics.py
- tests/test_task_lifecycle.py
- tests/test_task_orchestrator.py
- tests/test_v1r4c1_risk_fixes.py
`APPENDIX_A_END`
@@ -1,188 +0,0 @@
# V2R1 R10 personal Weixin release-plan amendment
Status: reviewed design-only amendment. It adds D1, B6, and B7 to the R9 linear release plan. It does not authorize runtime, candidate, production, baseline, tag, or Fleet mutation.
Authority remains `docs/superpowers/plans/2026-07-14-v2r1-runtime-release-plan.md` at B2 tip `68d27879103990aac692207a8512b3725c6d0d41`. R9 `START` is `a9a0b4ae396e33c9bfc35a5f5694e5e354c3a47c`; accepted B1 is `57103c11d21192a50febe846d30cee6fc5da4ac1`; accepted B2 is `68d27879103990aac692207a8512b3725c6d0d41`. Their trees and path sets are immutable. This amendment does not relabel, squash, reorder, or absorb any of the original five blockers.
## Exact linear ledger
The only valid ancestry is:
```text
START -> B1 -> B2 -> D1 -> B3 -> B4 -> B5 -> B6 -> B7
```
`D1` is a non-blocker design dependency whose parent is exactly B2. `B3` parent is D1; every later blocker parent is the immediately preceding row. `START..B7` contains exactly eight non-merge commits: B1, B2, D1, B3, B4, B5, B6, B7. B1-B5 remain five distinct original blocker commits; B6 and B7 are distinct new blocker commits. No cherry-pick collapse, squash, fixup, merge commit, history rewrite, or force push is allowed.
B3/B4 retain every R9 evidence gate. In particular, unresolved `EVIDENCE_REQUIRED` for the exact provider `client_id -> message_id` binding stops B4 with no implementation, commit, or ledger append. B5 can land only after accepted B4. Even after B5 lands, every forward `stage`, `verify-stage`, or `promote` operation remains blocked until accepted B6 and B7 commits exist and all gates below pass.
## D1 design-only commit
D1 changes exactly these 23 paths and no others:
```text
docs/superpowers/plans/2026-07-15-v2r1-personal-weixin-r10-release-plan-amendment.md
contracts/personal-weixin/v1/SECURITY.md
contracts/personal-weixin/v1/golden-contract.schema.json
contracts/personal-weixin/v1/fixtures/01-tenant-account-entitlement.json
contracts/personal-weixin/v1/fixtures/02-readiness-ready.json
contracts/personal-weixin/v1/fixtures/03-readiness-field-missing.json
contracts/personal-weixin/v1/fixtures/04-readiness-stale.json
contracts/personal-weixin/v1/fixtures/05-readiness-plugin-digest-mismatch.json
contracts/personal-weixin/v1/fixtures/06-readiness-account-not-entitled.json
contracts/personal-weixin/v1/fixtures/07-readiness-account-not-running.json
contracts/personal-weixin/v1/fixtures/08-readiness-error.json
contracts/personal-weixin/v1/fixtures/09-web-login-start-request.json
contracts/personal-weixin/v1/fixtures/10-web-login-wait-request.json
contracts/personal-weixin/v1/fixtures/11-qr-pending.json
contracts/personal-weixin/v1/fixtures/12-need-verify-code.json
contracts/personal-weixin/v1/fixtures/13-invalid-verify-code.json
contracts/personal-weixin/v1/fixtures/14-expired.json
contracts/personal-weixin/v1/fixtures/15-failed.json
contracts/personal-weixin/v1/fixtures/16-already-connected.json
contracts/personal-weixin/v1/fixtures/17-confirmed.json
contracts/personal-weixin/v1/fixtures/18-unknown-upstream-fail-closed.json
contracts/personal-weixin/v1/fixtures/19-authenticated-inbound-event.json
contracts/personal-weixin/v1/fixtures/20-unbind-unsupported.json
```
D1 validation requires JSON parse, Draft 2020-12 schema validation of every fixture, negative mutation tests, exact 23-path comparison, `git diff --check`, placeholder/secret scan, and confirmation that no runtime/deploy/release-active path changed.
## Preserved R9 blocker allowlists
B1 and B2 are already accepted at the immutable SHAs above. Their actual commit path sets, parent SHAs, and trees are the allowlists; compare with `git diff-tree --no-commit-id --name-only -r <sha>` and reject any mismatch. B1 has six actual paths under `openclaw/plugins/aios-tenant-boundary/`; B2 has exactly 43 actual package/import/matrix paths. No later blocker may amend them.
B3 changes exactly these 15 R9 paths:
```text
active-runtime/app/aios_v2r1/identity.py
active-runtime/app/aios_v2r1/tenant_context.py
active-runtime/app/aios_v2r1/ingress_service.py
active-runtime/app/aios_v2r1/bridge_auth.py
active-runtime/app/aios_v2r1/weixin_projection.py
fixes/patch_openclaw_weixin_v2r1_ingress.py
tests/v2r1/test_bridge_auth.py
tests/v2r1/test_weixin_projection.py
tests/integration/test_v2r1_bridge_transport.py
tests/test_weixin_v2r1_ingress_bridge.py
tests/fixtures/weixin_v2r1/text.json
tests/fixtures/weixin_v2r1/quoted-text.json
tests/fixtures/weixin_v2r1/image-with-text.json
tests/fixtures/weixin_v2r1/unresolved-reference.json
release/v2r1c0-rc1/weixin-bridge-evidence.json
```
B4 changes exactly these 19 R9 paths and remains evidence-stopped until both R9 evidence files are accepted:
```text
active-runtime/app/aios_v2r1/outbox.py
active-runtime/app/aios_v2r1/hermes_http.py
active-runtime/app/aios_v2r1/hermes_worker.py
active-runtime/app/aios_v2r1/outbound_receipt.py
active-runtime/app/aios_v2r1/ingress_service.py
active-runtime/app/aios_v2r1/message_ledger.py
tests/v2r1/test_outbox.py
tests/v2r1/test_hermes_http.py
tests/v2r1/test_outbound_receipt.py
tests/integration/test_v2r1_delivery_flow.py
fixes/patch_openclaw_weixin_outbound_quotes.py
fixes/patch_openclaw_weixin_outbound_receipts.py
openclaw/plugins/aios-tenant-boundary/dist/receipt-client.js
openclaw/plugins/aios-tenant-boundary/dist/outbound-receipt-journal.js
openclaw/plugins/aios-tenant-boundary/test/receipt-client.test.js
tests/test_weixin_outbound_quote_timestamp.py
tests/test_weixin_outbound_receipt_boundary.py
release/v2r1c0-rc1/hermes-http-contract-evidence.json
release/v2r1c0-rc1/weixin-outbound-binding-evidence.json
```
B5 changes exactly these 14 R9 paths:
```text
deploy/v2r1/release_ctl.py
deploy/v2r1/capture_live_preflight.py
deploy/v2r1/install.sh
deploy/v2r1/validate.sh
deploy/v2r1/healthcheck.sh
deploy/v2r1/rollback.sh
deploy/v2r1/aios-v2r1-candidate-gateway.service
deploy/v2r1/aios-v2r1-production-gateway.service
deploy/v2r1/aios-v2r1-ingress.service
deploy/v2r1/aios-v2r1-hermes-worker.service
deploy/v2r1/openclaw-candidate.json5
release/v2r1c0-rc1/FLEET_UNSUPPORTED
tests/deploy/test_v2r1_release_contract.py
release/v2r1c0-rc1/RELEASE.md
```
## B6: evidence-bound OpenClaw personal-Weixin candidate
B6 changes exactly these 13 paths:
```text
openclaw/candidates/personal-weixin-r10/README.md
openclaw/candidates/personal-weixin-r10/manifest.json
openclaw/candidates/personal-weixin-r10/src/golden-contract.js
openclaw/candidates/personal-weixin-r10/src/gateway-inbound-auth.js
openclaw/candidates/personal-weixin-r10/plugin/openclaw.plugin.json
openclaw/candidates/personal-weixin-r10/plugin/index.js
fixes/patch_openclaw_personal_weixin_login.py
fixes/patch_openclaw_personal_weixin_gateway_auth.py
openclaw/candidates/personal-weixin-r10/test/golden-contract.test.js
openclaw/candidates/personal-weixin-r10/test/gateway-auth.test.js
tests/personal_weixin/test_golden_fixtures.py
tests/integration/test_personal_weixin_gateway_contract.py
release/v2r1c0-rc1/personal-weixin-b6-evidence.json
```
Before B6 code, evidence records exact installed upstream source commit, every patch target path/digest, plugin loader version/digest, Gateway auth owner, credential files, and candidate-only state/config. Stop on inference or drift. Tests are RED before implementation and prove: required nonempty opaque `sessionKey`; no account fallback; exact canonical statuses and readiness shapes; unknown status fail-closed; 4-8 digit one-shot `verifyCode` with no persistence/log/response; exact event body with no authentication/tenant/user/session assertions; transport signature/skew/nonce/replay checks; exact 501 zero mutation; byte-idempotent patches; build/runtime inspect in isolated candidate state. The B6 evidence JSON is produced only after these gates pass.
## B7: tenant-scoped Portal adapter and durable challenge
B7 changes exactly these 13 paths:
```text
active-runtime/app/aios_v2r1/personal_weixin/official_login.py
active-runtime/app/aios_v2r1/personal_weixin/adapter.py
portal/personal_weixin/views.py
portal/personal_weixin/urls.py
portal/personal_weixin/services.py
portal/personal_weixin/templates/personal_weixin/bind.html
tests/personal_weixin/test_adapter.py
tests/personal_weixin/test_portal_views.py
tests/integration/test_personal_weixin_golden_flow.py
release/v2r1c0-rc1/personal-weixin-source-baseline.json
release/v2r1c0-rc1/personal-weixin-source-baseline.sha256
release/v2r1c0-rc1/personal-weixin-canary-contract.json
release/v2r1c0-rc1/personal-weixin-rollback-contract.json
```
Tests are RED before B7 implementation and prove: Django authentication/CSRF; server-session tenant resolution; browser `accountId` treated only as a preallocated-slot reference and current entitlement rechecked on every list/readiness/start/wait/status; exact `sessionKey` forwarding without fallback; live canonical readiness; authenticated Gateway transport context separate from the six-field event body; durable challenge record owning tenant/user/account/session digest; hash-only secret storage; transactional/CAS single consumption; exact replay idempotence; altered replay/concurrency/expiry/cross-tenant denial with zero mutation; no provider token/raw iLink/verify code/challenge code persistence or leakage; size/rate/no-store controls; restart; and unbind 501 before all mutations/calls/jobs.
## Canonical contract gates
`sessionKey` is nonempty and exact. Canonical results are:
```text
qr_pending connected=false alreadyConnected=false terminal=false needsVerifyCode=false
need_verifycode connected=false alreadyConnected=false terminal=false needsVerifyCode=true
invalid_verifycode connected=false alreadyConnected=false terminal=false needsVerifyCode=true
expired connected=false alreadyConnected=false terminal=true needsVerifyCode=false
failed connected=false alreadyConnected=false terminal=true needsVerifyCode=false
already_connected connected=false alreadyConnected=true terminal=true needsVerifyCode=false
confirmed connected=true alreadyConnected=false terminal=true needsVerifyCode=false
```
`binded_redirect` maps to `already_connected`. An unknown upstream status maps to canonical `failed/errorCode=unknown_upstream_status`. Readiness always uses the same complete shape: missing fields, stale evidence, plugin digest mismatch, absent entitlement, not-running account, and Gateway error all produce `ready=false`; only a live authenticated all-green snapshot produces `ready=true/code=ready`.
The inbound event body is exactly `eventId/accountId/senderId/chatId/challengeCode/observedAt`; transport authentication and server-resolved tenant/user/session context are not body fields. Fixture secrets are test inputs only. Raw `verifyCode` and `challengeCode` never enter persistence, logs, responses, URLs, analytics, traces, metrics labels, or job arguments. Raw `sessionKey` appears only where the golden login request/result contract requires it; it is never persisted or logged and is forwarded byte-exact without fallback.
## Merge, canary, promotion, and rollback gates
For each D1/B3-B7 row: require the exact parent row SHA, one non-merge commit, exact sorted path allowlist, clean `diff --check`, focused and carried tests, reviewer acceptance, then append `label/SHA/parent/tree` to the worktree-local ledger. No row may start from an unaccepted predecessor.
After B7, rebuild once from the accepted B7 tree. Stage one isolated candidate using B5 controls and no active configuration. Canary exactly one explicitly entitled `(tenant-canary, account-canary)` pair. Before traffic, require live Gateway/plugin/account readiness with expected digests and freshness. Exercise all golden outcomes, invalid and unknown fail-closed paths, restart, concurrent waits, atomic challenge consumption, exact replay, altered replay, expiry, unauthenticated event, tenant/account mismatch, cross-tenant IDOR, secret redaction, and exact end-to-end unbind 501 with zero durable/runtime/upstream/job mutation. Any missing evidence, extra account, digest drift, stale snapshot, noncanonical status, secret observation, or mutation fails the canary and blocks promotion.
Promotion accepts only the exact artifact/config/plugin/source digests verified by canary; it never rebuilds, repatches, switches branches, edits `VERSION`/`baseline/current.json`, creates a tag, invokes Fleet, or changes unrelated services. Candidate and production artifacts must be byte-identical. B5 live preflight is recaptured and reconciled immediately before `verify-stage` and `promote`.
Rollback is armed before canary with the immediately preceding production artifact/config/plugin digests, units, credentials metadata, and health contract. On any gate failure, STOP blocks forward actions; invoke only B5's recorded rollback-only path. Restore byte-identical prior artifacts/config, never the candidate rebuild; do not delete STOP automatically; do not roll back the database absent a separately approved incompatibility plan. Verify previous digests, listeners, units, secret paths/modes, canonical readiness, and tenant/account isolation. If verification fails, record `ROLLBACK_FAILED`, keep STOP, and escalate without forward mutation.
@@ -1,45 +0,0 @@
# Weixin Quoted Text Fail-Closed Design
## Goal
Prevent quoted-text questions from being answered with invented message metadata when Weixin does not provide the quoted body. Preserve normal text and confirmed image behavior.
## Evidence
- A quoted text event can arrive as `ref_msg.message_item.type=0` with only `msg_id` and timestamps.
- Session transcripts retain text but not the original Weixin numeric message ID.
- Outbound `clientId` values are not the same format as quoted numeric `msg_id` values, and the send API does not expose a server message ID.
- Therefore recent-message, timestamp, session, and outbound-client-ID matching are not exact evidence and must not be used.
## Decision
Use an evidence-only resolver in `process-message`:
1. If reference payload contains an explicit text item, extract that text and build a bounded quoted-text context for the ordinary text model path.
2. If reference context exists but has no explicit text and is not a confirmed image, return a deterministic response: `无法读取被引用文字,请复制原文后再提问。`
3. The unresolved branch is handled before ordinary model dispatch and before Hermes image dispatch.
4. Do not infer quoted text from metadata, recent messages, session order, timestamps, or approximate IDs.
No persistent text cache is introduced in this change. Outbound ID aliasing remains a separate observation task because an exact mapping has not been proved.
## Interfaces
- `extractHermesReferencedText(items): string` returns only explicit referenced text, otherwise an empty string.
- `resolveHermesQuotedTextContext(items, currentText)` returns one of:
- `{ referenced: false, handled: false, prompt: currentText, reason: "none" }`
- `{ referenced: true, handled: false, prompt, reason: "quoted_text_resolved" }`
- `{ referenced: true, handled: true, replyText: "无法读取被引用文字,请复制原文后再提问。", reason: "quoted_text_unavailable" }`
- Confirmed referenced images bypass this text resolver and keep the existing exact image path.
## Privacy And Logging
- Do not log quoted or current text.
- Log only `reason`, account ID, a one-way bounded hash of quoted ID when available, and boolean evidence flags.
- Do not persist tokens, credentials, full payloads, or message text.
## Acceptance
- Explicit quoted text is supplied to the ordinary text model with a clear delimiter.
- Type 0 reference without text returns the deterministic Chinese response and does not call the model.
- Plain text, current image, and exact quoted image behavior remain unchanged.
- Generated TS and JS remain byte-idempotent under `--process-only`.
@@ -1,294 +0,0 @@
# V2R1 Authenticated Runtime Bridge Design
**Date:** 2026-07-14
**Status:** Preflight design — B3/B4/B5 evidence-gated
**Release target:** V2R1C0 RC1
**Scope:** Executable OpenClaw policy compatibility, canonical Python packaging, authenticated Weixin loopback ingress, exact message authority, real Hermes HTTP dispatch, and digest-bound release operations.
## Decision and execution gates
Three alternatives were evaluated:
- **Alternative A — preserve the verified replay and stop at documentation.** Lowest immediate risk, but every runtime release blocker remains open.
- **Alternative B — patch only the Weixin path.** Demonstrates a message path but cannot prove plugin loading, transport authority, durable dispatch, artifact identity, or rollback.
- **Alternative C — close five independently reviewed blockers.** Prove the plugin, package one Python namespace, build the authenticated bridge, connect a durable outbox to the real Hermes HTTP router, then rehearse a digest-bound release.
Alternative C remains the selected architecture because active full-execution authorization covers implementation preparation. That authorization does not waive the following gates:
- B1 and B2 may start only after this corrected design and plan are reviewed.
- B3 stops unless the installed Weixin payload shape, patch target hashes, authority-map ownership, and transport signing boundary are evidenced.
- B4 stops unless Hermes request/response schema, router ownership, downstream timeout, idempotency behavior, and exact outbound message binding are evidenced.
- B5 stops unless B3 and B4 are accepted and the reconciled sentinel plus the V1R4C6 live-runtime snapshot are current for the candidate digest.
- The R9 D0 documentation correction becomes ledger `START` without consuming a blocker. It expands B2 executable coverage only; all authenticated transport, evidence, terminal, isolation, and release gates remain unchanged.
The present outbound evidence is insufficient, so B4 and B5 are currently stopped. This document defines the fail-closed path; it does not claim the missing evidence exists.
## Verified preflight observations
Read-only inspection on 2026-07-14 established these facts:
- OpenClaw is `2026.6.11` and exposes `plugins install --link`, `plugins inspect --runtime --json`, `config set`, `config validate`, and `doctor`.
- The OpenClaw schema requires a non-bundled plugin to opt into conversation-bearing hooks with `plugins.entries.aios-tenant-boundary.hooks.allowConversationAccess=true`.
- Without that opt-in, the tenant-boundary plugin's currently usable policy hook is `before_tool_call`; `before_agent_run` and `agent_end` cannot be treated as delivered authority.
- `aios-hermes-router.service` owns `/opt/aios-v23/shared/hermes_route_memory/hermes_router_service.py` and listens on `127.0.0.1:18791`.
- `GET http://127.0.0.1:18791/health/ready` returns router readiness and identifies the executor health URL.
- `POST http://127.0.0.1:18791/internal/hermes/route` is the router submission endpoint.
- The router forwards to the executor with an exact 140-second timeout. The existing direct Weixin helper uses 120 seconds, so it is incompatible and cannot be reused as the worker deadline.
- Router ownership and schema are inspectable, but replay/idempotency behavior for repeated `route_id` is not proved.
- A Weixin reference event exposes `ref_msg.message_item.msg_id` and `ref_msg.message_item.create_time_ms`, but not the original outbound `client_id`. The send acknowledgement exposes only `ret` and `errmsg`. No exact `client_id -> message_id` binding currently exists.
## Execution modes and terminal semantics
The gateway has exactly two modes:
- `disabled`: the V2R1 bridge performs no submission and the legacy message path may run.
- `enforced`: every eligible Weixin event is handled by the V2R1 bridge. The legacy path is prohibited.
There is no shadow, dual-send, or fall-through mode. In `enforced` mode:
- `ACCEPTED` is terminal. The Node caller must not invoke the legacy handler, Hermes directly, or a second transport path.
- A duplicate with the same canonical event and payload returns the same accepted receipt and is also terminal.
- `AUTH_FAILED`, `VALIDATION_FAILED`, `CONFLICT`, and `INTERNAL_ERROR` are terminal runtime results for that attempt. They never authorize legacy fallback. `REPLAY_REJECTED` belongs only to the authenticated HTTP transport/service boundary and is never a `RuntimeIngressResponse.status`.
- A transport timeout requires a receipt-status query. A retry is allowed only when the status is absent and must use a fresh nonce while retaining the same exact event identity.
Legacy handling is therefore reachable only when configuration explicitly says `disabled`, never as error recovery.
## Architecture and data flow
```text
Authenticated Weixin event in isolated OpenClaw candidate gateway
-> Node bridge client reads bridge credential from CREDENTIALS_DIRECTORY
-> Node signs the exact method/path/unsigned_body object and POSTs the outer wrapper to 127.0.0.1
-> Python ingress service authenticates freshness, nonce, key role, and replay
-> server-side WeixinProjection parses exact protocol evidence
-> server-side root-owned authority map supplies tenant/cell/principal/session
-> Python signs an internal RuntimeWiring request with a separate key role
-> RuntimeWiring.ingest -> TenantRouter -> MessageLedger -> SessionPool
-> ingress receipt reaches terminal ACCEPTED
-> HermesOutbox -> dedicated Hermes worker
-> HermesHttpAdapter -> 127.0.0.1:18791/internal/hermes/route
-> Weixin send boundary -> outbound receipt evidence
```
Node never constructs `CanonicalMessage`, never opens the authority map, and never supplies tenant or cell authority. Python never trusts route claims from the raw Weixin object. The root-owned authority map is keyed by authenticated account, sender, and conversation evidence; its result is still re-resolved through `TenantRouter` before mutation.
## One POST signing contract
Every authenticated POST route uses exactly one wire shape; there is no `auth`, authorization header, nested signature, or route-specific signing variant:
```json
{"unsigned_body":{"schema_version":"aios.signed-request.v1","key_id":"bridge-current","issued_at_ms":1784000000000,"nonce":"base64url-16-byte-value","route_payload":{}},"signature":"lowercase-64-hex"}
```
`unsigned_body` contains exactly `schema_version`, `key_id`, `issued_at_ms`, `nonce`, and `route_payload`. `route_payload` is the route-specific content: ingress account/sender/conversation/raw event, receipt-status account plus one identifier, or outbound receipt. The signer constructs exactly:
```json
{"method":"POST","path":"<exact route>","body":{"schema_version":"aios.signed-request.v1","key_id":"bridge-current","issued_at_ms":1784000000000,"nonce":"base64url-16-byte-value","route_payload":{}}}
```
The signing input is canonical JSON encoded as UTF-8, with object keys recursively sorted by Unicode code point, `,` and `:` separators, no insignificant whitespace, no ASCII escaping of Unicode, no floats, and protocol IDs represented as strings. `signature` is lowercase 64-hex HMAC-SHA256 of those bytes. It is outside `unsigned_body` and outside the signing input. Verification reconstructs the same object using literal method `POST` and the exact request path; no normalized, decoded, prefix-matched, or caller-declared path is accepted.
Transport authentication is fail-closed:
- accepted clock skew is at most 30 seconds in either direction;
- `(key_id, nonce)` is retained in a durable replay cache for five minutes;
- a nonce decoding to any length other than 16 bytes is rejected;
- a repeated nonce is `REPLAY_REJECTED`, even when payload and signature are identical;
- every retry creates a cryptographically fresh nonce and `issued_at_ms` while preserving the exact event/message identity used for idempotency;
- the keyring contains one active key and at most one previous key; `key_id` selects the key and unknown IDs fail;
- rotation first installs the new verifier key, then switches the Node signer, then removes the old key only after the five-minute replay window and maximum request lifetime have elapsed.
The Node candidate gateway receives the bridge-ingress and receipt-read keyrings through separate systemd `LoadCredential=` entries below `$CREDENTIALS_DIRECTORY`. It never receives the Python internal signing key or authority map. Only after B4 evidence is accepted, the Node Weixin sender receives a third, separate `outbound-receipt-write` credential for authenticated callbacks.
## Service routes and bounded responses
The Python service binds loopback only and exposes exactly:
- `GET /health/live`
- `GET /health/ready`
- `POST /v1/ingress`
- `POST /v1/receipts/status`
- `POST /v1/outbound-receipts`
`POST /v1/ingress` requires the `bridge-ingress` HMAC role. `POST /v1/receipts/status` requires `receipt-read`; its `route_payload` contains `account_id` plus exactly one of `event_id` or `receipt_id`. Both or neither is `VALIDATION_FAILED`. Resolution is restricted to the same account and event scope, and unknown/cross-account identifiers return the same bounded result. `POST /v1/outbound-receipts` requires `outbound-receipt-write`; no receipt role can submit ingress. Every route uses the one POST signing contract above and the same nonce, skew, replay, rotation, and fresh-retry rules.
Request bodies are at most 1 MiB. Any JSON response is at most 4096 bytes and contains only stable codes and bounded identifiers. The ingress response fields are `status`, `event_id`, `message_id`, and `receipt_id`. The receipt-status response fields are `status`, `receipt_id`, `event_id`, `updated_at`, `outbox_status`, `outbound_status`, and `evidence_state`. No response contains message bodies, quote text, attachments, route objects, paths, signatures, keys, provider payloads, or exception text.
Runtime-to-HTTP status mapping is exact:
| Stable status | HTTP | Retry rule |
|---|---:|---|
| `ACCEPTED` | 202 | Terminal; query receipt after transport uncertainty |
| `AUTH_FAILED` | 401 | No retry until credential or authority changes |
| `REPLAY_REJECTED` | 409 | Transport/service only; retry same event with a fresh nonce |
| `VALIDATION_FAILED` | 400 | No retry |
| `CONFLICT` | 409 | Operator review |
| `INTERNAL_ERROR` | 503 | Query receipt first; identical event may retry with fresh nonce |
## Canonical Python package
The release package has one V2R1 import identity: `aios_v2r1`. There is no `active_runtime` compatibility shim, alias package, `sys.modules` alias, or copied source tree.
`active-runtime/pyproject.toml` packages `aios_v2r1` from `active-runtime/app`. Repository tests use `PYTHONPATH='active-runtime:active-runtime/app'`: legacy modules import as `app.*`, while V2R1 imports as `aios_v2r1.*`. The implementation plan enumerates all 23 known `active_runtime.app` test imports and replaces them explicitly. Full collection, full pytest, and wheel inspection must prove the wheel contains one V2R1 namespace and that source and installed imports resolve to `aios_v2r1` only.
Packaging acceptance is also the executable completeness gate for seven preserved runtime components. Message Ledger must prove exact-ID/no-fallback semantics, idempotency/conflict, tenant/cell/direction isolation, expiry/purge/restart/concurrency, and sanitized SQLite errors. Memory must prove every namespace, cross-scope denial, shared-artifact read-only behavior, qualified-key traversal rejection, and disabled search. Secret must prove reference, broker path, scope, kind, version, identifier, no-value-leakage, and fail-closed broker behavior. Audit must prove genesis and tamper/delete/reorder detection, Unicode canonicalization, concurrency, restart, validation, tenant isolation, and sanitized SQLite errors. Hermes Profile must prove exact environment, absolute `0700` roots, traversal/symlink denial, scope, idempotence, and no environment inheritance. Cell Manager/CLI must prove one cell per tenant, an explicit state graph, atomic concurrent persistence, corrupt-registry fail-closed behavior, stable JSON/exit codes, and restart. Fabric must prove that claims return `running` consistently with durable state, isolation, one concurrent claimant, non-claimable states, restart, and sanitized SQLite errors.
The original 29 B2 paths are extended by exactly fourteen reviewed paths: seven production modules (`message_ledger.py`, `memory_scope.py`, `secret_scope.py`, `audit.py`, `hermes_profile.py`, `cell_manager.py`, and `hermes_fabric.py`) and seven matrix tests (`test_message_ledger_append.py`, `test_memory_scope.py`, `test_secret_scope.py`, `test_audit.py`, `test_hermes_profile.py`, `test_cell_manager.py`, and `test_hermes_fabric.py`). `cli.py` remains one of the original package paths. A production module changes only after its focused test has been observed failing for the intended contract; already-correct behavior is a characterization and must not be weakened merely to manufacture a RED.
The B2 review requires exactly 43 changed paths, at least 160 passing carried V2R1/integration/package tests with every matrix bullet represented, zero full-collection errors, a passing complete pytest suite, the 15 Node tests, the real isolated loader in default and opt-in modes, compileall, wheel identity, and a clean tree. These coverage additions do not authorize B3 bridge, B4 Hermes HTTP/outbound, B5 deployment, active service, main, tag, `VERSION`, or baseline mutation.
## Exact projection, quote, and attachment authority
`WeixinProjection` runs in Python after transport authentication. It preserves numeric Weixin IDs from the JSON token as decimal strings and rejects a value that has already passed through unsafe JavaScript-number conversion.
- `event_id` is deterministic from authenticated account, sender, conversation, direction, and exact protocol event ID.
- `message_id` is an exact provider/server message ID when supplied.
- `reply_to_message_id` comes only from an explicit reference ID.
- `quoted_body` comes only from explicit referenced text or from `MessageLedger.resolve_quote()` using the fully scoped exact message ID.
- Timestamp, nearest-message order, recent-message cache, numeric approximation, prefix, similarity, and local client ID are never quote authority.
`CanonicalAttachment` contains `attachment_id`, `kind`, `media_type`, `sha256`, and `size_bytes`. Attachment bytes are accepted only when exact protocol identity and digest/size verification both pass. A reference without exact media evidence cannot fall back to the latest image.
`Date.now()` is local send telemetry only. It may populate a local observation timestamp but cannot populate a provider timestamp, resolve a quote, correlate a client ID, or select a message.
## Outbound evidence and hard stop
The current Weixin protocol evidence cannot bind an outbound client ID to a later reference:
- send acknowledgement: `ret` and `errmsg` only;
- later reference: `ref_msg.message_item.msg_id` and `ref_msg.message_item.create_time_ms`;
- absent evidence: the original outbound `client_id` echoed beside the provider `message_id`.
Therefore outbound quote authority is unavailable. A successful send may be recorded as transport success, but `OutboundReceipt.evidence_state` must be `EVIDENCE_REQUIRED`, and the release gate must hard STOP. `sent` does not mean `confirmed`, and neither timestamp equality nor proximity may upgrade it.
The only acceptable future unlock is verified provider/reference evidence that echoes or otherwise exactly binds `client_id -> message_id`. That evidence must include the raw protocol fields, parser test, collision test, and a durable mapping scoped by tenant, cell, account, peer, direction, and exact IDs. Until then no outbound record becomes quote authority.
`OutboundReceipt` has stable fields:
```text
receipt_id, outbox_id, tenant_id, cell_id, channel, channel_account_id,
peer_id, client_message_id, provider_message_id, payload_sha256,
status = sent | confirmed | failed,
evidence_state = EXACT | EVIDENCE_REQUIRED
```
`provider_message_id` is null while evidence is unavailable. `POST /v1/outbound-receipts` rejects any attempt to synthesize it from `Date.now()`, `ref_msg.message_item.create_time_ms`, message order, or approximate matching. Every local timestamp field is explicitly suffixed `_ms` and remains telemetry.
After B4 evidence is accepted, the actual Node Weixin sender owns its outbound callback retry journal at `$STATE_DIRECTORY/outbound-receipts`. Its systemd unit declares a gateway-specific `StateDirectory=` and `StateDirectoryMode=0700`; systemd owns that directory to the unit's `User=`/`Group=`, and no other gateway service user may read it. The sender receives its separate `outbound-receipt-write` `LoadCredential=` entry only at that gate. Each journal entry is one sanitized JSON record per client ID, named by a safe fixed-width digest of that ID. It contains only bounded identifiers, send-success state, callback-attempt count, and next-attempt metadata—never message bodies, quote text, attachments, signatures, key material, credentials, or provider payloads.
Persistence is exact: create a same-directory temporary file with mode `0600`, owned and readable only by that gateway's service user, write the sanitized record, `fsync` the file, atomically rename it to its final `.pending` name, then `fsync` the directory. Startup recovery and a background worker retry authenticated callbacks with bounded attempts and backoff; they never call the Weixin send operation again. A pending record is deleted, followed by directory `fsync`, only after the Python callback returns an authenticated application ACK. Transport uncertainty, non-ACK responses, and callback authentication failures retain the record for bounded retry.
Ownership is deliberately split. Node owns this send-success/callback-retry journal. Python owns the authenticated receipt endpoint, receipt store, outbox, and Hermes worker state. Python does not create or own a second send journal, so there is only one durable resend boundary.
## Real Hermes HTTP adapter and outbox semantics
The runtime does not claim that the local `HermesFabric` SQLite primitive is the production Hermes transport. Production dispatch uses:
- readiness: `GET http://127.0.0.1:18791/health/ready`;
- submission: `POST http://127.0.0.1:18791/internal/hermes/route`;
- owner: `aios-hermes-router.service`;
- downstream executor budget: exactly 140 seconds;
- worker HTTP deadline: 150 seconds, so the known 120-second direct-bridge mismatch is removed.
`HermesHttpAdapter` validates bounded request and response schemas before accepting a result. `HermesOutbox` provides durable intent and leases, but the system makes no exactly-once claim. Exact local states are `pending`, `leased`, `delivered`, `delivery_unknown`, and `dead`.
Repeated delivery behavior depends on evidence:
- if the verified router contract proves the same `route_id` and payload digest are idempotent, an expired/failed attempt may retry with that exact pair;
- if idempotency is false, the worker never automatically retries an ambiguous submission;
- if idempotency is unresolved, B4 stops before implementation and commit;
- a client timeout or connection loss after send becomes `delivery_unknown`, never a fabricated failure or success.
B4 evidence must record the live listener, owning unit, executable/source digest, readiness response schema, route request fields, route response fields, exact 140-second downstream timeout, router tenant/user ownership rules, and repeated-`route_id` behavior. The dedicated `aios-v2r1-hermes-worker.service` is installed only in B5 after this evidence is accepted.
## OpenClaw plugin compatibility and permissions
The plugin configuration contract is exact:
- `blockUnknownAgentRuns`: boolean, default `false`;
- `approvalTimeoutMs`: integer, minimum `1000`, maximum `300000`, default `60000`;
- package and manifest versions are identical;
- every property read by runtime code appears in `configSchema`.
Unit tests with a fake API remain useful, but they are insufficient. B1 must run the real OpenClaw loader in an isolated state/config directory. It must prove two permission states:
1. Default hook permissions: runtime inspection shows only `before_tool_call` is usable for this plugin; no test treats `before_agent_run` provenance as present.
2. Explicit conversation access: set `plugins.entries.aios-tenant-boundary.hooks.allowConversationAccess=true`, validate config, reload/inspect runtime, and prove `before_agent_run`, `before_tool_call`, and `agent_end` are installed.
The default remains fail-closed for side effects because absent run provenance cannot authorize them. Candidate production never relies on the default-only state; the isolated staging config explicitly opts in and is validated.
## Isolated candidate staging and plugin loader sequence
B5 defines separate staging and production gateway units plus the isolated ingress and worker units. Staging starts the candidate trio; production gateway activation occurs only through the evidence-gated promotion/migration:
- `aios-v2r1-candidate-gateway.service`
- `aios-v2r1-production-gateway.service`
- `aios-v2r1-ingress.service`
- `aios-v2r1-hermes-worker.service`
The candidate gateway uses its dedicated service user with `StateDirectory=aios-v2r1-candidate-gateway`; the migrated production gateway uses a different dedicated service user with `StateDirectory=aios-v2r1-production-gateway`. Both units set `StateDirectoryMode=0700`, and both resolve their journal only as `$STATE_DIRECTORY/outbound-receipts`. The names, absolute paths, owners, and journal contents are distinct; staging and production never share, bind-mount, or symlink journal state. The current root-run production gateway retains its own root-owned state only until an explicit audited migration. Candidate staging never reads that state. Migration verifies pending records contain no bodies or secrets, installs them only into the production-specific directory with its service user ownership and mode `0600`, and leaves no root-owned production journal in active use.
The candidate gateway has its own OpenClaw state directory, config file, port, credentials, and plugin link. It does not edit or restart the active V1R4C6 gateway. The loader sequence against the candidate state is fixed:
```bash
openclaw plugins install --link "$candidate_release/openclaw/plugins/aios-tenant-boundary"
openclaw plugins inspect aios-tenant-boundary --runtime --json
openclaw config set plugins.entries.aios-tenant-boundary.hooks.allowConversationAccess true --strict-json
openclaw config validate
openclaw doctor --lint --json
openclaw doctor --post-upgrade --json
openclaw plugins inspect aios-tenant-boundary --runtime --json
```
The environment supplies the isolated config/state paths. Any command that resolves to the active V1R4C6 config is a hard stop. Inspection output must prove the linked candidate path, version, hooks, and permission state.
## Artifact, preflight evidence, promotion, and rollback
One deterministic archive and manifest are built from a clean reviewed commit. Staging and production consume byte-identical artifact digests. `NOOP_DIGEST_MATCH` returns exit 0 and performs no extraction, migration, symlink change, restart, or deployment audit increment.
The executable controller is `python3 deploy/v2r1/release_ctl.py` with commands `build`, `stage`, `verify-stage`, `promote`, `status`, and `rollback`. Forward state transitions are exactly `BUILT -> STAGED -> STAGE_VERIFIED -> PRODUCTION`; skipping or reversing a forward edge is rejected. `capture_live_preflight.py` creates the evidence file and every mutating controller command accepts it as `--preflight-file`; callers never supply digest strings. The controller validates the evidence schema, exact host identity, reconciled sentinel health and candidate refs, required runtime fields, and capture freshness of at most 300 seconds. It then computes the artifact, reconciled-sentinel, and runtime-snapshot SHA-256 digests internally and persists that triplet in the state record.
`verify-stage` and `promote` each invoke the live collector again, validate the fresh result by the same rules, recompute the triplet internally, and compare the recaptured live state with the recorded candidate state. Any host, sentinel, candidate-ref, runtime-field, artifact, or digest drift rejects the transition. `status` is read-only. Rollback transitions are `PRODUCTION -> ROLLBACK_STARTED -> ROLLED_BACK`; a failed recovery records `ROLLBACK_FAILED` without claiming the prior state is active. `NOOP_DIGEST_MATCH` leaves the current state and all three digests unchanged.
Before staging and again before promotion, both pieces of live evidence are mandatory:
- reconciled Gitea branch-consistency sentinel: `status=healthy`, `drift_count=0`, and recorded refs matching the candidate manifest;
- V1R4C6 live-runtime snapshot: active Git SHA, service units, process commands, listener ports, OpenClaw version, config/plugin digests, credential metadata without secrets, state/database paths, mounts, current artifact identity, and rollback target.
The snapshot is immutable, SHA-256-addressed, and timestamped. Promotion rejects a snapshot older than 300 seconds or any live drift from it. `capture_live_preflight.py` creates these evidence inputs from live inspection; `release_ctl.py` may validate and recapture them but may not invent missing fields or accept caller-asserted digests.
The host sentinel `/var/lib/aios-v2r1/STOP` blocks forward mutation: install, migration, staging activation, promotion, candidate/production restart, and new artifact selection. It does not block one constrained recovery command:
```text
rollback --rollback-only --to-previous-recorded --reason-file "$reason_file"
```
That escape may only select the exact previous digest and its recorded compatible database snapshot; it cannot install new bytes, run forward migrations, choose an arbitrary digest, remove the sentinel, or start a new candidate. It writes an audit record before and after the attempt. If previous-state evidence is missing or fails digest/integrity checks, rollback also stops.
The tracked `release/v2r1c0-rc1/FLEET_UNSUPPORTED` capability sentinel remains authoritative. `OpenClawFleet` and `openclaw fleet` are outside this release.
## Stop conditions
Preparation or release stops when any of these is true:
- corrected design/plan review is not accepted;
- a B3/B4/B5 evidence gate remains unresolved;
- authoritative Git refs differ from reviewed values or the worktree is dirty;
- the real plugin loader does not prove the required hook permissions;
- transport key role, `key_id`, timestamp, exact nonce length, skew, replay cache, rotation, or fresh-retry behavior fails;
- Node attempts projection/authority resolution or Python trusts route claims from the raw event;
- `ACCEPTED` can fall through to legacy processing, or legacy runs while bridge mode is `enforced`;
- quote or attachment resolution uses timestamp, approximation, recent order, or a local client ID;
- outbound evidence remains `EVIDENCE_REQUIRED`;
- Hermes schema, ownership, 140-second downstream budget, router identity rules, or repeated-route behavior is unresolved;
- any code or document claims exactly-once Hermes delivery;
- the candidate gateway is not isolated from V1R4C6;
- reconciled sentinel or V1R4C6 live-runtime snapshot is absent, stale, inconsistent, or digest-invalid;
- artifact, migration, staging, or production digest differs;
- the STOP sentinel blocks a forward action;
- constrained rollback cannot prove the exact previous artifact and compatible snapshot;
- any main, tag, `VERSION`, baseline, live service, or deployment mutation occurs without the separate release authorization.
## Acceptance
The design is review-complete when the plan contains exactly five blocker commits, dynamically anchors their parent chain at B1 start, enumerates all legacy import edits, and has executable RED/GREEN/review gates. R9 preparation additionally requires D0 as `START`, B1 as its child, B2 as one 43-path child commit, the full seven-component executable matrix, and a partial ledger containing exactly `START,B1,B2`. Runtime release acceptance additionally requires all B3/B4/B5 evidence gates, the full Python collection and suite, the real Node/OpenClaw loader harness, exact receipt semantics, isolated candidate staging, deterministic artifacts, current live evidence, and rehearsed constrained rollback.
@@ -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
-112
View File
@@ -1,112 +0,0 @@
from __future__ import annotations
import argparse
import json
from pathlib import Path
TS_HELPER = '''// AIOS Weixin lossless getUpdates JSON begin
const WEIXIN_EXACT_ID_FIELDS = new Set(["message_id", "msg_id", "root_id", "parent_id", "server_msg_id", "new_msg_id"]);
function parseWeixinGetUpdatesJson(rawText: string): GetUpdatesResp {
return JSON.parse(rawText, (key: string, value: unknown, context?: { source?: string }) => {
if (!WEIXIN_EXACT_ID_FIELDS.has(key) || value == null || typeof value === "string") return value;
if (typeof value !== "number") throw new Error(`invalid ${key} type in getUpdates response`);
const source = context?.source;
if (!source || !/^(?:0|[1-9]\\d*)$/.test(source)) throw new Error(`unsafe numeric ${key} in getUpdates response`);
return source;
}) as GetUpdatesResp;
}
// AIOS Weixin lossless getUpdates JSON end'''
JS_HELPER = '''// AIOS Weixin lossless getUpdates JSON begin
const WEIXIN_EXACT_ID_FIELDS = new Set(["message_id", "msg_id", "root_id", "parent_id", "server_msg_id", "new_msg_id"]);
function parseWeixinGetUpdatesJson(rawText) {
return JSON.parse(rawText, (key, value, context) => {
if (!WEIXIN_EXACT_ID_FIELDS.has(key) || value == null || typeof value === "string")
return value;
if (typeof value !== "number")
throw new Error(`invalid ${key} type in getUpdates response`);
const source = context?.source;
if (!source || !/^(?:0|[1-9]\\d*)$/.test(source))
throw new Error(`unsafe numeric ${key} in getUpdates response`);
return source;
});
}
// AIOS Weixin lossless getUpdates JSON end'''
def insert_once(text: str, marker: str, addition: str) -> str:
if addition in text:
return text
if marker not in text:
raise RuntimeError(f'marker not found: {marker}')
return text.replace(marker, marker + '\n' + addition, 1)
def patch_api_ts(text: str) -> str:
text = insert_once(text, 'import { redactBody, redactUrl } from "../util/redact.js";', '\n' + TS_HELPER)
old = 'const resp: GetUpdatesResp = JSON.parse(rawText);'
new = 'const resp: GetUpdatesResp = parseWeixinGetUpdatesJson(rawText);'
if new not in text:
if old not in text:
raise RuntimeError('TypeScript getUpdates parser marker not found')
text = text.replace(old, new, 1)
return text
def patch_api_js(text: str) -> str:
text = insert_once(text, 'import { redactBody, redactUrl } from "../util/redact.js";', '\n' + JS_HELPER)
old = 'const resp = JSON.parse(rawText);'
new = 'const resp = parseWeixinGetUpdatesJson(rawText);'
if new not in text:
if old not in text:
raise RuntimeError('JavaScript getUpdates parser marker not found')
text = text.replace(old, new, 1)
return text
def patch_types_ts(text: str) -> str:
new = 'message_id?: string | number;'
if new in text:
return text
old = 'message_id?: number;'
if old not in text:
raise RuntimeError('message_id type marker not found')
return text.replace(old, new, 1)
def write_if_changed(path: Path, content: str) -> bool:
original = path.read_text(encoding='utf-8')
if original == content:
return False
path.write_text(content, encoding='utf-8')
return True
def apply(plugin_root: Path) -> dict[str, bool]:
paths = {
'src/api/api.ts': patch_api_ts,
'dist/src/api/api.js': patch_api_js,
'src/api/types.ts': patch_types_ts,
}
changed: dict[str, bool] = {}
for relative, transform in paths.items():
path = plugin_root / relative
if not path.exists():
raise RuntimeError(f'missing plugin file: {path}')
original = path.read_text(encoding='utf-8')
changed[relative] = write_if_changed(path, transform(original))
return changed
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument('plugin_root', type=Path)
args = parser.parse_args()
changed = apply(args.plugin_root.resolve())
print(json.dumps({'plugin_root': str(args.plugin_root.resolve()), 'changed': changed}, ensure_ascii=False))
return 0
if __name__ == '__main__':
raise SystemExit(main())
+11 -519
View File
@@ -439,26 +439,9 @@ type HermesFollowupImageContext = {
quotedMessageId?: string;
};
type HermesQuotedTextContext = {
referenced: boolean;
handled: boolean;
prompt?: string;
replyText?: string;
reason: "none" | "quoted_text_resolved" | "quoted_text_unavailable";
};
type HermesTextMessageEntry = {
text: string;
timestampMs: number;
conflict?: boolean;
};
const DEFAULT_HERMES_LAST_IMAGE_CACHE_PATH = "/var/lib/aios/openclaw-session/openclaw-weixin/hermes-last-image-cache.json";
const DEFAULT_HERMES_LAST_IMAGE_MAX_AGE_MS = 15 * 60 * 1000;
const DEFAULT_HERMES_MEDIA_DEBUG_PATH = "/var/log/aios/hermes-route-memory/weixin_media_debug.jsonl";
const DEFAULT_HERMES_TEXT_MESSAGE_CACHE_PATH = "/var/lib/aios/openclaw-session/openclaw-weixin/hermes-message-text-cache.json";
const DEFAULT_HERMES_TEXT_MESSAGE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const DEFAULT_HERMES_TEXT_MESSAGE_MAX_ENTRIES = 500;
function hermesLastImageCachePath(): string {
return (process.env.AIOS_HERMES_WEIXIN_LAST_IMAGE_CACHE || DEFAULT_HERMES_LAST_IMAGE_CACHE_PATH).trim();
@@ -473,80 +456,6 @@ function hermesMediaDebugPath(): string {
return (process.env.AIOS_HERMES_WEIXIN_MEDIA_DEBUG || DEFAULT_HERMES_MEDIA_DEBUG_PATH).trim();
}
function hermesTextMessageCachePath(): string {
return (process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE || DEFAULT_HERMES_TEXT_MESSAGE_CACHE_PATH).trim();
}
function hermesTextMessageCacheKey(accountId: string, senderId: string, messageId: string): string {
return `${accountId || "unknown"}:${senderId || "unknown"}:${messageId}`;
}
function hermesTextMessageTimeCacheKey(accountId: string, senderId: string, createTimeMs: string): string {
return `${accountId || "unknown"}:${senderId || "unknown"}:created:${createTimeMs}`;
}
function readHermesTextMessageCache(): Record<string, HermesTextMessageEntry> {
try {
const filePath = hermesTextMessageCachePath();
if (!existsSync(filePath)) return {};
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, HermesTextMessageEntry> : {};
} catch (err) {
logger.warn(`[weixin] Hermes text message cache read skipped err=${String(err).slice(0, 160)}`);
return {};
}
}
function writeHermesTextMessageCache(cache: Record<string, HermesTextMessageEntry>): void {
try {
const filePath = hermesTextMessageCachePath();
mkdirSync(path.dirname(filePath), { recursive: true });
const now = Date.now();
const retained = Object.entries(cache)
.filter(([, entry]) => entry && typeof entry.text === "string" && now - Number(entry.timestampMs ?? 0) <= DEFAULT_HERMES_TEXT_MESSAGE_MAX_AGE_MS)
.sort((a, b) => Number(b[1].timestampMs ?? 0) - Number(a[1].timestampMs ?? 0))
.slice(0, DEFAULT_HERMES_TEXT_MESSAGE_MAX_ENTRIES);
writeFileSync(filePath, JSON.stringify(Object.fromEntries(retained), null, 2), { encoding: "utf-8", mode: 0o600 });
} catch (err) {
logger.warn(`[weixin] Hermes text message cache write skipped err=${String(err).slice(0, 160)}`);
}
}
function rememberHermesTextMessage(accountId: string, senderId: string, full: any, text: string): void {
const normalizedText = String(text ?? "").trim().slice(0, 4000);
if (!normalizedText) return;
const messageIds = collectHermesMessageIds(full);
if (!messageIds.size) return;
const cache = readHermesTextMessageCache();
const timestampMs = Date.now();
for (const messageId of messageIds) {
cache[hermesTextMessageCacheKey(accountId, senderId, messageId)] = { text: normalizedText, timestampMs };
}
writeHermesTextMessageCache(cache);
}
function rememberHermesOutboundTextMessage(accountId: string, senderId: string, text: string, createTimeMs: number): void {
const normalizedText = String(text ?? "").trim().slice(0, 4000);
if (!normalizedText || !Number.isSafeInteger(createTimeMs) || createTimeMs <= 0) return;
const cache = readHermesTextMessageCache();
const key = hermesTextMessageTimeCacheKey(accountId, senderId, String(createTimeMs));
const existing = cache[key];
cache[key] = existing && existing.text !== normalizedText
? { text: "", timestampMs: Date.now(), conflict: true }
: { text: normalizedText, timestampMs: Date.now() };
writeHermesTextMessageCache(cache);
}
function resolveHermesCachedQuotedText(accountId: string, senderId: string, messageId?: string, createTimeMs?: string | number): string {
const normalizedId = String(messageId ?? "").trim();
const normalizedTime = String(createTimeMs ?? "").trim();
const cache = readHermesTextMessageCache();
const entry = (normalizedId ? cache[hermesTextMessageCacheKey(accountId, senderId, normalizedId)] : undefined) ??
(normalizedTime ? cache[hermesTextMessageTimeCacheKey(accountId, senderId, normalizedTime)] : undefined);
if (!entry || entry.conflict || Date.now() - Number(entry.timestampMs ?? 0) > DEFAULT_HERMES_TEXT_MESSAGE_MAX_AGE_MS) return "";
return String(entry.text ?? "").trim().slice(0, 4000);
}
function hermesLastImageCacheKey(accountId: string, senderId: string): string {
return `${accountId || "unknown"}:${senderId || "unknown"}`;
}
@@ -667,128 +576,6 @@ function hasHermesReferenceContext(value: any, depth = 0): boolean {
return false;
}
function findHermesReferencedCreateTimeMs(value: any, depth = 0, insideReference = false): string | undefined {
if (depth > 8 || value == null) return undefined;
if (Array.isArray(value)) {
for (const item of value) {
const found = findHermesReferencedCreateTimeMs(item, depth + 1, insideReference);
if (found) return found;
}
return undefined;
}
if (typeof value !== "object") return undefined;
const obj = value as Record<string, any>;
if (insideReference) {
const raw = obj.create_time_ms ?? obj.createTimeMs;
if (typeof raw === "number" && Number.isSafeInteger(raw) && raw > 0) return String(raw);
if (typeof raw === "string" && /^(?:0|[1-9]\d*)$/.test(raw.trim())) return raw.trim();
}
const referenceKeys = ["ref_msg", "refMsg", "quoted_message", "quotedMessage", "quoted", "quote", "referenced_message", "referencedMessage"];
for (const key of referenceKeys) {
if (key in obj) {
const found = findHermesReferencedCreateTimeMs(obj[key], depth + 1, true);
if (found) return found;
}
}
if (insideReference) {
for (const key of ["message_item", "messageItem", "item_list", "itemList", "message", "msg", "payload"]) {
if (key in obj) {
const found = findHermesReferencedCreateTimeMs(obj[key], depth + 1, true);
if (found) return found;
}
}
}
return undefined;
}
function extractHermesReferencedText(value: any, depth = 0, insideReference = false): string {
if (depth > 8 || value == null) return "";
if (Array.isArray(value)) {
for (const item of value) {
const found = extractHermesReferencedText(item, depth + 1, insideReference);
if (found) return found;
}
return "";
}
if (typeof value !== "object") return "";
const obj = value as Record<string, any>;
const referenceKeys = [
"ref_msg",
"refMsg",
"quoted_message",
"quotedMessage",
"quoted",
"quote",
"referenced_message",
"referencedMessage",
];
for (const key of referenceKeys) {
if (key in obj) {
const referenceValue = obj[key];
const found = extractHermesReferencedText(referenceValue, depth + 1, true);
if (found) return found;
const title = String(referenceValue?.title ?? "").trim();
if (title) return title.slice(0, 4000);
}
}
if (insideReference) {
const directCandidates = [
obj.text_item?.text,
obj.textItem?.text,
];
for (const candidate of directCandidates) {
const normalized = String(candidate ?? "").trim();
if (normalized) return normalized.slice(0, 4000);
}
for (const key of ["message_item", "messageItem", "text_item", "textItem", "item_list", "itemList", "message", "msg", "payload"]) {
if (key in obj) {
const found = extractHermesReferencedText(obj[key], depth + 1, true);
if (found) return found;
}
}
} else {
for (const key of ["item_list", "itemList", "message", "msg", "payload"]) {
if (key in obj) {
const found = extractHermesReferencedText(obj[key], depth + 1, false);
if (found) return found;
}
}
}
return "";
}
function resolveHermesQuotedTextContext(items: any, currentText: string, cachedQuotedText = ""): HermesQuotedTextContext {
if (!hasHermesReferenceContext(items)) {
return { referenced: false, handled: false, prompt: String(currentText ?? "").trim(), reason: "none" };
}
const quotedText = extractHermesReferencedText(items) || String(cachedQuotedText ?? "").trim().slice(0, 4000);
if (!quotedText) {
return {
referenced: true,
handled: true,
replyText: "无法读取被引用文字,请复制原文后再提问。",
reason: "quoted_text_unavailable",
};
}
const normalizedCurrent = String(currentText ?? "").trim().slice(0, 4000);
return {
referenced: true,
handled: false,
prompt: `[被引用文字]\n${quotedText}\n[/被引用文字]\n\n[当前问题]\n${normalizedCurrent}\n[/当前问题]`,
reason: "quoted_text_resolved",
};
}
async function dispatchHermesQuotedTextFailClosed(context: HermesQuotedTextContext, dispatcher: any): Promise<boolean> {
if (!context.handled) return false;
dispatcher.sendFinalReply({
text: context.replyText ?? "无法读取被引用文字,请复制原文后再提问。",
});
dispatcher.markComplete();
await dispatcher.waitForIdle();
return true;
}
function findHermesReferencedMediaItem(value: any, depth = 0, insideReference = false): any {
if (depth > 8 || value == null) return undefined;
if (Array.isArray(value)) {
@@ -842,7 +629,6 @@ function extractHermesMessageId(value: any): string | undefined {
const obj = value as Record<string, any>;
for (const key of ["message_id", "messageId", "msg_id", "msgId", "client_msg_id", "clientMsgId", "server_msg_id", "serverMsgId", "new_msg_id", "newMsgId", "id"]) {
const raw = obj[key];
if (typeof raw === "number" && !Number.isSafeInteger(raw)) continue;
if (typeof raw === "string" || typeof raw === "number") {
const text = String(raw).trim();
if (text) return text;
@@ -1082,9 +868,6 @@ JS_PROCESS_HELPERS = r'''
const DEFAULT_HERMES_LAST_IMAGE_CACHE_PATH = "/var/lib/aios/openclaw-session/openclaw-weixin/hermes-last-image-cache.json";
const DEFAULT_HERMES_LAST_IMAGE_MAX_AGE_MS = 15 * 60 * 1000;
const DEFAULT_HERMES_MEDIA_DEBUG_PATH = "/var/log/aios/hermes-route-memory/weixin_media_debug.jsonl";
const DEFAULT_HERMES_TEXT_MESSAGE_CACHE_PATH = "/var/lib/aios/openclaw-session/openclaw-weixin/hermes-message-text-cache.json";
const DEFAULT_HERMES_TEXT_MESSAGE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const DEFAULT_HERMES_TEXT_MESSAGE_MAX_ENTRIES = 500;
function hermesLastImageCachePath() {
return (process.env.AIOS_HERMES_WEIXIN_LAST_IMAGE_CACHE || DEFAULT_HERMES_LAST_IMAGE_CACHE_PATH).trim();
}
@@ -1095,79 +878,6 @@ function hermesLastImageMaxAgeMs() {
function hermesMediaDebugPath() {
return (process.env.AIOS_HERMES_WEIXIN_MEDIA_DEBUG || DEFAULT_HERMES_MEDIA_DEBUG_PATH).trim();
}
function hermesTextMessageCachePath() {
return (process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE || DEFAULT_HERMES_TEXT_MESSAGE_CACHE_PATH).trim();
}
function hermesTextMessageCacheKey(accountId, senderId, messageId) {
return `${accountId || "unknown"}:${senderId || "unknown"}:${messageId}`;
}
function hermesTextMessageTimeCacheKey(accountId, senderId, createTimeMs) {
return `${accountId || "unknown"}:${senderId || "unknown"}:created:${createTimeMs}`;
}
function readHermesTextMessageCache() {
try {
const filePath = hermesTextMessageCachePath();
if (!existsSync(filePath))
return {};
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
}
catch (err) {
logger.warn(`[weixin] Hermes text message cache read skipped err=${String(err).slice(0, 160)}`);
return {};
}
}
function writeHermesTextMessageCache(cache) {
try {
const filePath = hermesTextMessageCachePath();
mkdirSync(path.dirname(filePath), { recursive: true });
const now = Date.now();
const retained = Object.entries(cache)
.filter(([, entry]) => entry && typeof entry.text === "string" && now - Number(entry.timestampMs ?? 0) <= DEFAULT_HERMES_TEXT_MESSAGE_MAX_AGE_MS)
.sort((a, b) => Number(b[1].timestampMs ?? 0) - Number(a[1].timestampMs ?? 0))
.slice(0, DEFAULT_HERMES_TEXT_MESSAGE_MAX_ENTRIES);
writeFileSync(filePath, JSON.stringify(Object.fromEntries(retained), null, 2), { encoding: "utf-8", mode: 0o600 });
}
catch (err) {
logger.warn(`[weixin] Hermes text message cache write skipped err=${String(err).slice(0, 160)}`);
}
}
function rememberHermesTextMessage(accountId, senderId, full, text) {
const normalizedText = String(text ?? "").trim().slice(0, 4000);
if (!normalizedText)
return;
const messageIds = collectHermesMessageIds(full);
if (!messageIds.size)
return;
const cache = readHermesTextMessageCache();
const timestampMs = Date.now();
for (const messageId of messageIds) {
cache[hermesTextMessageCacheKey(accountId, senderId, messageId)] = { text: normalizedText, timestampMs };
}
writeHermesTextMessageCache(cache);
}
function rememberHermesOutboundTextMessage(accountId, senderId, text, createTimeMs) {
const normalizedText = String(text ?? "").trim().slice(0, 4000);
if (!normalizedText || !Number.isSafeInteger(createTimeMs) || createTimeMs <= 0)
return;
const cache = readHermesTextMessageCache();
const key = hermesTextMessageTimeCacheKey(accountId, senderId, String(createTimeMs));
const existing = cache[key];
cache[key] = existing && existing.text !== normalizedText
? { text: "", timestampMs: Date.now(), conflict: true }
: { text: normalizedText, timestampMs: Date.now() };
writeHermesTextMessageCache(cache);
}
function resolveHermesCachedQuotedText(accountId, senderId, messageId, createTimeMs) {
const normalizedId = String(messageId ?? "").trim();
const normalizedTime = String(createTimeMs ?? "").trim();
const cache = readHermesTextMessageCache();
const entry = (normalizedId ? cache[hermesTextMessageCacheKey(accountId, senderId, normalizedId)] : undefined) ??
(normalizedTime ? cache[hermesTextMessageTimeCacheKey(accountId, senderId, normalizedTime)] : undefined);
if (!entry || entry.conflict || Date.now() - Number(entry.timestampMs ?? 0) > DEFAULT_HERMES_TEXT_MESSAGE_MAX_AGE_MS)
return "";
return String(entry.text ?? "").trim().slice(0, 4000);
}
function hermesLastImageCacheKey(accountId, senderId) {
return `${accountId || "unknown"}:${senderId || "unknown"}`;
}
@@ -1292,141 +1002,6 @@ function hasHermesReferenceContext(value, depth = 0) {
}
return false;
}
function findHermesReferencedCreateTimeMs(value, depth = 0, insideReference = false) {
if (depth > 8 || value == null)
return undefined;
if (Array.isArray(value)) {
for (const item of value) {
const found = findHermesReferencedCreateTimeMs(item, depth + 1, insideReference);
if (found)
return found;
}
return undefined;
}
if (typeof value !== "object")
return undefined;
const obj = value;
if (insideReference) {
const raw = obj.create_time_ms ?? obj.createTimeMs;
if (typeof raw === "number" && Number.isSafeInteger(raw) && raw > 0)
return String(raw);
if (typeof raw === "string" && /^(?:0|[1-9]\d*)$/.test(raw.trim()))
return raw.trim();
}
const referenceKeys = ["ref_msg", "refMsg", "quoted_message", "quotedMessage", "quoted", "quote", "referenced_message", "referencedMessage"];
for (const key of referenceKeys) {
if (key in obj) {
const found = findHermesReferencedCreateTimeMs(obj[key], depth + 1, true);
if (found)
return found;
}
}
if (insideReference) {
for (const key of ["message_item", "messageItem", "item_list", "itemList", "message", "msg", "payload"]) {
if (key in obj) {
const found = findHermesReferencedCreateTimeMs(obj[key], depth + 1, true);
if (found)
return found;
}
}
}
return undefined;
}
function extractHermesReferencedText(value, depth = 0, insideReference = false) {
if (depth > 8 || value == null)
return "";
if (Array.isArray(value)) {
for (const item of value) {
const found = extractHermesReferencedText(item, depth + 1, insideReference);
if (found)
return found;
}
return "";
}
if (typeof value !== "object")
return "";
const obj = value;
const referenceKeys = [
"ref_msg",
"refMsg",
"quoted_message",
"quotedMessage",
"quoted",
"quote",
"referenced_message",
"referencedMessage",
];
for (const key of referenceKeys) {
if (key in obj) {
const referenceValue = obj[key];
const found = extractHermesReferencedText(referenceValue, depth + 1, true);
if (found)
return found;
const title = String(referenceValue?.title ?? "").trim();
if (title)
return title.slice(0, 4000);
}
}
if (insideReference) {
const directCandidates = [
obj.text_item?.text,
obj.textItem?.text,
];
for (const candidate of directCandidates) {
const normalized = String(candidate ?? "").trim();
if (normalized)
return normalized.slice(0, 4000);
}
for (const key of ["message_item", "messageItem", "text_item", "textItem", "item_list", "itemList", "message", "msg", "payload"]) {
if (key in obj) {
const found = extractHermesReferencedText(obj[key], depth + 1, true);
if (found)
return found;
}
}
}
else {
for (const key of ["item_list", "itemList", "message", "msg", "payload"]) {
if (key in obj) {
const found = extractHermesReferencedText(obj[key], depth + 1, false);
if (found)
return found;
}
}
}
return "";
}
function resolveHermesQuotedTextContext(items, currentText, cachedQuotedText = "") {
if (!hasHermesReferenceContext(items)) {
return { referenced: false, handled: false, prompt: String(currentText ?? "").trim(), reason: "none" };
}
const quotedText = extractHermesReferencedText(items) || String(cachedQuotedText ?? "").trim().slice(0, 4000);
if (!quotedText) {
return {
referenced: true,
handled: true,
replyText: "无法读取被引用文字,请复制原文后再提问。",
reason: "quoted_text_unavailable",
};
}
const normalizedCurrent = String(currentText ?? "").trim().slice(0, 4000);
return {
referenced: true,
handled: false,
prompt: `[被引用文字]\n${quotedText}\n[/被引用文字]\n\n[当前问题]\n${normalizedCurrent}\n[/当前问题]`,
reason: "quoted_text_resolved",
};
}
async function dispatchHermesQuotedTextFailClosed(context, dispatcher) {
if (!context.handled)
return false;
dispatcher.sendFinalReply({
text: context.replyText ?? "无法读取被引用文字,请复制原文后再提问。",
});
dispatcher.markComplete();
await dispatcher.waitForIdle();
return true;
}
function findHermesReferencedMediaItem(value, depth = 0, insideReference = false) {
if (depth > 8 || value == null)
return undefined;
@@ -1486,8 +1061,6 @@ function extractHermesMessageId(value) {
const obj = value;
for (const key of ["message_id", "messageId", "msg_id", "msgId", "client_msg_id", "clientMsgId", "server_msg_id", "serverMsgId", "new_msg_id", "newMsgId", "id"]) {
const raw = obj[key];
if (typeof raw === "number" && !Number.isSafeInteger(raw))
continue;
if (typeof raw === "string" || typeof raw === "number") {
const text = String(raw).trim();
if (text)
@@ -1748,9 +1321,6 @@ TS_PROCESS_HOOK = r'''
const hermesExplicitSlash = isHermesSlashCommandText(hermesContent);
const hermesReferenceContextPresent = hasHermesReferenceContext(full.item_list);
const hermesQuotedMessageId = findHermesReferencedMessageId(full.item_list);
const hermesQuotedCreateTimeMs = findHermesReferencedCreateTimeMs(full.item_list);
rememberHermesTextMessage(deps.accountId, hermesSenderId, full, textBody);
const hermesCachedQuotedText = resolveHermesCachedQuotedText(deps.accountId, hermesSenderId, hermesQuotedMessageId, hermesQuotedCreateTimeMs);
const hermesReferencedMediaIsImage = Boolean(refMediaItem && hermesCurrentMessageIsImage({}, refMediaItem));
const hermesReferencedImageFollowupIntent = hermesReferenceContextPresent && !refMediaItem;
const hermesCurrentImage = hermesCurrentMessageIsImage(hermesCtx, mediaItem);
@@ -1761,18 +1331,6 @@ TS_PROCESS_HOOK = r'''
: (hermesReferencedImageFollowupIntent
? resolveHermesReferencedImageFollowup(deps.accountId, hermesSenderId, hermesContent, hermesQuotedMessageId)
: resolveHermesFollowupImageContext(deps.accountId, hermesSenderId, hermesContent)));
const hermesQuotedTextContext = hermesReferenceContextPresent &&
!hermesExplicitSlash &&
!hermesCurrentImage &&
!hermesReferencedMediaIsImage &&
!hermesFollowup.matched
? resolveHermesQuotedTextContext(full.item_list, hermesContent, hermesCachedQuotedText)
: { referenced: false, handled: false, prompt: hermesContent, reason: "none" as const };
if (hermesQuotedTextContext.reason === "quoted_text_resolved" && hermesQuotedTextContext.prompt) {
ctx.Body = hermesQuotedTextContext.prompt;
ctx.CommandBody = hermesQuotedTextContext.prompt;
logger.info(`[weixin] quoted text context resolved quoted_message_id=${hermesQuotedMessageId ? "present" : "none"}`);
}
if (hermesFollowup.reason === "referenced_image_unresolved") {
writeHermesMediaDebug("referenced_image_unresolved", {
accountId: deps.accountId,
@@ -1820,7 +1378,7 @@ TS_PROCESS_HOOK = r'''
return;
}
}
if (!hermesQuotedTextContext.referenced && !textBody.startsWith("/")) {
if (!textBody.startsWith("/")) {
const hermesImageResult = await handleHermesImplicitImage(hermesContent, hermesRouteCtx, receivedAt, full.create_time_ms);
if (hermesImageResult.handled) {
logger.info(`[weixin] Hermes implicit image handled, skipping AI pipeline`);
@@ -1832,7 +1390,7 @@ TS_PROCESS_HOOK = r'''
}
}
// AIOS Hermes image ingress hook end
'''.strip()
'''.rstrip()
JS_PROCESS_HOOK = r'''
@@ -1843,9 +1401,6 @@ JS_PROCESS_HOOK = r'''
const hermesExplicitSlash = isHermesSlashCommandText(hermesContent);
const hermesReferenceContextPresent = hasHermesReferenceContext(full.item_list);
const hermesQuotedMessageId = findHermesReferencedMessageId(full.item_list);
const hermesQuotedCreateTimeMs = findHermesReferencedCreateTimeMs(full.item_list);
rememberHermesTextMessage(deps.accountId, hermesSenderId, full, textBody);
const hermesCachedQuotedText = resolveHermesCachedQuotedText(deps.accountId, hermesSenderId, hermesQuotedMessageId, hermesQuotedCreateTimeMs);
const hermesReferencedMediaIsImage = Boolean(refMediaItem && hermesCurrentMessageIsImage({}, refMediaItem));
const hermesReferencedImageFollowupIntent = hermesReferenceContextPresent && !refMediaItem;
const hermesCurrentImage = hermesCurrentMessageIsImage(ctx, mediaItem);
@@ -1856,18 +1411,6 @@ JS_PROCESS_HOOK = r'''
: (hermesReferencedImageFollowupIntent
? resolveHermesReferencedImageFollowup(deps.accountId, hermesSenderId, hermesContent, hermesQuotedMessageId)
: resolveHermesFollowupImageContext(deps.accountId, hermesSenderId, hermesContent)));
const hermesQuotedTextContext = hermesReferenceContextPresent &&
!hermesExplicitSlash &&
!hermesCurrentImage &&
!hermesReferencedMediaIsImage &&
!hermesFollowup.matched
? resolveHermesQuotedTextContext(full.item_list, hermesContent, hermesCachedQuotedText)
: { referenced: false, handled: false, prompt: hermesContent, reason: "none" };
if (hermesQuotedTextContext.reason === "quoted_text_resolved" && hermesQuotedTextContext.prompt) {
ctx.Body = hermesQuotedTextContext.prompt;
ctx.CommandBody = hermesQuotedTextContext.prompt;
logger.info(`[weixin] quoted text context resolved quoted_message_id=${hermesQuotedMessageId ? "present" : "none"}`);
}
if (hermesFollowup.reason === "referenced_image_unresolved") {
writeHermesMediaDebug("referenced_image_unresolved", {
accountId: deps.accountId,
@@ -1915,7 +1458,7 @@ JS_PROCESS_HOOK = r'''
return;
}
}
if (!hermesQuotedTextContext.referenced && !textBody.startsWith("/")) {
if (!textBody.startsWith("/")) {
const hermesImageResult = await handleHermesImplicitImage(hermesContent, hermesRouteCtx, receivedAt, full.create_time_ms);
if (hermesImageResult.handled) {
logger.info(`[weixin] Hermes implicit image handled, skipping AI pipeline`);
@@ -1927,29 +1470,7 @@ JS_PROCESS_HOOK = r'''
}
}
// AIOS Hermes image ingress hook end
'''.strip()
TS_QUOTED_TEXT_DISPATCH_GUARD = r'''
// AIOS quoted text dispatch guard begin
if (await dispatchHermesQuotedTextFailClosed(hermesQuotedTextContext, dispatcher)) {
logger.info(`[weixin] quoted text fail-closed reason=${hermesQuotedTextContext.reason} quoted_message_id=${hermesQuotedMessageId ? "present" : "none"}`);
markDispatchIdle();
return;
}
// AIOS quoted text dispatch guard end
'''.strip()
JS_QUOTED_TEXT_DISPATCH_GUARD = r'''
// AIOS quoted text dispatch guard begin
if (await dispatchHermesQuotedTextFailClosed(hermesQuotedTextContext, dispatcher)) {
logger.info(`[weixin] quoted text fail-closed reason=${hermesQuotedTextContext.reason} quoted_message_id=${hermesQuotedMessageId ? "present" : "none"}`);
markDispatchIdle();
return;
}
// AIOS quoted text dispatch guard end
'''.strip()
'''.rstrip()
TS_HANDLE_SLASH_COMMAND = r'''
@@ -2365,38 +1886,23 @@ def patch_channel_outbound_cache(text: str) -> str:
raise RuntimeError("sendWeixinMediaFile success branch not found for outbound cache patch")
def remove_managed_process_block(text: str, start_marker: str, end_marker: str, label: str) -> str:
while True:
start = text.find(start_marker)
if start < 0:
return text
def remove_existing_process_hermes_hook(text: str) -> str:
start_marker = "// AIOS Hermes image ingress hook begin"
end_marker = "// AIOS Hermes image ingress hook end"
start = text.find(start_marker)
if start >= 0:
line_start = text.rfind("\n", 0, start)
block_start = 0 if line_start < 0 else line_start + 1
if line_start > 0 and text[line_start - 1] == "\n":
block_start = line_start
end = text.find(end_marker, start)
if end < 0:
raise RuntimeError(f"unterminated {label} block")
raise RuntimeError("unterminated Hermes process hook block")
end = text.find("\n", end)
block_end = len(text) if end < 0 else end + 1
while block_end < len(text) and text[block_end] == "\n":
block_end += 1
text = text[:block_start] + text[block_end:]
def remove_existing_process_hermes_hook(text: str) -> str:
text = remove_managed_process_block(
text,
"// AIOS Hermes image ingress hook begin",
"// AIOS Hermes image ingress hook end",
"Hermes process hook",
)
text = remove_managed_process_block(
text,
"// AIOS quoted text dispatch guard begin",
"// AIOS quoted text dispatch guard end",
"quoted text dispatch guard",
)
return text[:block_start] + text[block_end:]
old_hook_pattern = re.compile(
r'\n\s*if \(!textBody\.startsWith\("/"\)\) \{\n'
@@ -2498,16 +2004,6 @@ def patch_process_message_file(path: pathlib.Path, is_ts: bool) -> bool:
if needle not in text:
raise RuntimeError("ctx.CommandAuthorized assignment not found")
text = text.replace(needle, needle + hook + "\n", 1)
dispatch_guard = TS_QUOTED_TEXT_DISPATCH_GUARD if is_ts else JS_QUOTED_TEXT_DISPATCH_GUARD
dispatch_needle = (
' logger.debug(`dispatchReplyFromConfig: starting agentId=${route.agentId ?? "(none)"}`);\n'
if is_ts
else ' logger.debug(`dispatchReplyFromConfig: starting agentId=${route.agentId ?? "(none)"}`);\n'
)
if dispatch_needle not in text:
raise RuntimeError("dispatchReplyFromConfig start log not found")
text = text.replace("\n\n" + dispatch_needle, "\n" + dispatch_needle, 1)
text = text.replace(dispatch_needle, dispatch_guard + "\n" + dispatch_needle, 1)
return write_if_changed(path, text, original)
@@ -2608,10 +2104,6 @@ def verify_plugin_hook(root: pathlib.Path) -> dict[str, object]:
and "resolveHermesFollowupImageContext" in text
and "matched cached media" in text
and "has no cached media, fail-closed" in text
and "resolveHermesQuotedTextContext" in text
and "dispatchHermesQuotedTextFailClosed" in text
and "AIOS quoted text dispatch guard begin" in text
and "无法读取被引用文字,请复制原文后再提问。" in text
and "mediaPath" in text
)
@@ -1,156 +0,0 @@
from __future__ import annotations
import argparse
import json
from pathlib import Path
def replace_required(text: str, old: str, new: str, label: str) -> str:
if new in text:
return text
if old not in text:
raise RuntimeError(f'{label} marker not found')
return text.replace(old, new, 1)
def patch_send_ts(text: str) -> str:
text = replace_required(
text,
'}): Promise<{ messageId: string }> {\n const { to, text, opts } = params;\n',
'}): Promise<{ messageId: string; createTimeMs: number }> {\n const { to, text, opts } = params;\n',
'TS send return type',
)
text = replace_required(
text,
' const clientId = generateClientId();\n const req = buildSendMessageReq({',
' const clientId = generateClientId();\n const createTimeMs = Date.now();\n const req = buildSendMessageReq({',
'TS create time',
)
text = replace_required(
text,
' });\n try {\n await sendMessageApi({',
' });\n if (!req.msg?.item_list?.[0]) throw new Error("sendMessageWeixin: text item missing");\n req.msg.item_list[0].create_time_ms = createTimeMs;\n try {\n await sendMessageApi({',
'TS request timestamp',
)
text = replace_required(
text,
' return { messageId: clientId };\n}\n\n/** Send a single structured MessageItem downstream. */',
' return { messageId: clientId, createTimeMs };\n}\n\n/** Send a single structured MessageItem downstream. */',
'TS send result',
)
return text
def patch_send_js(text: str) -> str:
text = replace_required(
text,
' const clientId = generateClientId();\n const req = buildSendMessageReq({',
' const clientId = generateClientId();\n const createTimeMs = Date.now();\n const req = buildSendMessageReq({',
'JS create time',
)
text = replace_required(
text,
' });\n try {\n await sendMessageApi({',
' });\n if (!req.msg?.item_list?.[0])\n throw new Error("sendMessageWeixin: text item missing");\n req.msg.item_list[0].create_time_ms = createTimeMs;\n try {\n await sendMessageApi({',
'JS request timestamp',
)
text = replace_required(
text,
' return { messageId: clientId };\n}\n/** Send a single structured MessageItem downstream. */',
' return { messageId: clientId, createTimeMs };\n}\n/** Send a single structured MessageItem downstream. */',
'JS send result',
)
return text
def patch_process_ts(text: str) -> str:
old = '''await sendMessageWeixin({ to: ctx.To, text, opts: {
baseUrl: deps.baseUrl,
token: deps.token,
contextToken,
runId,
}});'''
new = '''const sentText = await sendMessageWeixin({ to: ctx.To, text, opts: {
baseUrl: deps.baseUrl,
token: deps.token,
contextToken,
runId,
}});
rememberHermesOutboundTextMessage(deps.accountId, ctx.To, text, sentText.createTimeMs);'''
text = replace_required(text, old, new, 'TS fallback text send')
old = '''await sendMessageWeixin({ to: ctx.To, text, opts: {
baseUrl: deps.baseUrl,
token: deps.token,
contextToken,
runId,
}});'''
new = '''const sentText = await sendMessageWeixin({ to: ctx.To, text, opts: {
baseUrl: deps.baseUrl,
token: deps.token,
contextToken,
runId,
}});
rememberHermesOutboundTextMessage(deps.accountId, ctx.To, text, sentText.createTimeMs);'''
return replace_required(text, old, new, 'TS normal text send')
def patch_process_js(text: str) -> str:
old = '''await sendMessageWeixin({ to: ctx.To, text, opts: {
baseUrl: deps.baseUrl,
token: deps.token,
contextToken,
runId,
} });'''
new = '''const sentText = await sendMessageWeixin({ to: ctx.To, text, opts: {
baseUrl: deps.baseUrl,
token: deps.token,
contextToken,
runId,
} });
rememberHermesOutboundTextMessage(deps.accountId, ctx.To, text, sentText.createTimeMs);'''
text = replace_required(text, old, new, 'JS fallback text send')
old = '''await sendMessageWeixin({ to: ctx.To, text, opts: {
baseUrl: deps.baseUrl,
token: deps.token,
contextToken,
runId,
} });'''
new = '''const sentText = await sendMessageWeixin({ to: ctx.To, text, opts: {
baseUrl: deps.baseUrl,
token: deps.token,
contextToken,
runId,
} });
rememberHermesOutboundTextMessage(deps.accountId, ctx.To, text, sentText.createTimeMs);'''
return replace_required(text, old, new, 'JS normal text send')
def apply(root: Path) -> dict[str, bool]:
transforms = {
'src/messaging/send.ts': patch_send_ts,
'dist/src/messaging/send.js': patch_send_js,
'src/messaging/process-message.ts': patch_process_ts,
'dist/src/messaging/process-message.js': patch_process_js,
}
changed = {}
for relative, transform in transforms.items():
path = root / relative
original = path.read_text(encoding='utf-8')
updated = transform(original)
changed[relative] = updated != original
if updated != original:
path.write_text(updated, encoding='utf-8')
return changed
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument('plugin_root', type=Path)
args = parser.parse_args()
root = args.plugin_root.resolve()
print(json.dumps({'plugin_root': str(root), 'changed': apply(root)}, ensure_ascii=False))
return 0
if __name__ == '__main__':
raise SystemExit(main())
-111
View File
@@ -1,111 +0,0 @@
import {
classifyRun,
decideToolCall,
resolveSenderId,
runKey,
} from "./policy.js";
const MAX_RUN_AGE_MS = 60 * 60 * 1000;
function prune(activeRuns, now = Date.now()) {
for (const [key, value] of activeRuns.entries()) {
if (now - value.recordedAt > MAX_RUN_AGE_MS) activeRuns.delete(key);
}
}
function approvalTimeout(config) {
const value = config.approvalTimeoutMs;
return Number.isInteger(value) && value >= 1000 && value <= 300000
? value
: 60000;
}
export default {
id: "aios-tenant-boundary",
name: "AIOS Tenant Boundary",
description: "Fail-closed user-origin and side-effect policy for Tenant Cells.",
register(api) {
const activeRuns = new Map();
api.on(
"before_agent_run",
async (event, ctx) => {
prune(activeRuns);
const config = event.context?.pluginConfig ?? api.pluginConfig ?? {};
const trust = classifyRun(ctx, config);
const key = runKey(event, ctx);
const existing = key ? activeRuns.get(key) : undefined;
if (existing && existing.senderId !== trust.senderId) {
return {
outcome: "block",
reason: "run_scope_sender_conflict",
message: "This run scope is already bound to a different authenticated sender.",
};
}
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(activeRuns);
const config = event.context?.pluginConfig ?? api.pluginConfig ?? {};
const key = runKey(event, ctx);
let trust = key ? activeRuns.get(key) : undefined;
const senderId = resolveSenderId(ctx);
if (trust && senderId && trust.senderId !== senderId) {
trust = { trusted: false, reason: "run_scope_sender_conflict" };
}
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: approvalTimeout(config),
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);
},
{ priority: 100 },
);
},
};
-91
View File
@@ -1,91 +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 normalize = (value) => typeof value === "string" && value.trim()
? value.trim()
: undefined;
const sameField = (eventValue, contextValue) => {
const fromEvent = normalize(eventValue);
const fromContext = normalize(contextValue);
return {
valid: !(fromEvent && fromContext && fromEvent !== fromContext),
value: fromEvent || fromContext,
};
};
const runId = sameField(event.runId, ctx.runId);
const agentId = sameField(event.agentId, ctx.agentId);
const sessionId = sameField(event.sessionId, ctx.sessionId);
const sessionKey = sameField(event.sessionKey, ctx.sessionKey);
if (!runId.valid || !agentId.valid || !sessionId.valid || !sessionKey.valid) {
return undefined;
}
const selectedSession = sessionId.value || sessionKey.value;
return runId.value && agentId.value && selectedSession
? JSON.stringify([runId.value, agentId.value, selectedSession])
: undefined;
}
@@ -1,25 +0,0 @@
{
"id": "aios-tenant-boundary",
"name": "AIOS Tenant Boundary",
"description": "Enforces AIOS V2R1 tenant provenance and tool policy boundaries.",
"version": "2.1.0-rc.1",
"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 },
"blockUnknownAgentRuns": { "type": "boolean", "default": false },
"approvalTimeoutMs": {
"type": "integer",
"minimum": 1000,
"maximum": 300000,
"default": 60000
}
}
}
}
@@ -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,64 +0,0 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import plugin from "../dist/index.js";
const packageJson = JSON.parse(
await readFile(new URL("../package.json", import.meta.url), "utf8"),
);
const manifest = JSON.parse(
await readFile(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
);
function registrations(config = {}) {
const seen = [];
plugin.register({
pluginConfig: config,
on(name, handler, options) {
seen.push({ name, handler, options });
},
});
return seen;
}
test("package, manifest, and executable test command are release compatible", () => {
assert.equal(packageJson.version, "2.1.0-rc.1");
assert.equal(manifest.version, "2.1.0-rc.1");
assert.equal(packageJson.scripts.test, "node --test test/*.test.js");
});
test("schema declares every runtime-read property and exact B1 bounds", () => {
const properties = manifest.configSchema.properties;
const runtimeKeys = [
"approvalTimeoutMs",
"blockUnknownAgentRuns",
"blockedSenderIds",
"readOnlyTools",
"requireApprovalForHumanSideEffects",
"requireExplicitTrustedSenders",
"trustedSenderIds",
];
assert.deepEqual(Object.keys(properties).sort(), runtimeKeys);
assert.deepEqual(properties.blockUnknownAgentRuns, {
type: "boolean",
default: false,
});
assert.deepEqual(properties.approvalTimeoutMs, {
type: "integer",
minimum: 1000,
maximum: 300000,
default: 60000,
});
});
test("all three hooks register exactly once at priority 100", () => {
const seen = registrations();
assert.deepEqual(
seen.map(({ name }) => name).sort(),
["agent_end", "before_agent_run", "before_tool_call"],
);
for (const registration of seen) {
assert.deepEqual(registration.options, { priority: 100 });
}
});
@@ -1,50 +0,0 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { realpath } from "node:fs/promises";
const [mode, inspectPath] = process.argv.slice(2);
assert.ok(["default", "opt-in"].includes(mode), `unsupported mode: ${mode}`);
assert.ok(inspectPath, "inspect JSON path is required");
const data = JSON.parse(await readFile(inspectPath, "utf8"));
const plugin = data.plugin;
assert.equal(plugin.id, "aios-tenant-boundary");
assert.equal(plugin.version, "2.1.0-rc.1");
assert.equal(plugin.status, "loaded");
assert.equal(plugin.activated, true);
assert.equal(await realpath(plugin.rootDir), await realpath(new URL("..", import.meta.url)));
assert.equal(await realpath(plugin.source), await realpath(new URL("../dist/index.js", import.meta.url)));
assert.equal(data.install.source, "path");
assert.equal(await realpath(data.install.installPath), await realpath(plugin.rootDir));
const hooks = data.typedHooks
.map(({ name, priority }) => ({ name, priority }))
.sort((a, b) => a.name.localeCompare(b.name));
const expectedNames = mode === "default"
? ["before_tool_call"]
: ["agent_end", "before_agent_run", "before_tool_call"];
assert.deepEqual(hooks.map(({ name }) => name), expectedNames);
assert.ok(hooks.every(({ priority }) => priority === 100));
const diagnostics = data.diagnostics ?? [];
assert.equal(
diagnostics.filter(({ level, severity }) =>
["error", "fatal"].includes(String(level ?? severity).toLowerCase())).length,
0,
);
if (mode === "default") {
assert.deepEqual(
diagnostics.map(({ level, message }) => ({ level, message })).sort((a, b) => a.message.localeCompare(b.message)),
["agent_end", "before_agent_run"].map((hook) => ({
level: "warn",
message: `typed hook "${hook}" blocked because non-bundled plugins must set plugins.entries.aios-tenant-boundary.hooks.allowConversationAccess=true`,
})).sort((a, b) => a.message.localeCompare(b.message)),
);
} else {
assert.deepEqual(diagnostics, []);
}
const properties = plugin.configJsonSchema?.properties ?? {};
for (const key of ["approvalTimeoutMs", "blockUnknownAgentRuns"]) {
assert.ok(Object.hasOwn(properties, key), `missing runtime schema property: ${key}`);
}
@@ -1,349 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import plugin from "../dist/index.js";
import {
classifyRun,
decideToolCall,
resolveSenderId,
runKey,
} from "../dist/policy.js";
function registration(config = {}) {
const handlers = new Map();
plugin.register({
pluginConfig: config,
on(name, handler) {
handlers.set(name, handler);
},
});
return {
beforeAgentRun: handlers.get("before_agent_run"),
beforeToolCall: handlers.get("before_tool_call"),
agentEnd: handlers.get("agent_end"),
};
}
function agentContext(id, senderId) {
return {
runId: `run-${id}`,
agentId: "agent-main",
sessionId: "session-uuid-123",
sessionKey: "agent:main:weixin:direct:user",
...(senderId ? { senderId } : {}),
};
}
function toolContext(id, extra = {}) {
return {
runId: `run-${id}`,
agentId: "agent-main",
sessionId: "session-uuid-123",
sessionKey: "agent:main:weixin:direct:user",
...extra,
};
}
test("sender classification covers missing, blocked, allowlisted, and automation", () => {
assert.equal(resolveSenderId({ channelContext: { sender: { id: " nested " } } }), "nested");
assert.deepEqual(classifyRun({}, {}), {
trusted: false,
origin: "system_or_unknown",
reason: "sender_id_missing",
});
assert.equal(
classifyRun({ senderId: "blocked" }, { blockedSenderIds: ["blocked"] }).reason,
"sender_blocked",
);
assert.equal(
classifyRun(
{ senderId: "alice" },
{ requireExplicitTrustedSenders: true, trustedSenderIds: ["alice"] },
).trusted,
true,
);
assert.deepEqual(classifyRun({ jobId: "job-1" }, {}), {
trusted: false,
origin: "automation",
reason: "job_id_present",
});
});
test("unknown agent runs default to pass but fail closed when explicitly enabled", async () => {
const defaultHandlers = registration();
assert.deepEqual(
await defaultHandlers.beforeAgentRun({}, agentContext("default")),
{ outcome: "pass" },
);
const enforced = registration({ blockUnknownAgentRuns: true });
const denied = await enforced.beforeAgentRun({}, agentContext("denied"));
assert.equal(denied.outcome, "block");
assert.equal(denied.reason, "sender_id_missing");
assert.doesNotMatch(denied.reason + denied.message, /undefined|\r|\n/);
assert.deepEqual(
await enforced.beforeAgentRun({}, { ...agentContext("job"), jobId: "job-1" }),
{ outcome: "pass" },
);
});
test("read-only tools pass without provenance and side effects fail with a stable reason", async () => {
assert.deepEqual(decideToolCall("read", undefined, {}), {
action: "allow",
code: "read_only",
});
assert.deepEqual(decideToolCall("shell", undefined, {}), {
action: "block",
code: "run_provenance_missing",
});
const handlers = registration();
assert.equal(await handlers.beforeToolCall({ toolName: "read" }, {}), undefined);
const blocked = await handlers.beforeToolCall({ toolName: "shell" }, {});
assert.equal(blocked.block, true);
assert.equal(
blocked.blockReason,
"AIOS V2R1 origin policy denied tool call: run_provenance_missing",
);
});
test("trusted human side effects request approval with exact timeout validation", async () => {
for (const [configured, expected] of [
[undefined, 60000],
[1000, 1000],
[300000, 300000],
[999, 60000],
[300001, 60000],
[60000.5, 60000],
["1000", 60000],
[null, 60000],
]) {
const config = {
requireExplicitTrustedSenders: true,
trustedSenderIds: ["alice"],
};
if (configured !== undefined) config.approvalTimeoutMs = configured;
const handlers = registration(config);
const id = `approval-${String(configured)}`;
await handlers.beforeAgentRun({}, agentContext(id, "alice"));
const result = await handlers.beforeToolCall({ toolName: "shell" }, toolContext(id));
assert.equal(result.requireApproval.timeoutMs, expected, String(configured));
assert.deepEqual(result.requireApproval.allowedDecisions, ["allow-once", "deny"]);
}
});
test("active run provenance is isolated by registration, run, and sender", async () => {
const config = {
requireExplicitTrustedSenders: true,
trustedSenderIds: ["alice", "bob"],
};
const first = registration(config);
const second = registration(config);
await first.beforeAgentRun({}, agentContext("shared", "alice"));
const otherSender = await first.beforeToolCall(
{ toolName: "shell" },
toolContext("shared", { senderId: "bob" }),
);
assert.equal(otherSender.block, true);
assert.match(otherSender.blockReason, /run_scope_sender_conflict$/);
const otherRegistration = await second.beforeToolCall(
{ toolName: "shell" },
toolContext("shared"),
);
assert.equal(otherRegistration.block, true);
assert.match(otherRegistration.blockReason, /run_provenance_missing$/);
});
test("agent_end cleanup removes only the matching shared scope", async () => {
const handlers = registration({
requireExplicitTrustedSenders: true,
trustedSenderIds: ["alice", "bob"],
});
await handlers.beforeAgentRun({}, agentContext("alice-scope", "alice"));
await handlers.beforeAgentRun({}, agentContext("bob-scope", "bob"));
await handlers.agentEnd({}, toolContext("alice-scope"));
const alice = await handlers.beforeToolCall(
{ toolName: "shell" },
toolContext("alice-scope"),
);
assert.equal(alice.block, true);
const bob = await handlers.beforeToolCall(
{ toolName: "shell" },
toolContext("bob-scope"),
);
assert.ok(bob.requireApproval);
});
test("run provenance expires after the bounded age", async (t) => {
const originalNow = Date.now;
t.after(() => { Date.now = originalNow; });
let now = 1_000_000;
Date.now = () => now;
const handlers = registration({
requireExplicitTrustedSenders: true,
trustedSenderIds: ["alice"],
});
await handlers.beforeAgentRun({}, agentContext("expiring", "alice"));
now += 60 * 60 * 1000 + 1;
const result = await handlers.beforeToolCall(
{ toolName: "shell" },
toolContext("expiring"),
);
assert.equal(result.block, true);
assert.match(result.blockReason, /run_provenance_missing$/);
});
test("host-shaped lifecycle keeps distinct sessionId and sessionKey across all hooks", async () => {
const handlers = registration({
requireExplicitTrustedSenders: true,
trustedSenderIds: ["alice"],
});
const before = agentContext("host-shape", "alice");
const tool = toolContext("host-shape");
const end = toolContext("host-shape");
for (const ctx of [before, tool, end]) {
assert.equal(ctx.sessionId, "session-uuid-123");
assert.equal(ctx.sessionKey, "agent:main:weixin:direct:user");
assert.equal(ctx.runId, "run-host-shape");
assert.equal(ctx.agentId, "agent-main");
}
await handlers.beforeAgentRun({}, before);
const result = await handlers.beforeToolCall(
{ toolName: "shell" },
tool,
);
assert.ok(result.requireApproval);
assert.equal(result.block, undefined);
await handlers.agentEnd({}, end);
const cleaned = await handlers.beforeToolCall({ toolName: "shell" }, tool);
assert.equal(cleaned.block, true);
assert.match(cleaned.blockReason, /run_provenance_missing$/);
});
test("shared scope collision fails closed without overwriting original sender", async () => {
const handlers = registration({
requireExplicitTrustedSenders: true,
trustedSenderIds: ["alice", "bob"],
});
assert.deepEqual(
await handlers.beforeAgentRun({}, agentContext("collision", "alice")),
{ outcome: "pass" },
);
const collision = await handlers.beforeAgentRun(
{},
agentContext("collision", "bob"),
);
assert.equal(collision.outcome, "block");
assert.equal(collision.reason, "run_scope_sender_conflict");
assert.doesNotMatch(collision.message, /alice|bob/);
const original = await handlers.beforeToolCall(
{ toolName: "shell" },
toolContext("collision"),
);
assert.ok(original.requireApproval);
});
test("shared scope normalization is exact and incomplete scope fails closed", async () => {
const bothAliases = runKey({}, {
runId: " run-1 ",
agentId: " agent-1 ",
sessionId: " session-uuid-123 ",
sessionKey: " agent:main:weixin:direct:user ",
});
assert.equal(
bothAliases,
JSON.stringify(["run-1", "agent-1", "session-uuid-123"]),
);
const fallback = runKey(
{ sessionKey: " agent:main:weixin:direct:user " },
{
runId: "run-1",
agentId: "agent-1",
sessionKey: "agent:main:weixin:direct:user",
},
);
assert.equal(
fallback,
JSON.stringify(["run-1", "agent-1", "agent:main:weixin:direct:user"]),
);
assert.equal(runKey(
{ sessionId: "event-session" },
{
runId: "run-1",
agentId: "agent-1",
sessionId: "context-session",
sessionKey: "agent:main:weixin:direct:user",
},
), undefined);
assert.equal(runKey({}, {
runId: "run-1",
agentId: "agent-1",
sessionKey: "agent:main:weixin:direct:user",
sessionId: "session-uuid-123",
}), bothAliases);
assert.equal(runKey({}, {
runId: "run-1",
agentId: "agent-1",
sessionKey: "agent:main:weixin:direct:user",
}), fallback);
assert.equal(runKey({}, {
agentId: "agent-1", sessionId: "session-uuid-123",
}), undefined);
const handlers = registration();
const missing = await handlers.beforeToolCall(
{ toolName: "shell" },
{ agentId: "agent-1", sessionId: "session-uuid-123" },
);
assert.equal(missing.block, true);
assert.match(missing.blockReason, /run_provenance_missing$/);
});
test("sessionKey fallback requires sessionId absent from both hook contexts", async () => {
const handlers = registration({
requireExplicitTrustedSenders: true,
trustedSenderIds: ["alice"],
});
const before = {
runId: "run-fallback",
agentId: "agent-main",
sessionKey: "agent:main:weixin:direct:user",
senderId: "alice",
};
const tool = {
runId: "run-fallback",
agentId: "agent-main",
sessionKey: "agent:main:weixin:direct:user",
};
await handlers.beforeAgentRun({}, before);
assert.ok((await handlers.beforeToolCall({ toolName: "shell" }, tool)).requireApproval);
const mismatchedSelection = await handlers.beforeToolCall(
{ toolName: "shell" },
{ ...tool, sessionId: "session-uuid-123" },
);
assert.equal(mismatchedSelection.block, true);
assert.match(mismatchedSelection.blockReason, /run_provenance_missing$/);
});
test("same-named event and context sessionId mismatch fails closed", async () => {
const handlers = registration({
requireExplicitTrustedSenders: true,
trustedSenderIds: ["alice"],
});
await handlers.beforeAgentRun({}, agentContext("event-mismatch", "alice"));
const result = await handlers.beforeToolCall(
{ toolName: "shell", sessionId: "different-session" },
toolContext("event-mismatch"),
);
assert.equal(result.block, true);
assert.match(result.blockReason, /run_provenance_missing$/);
});
@@ -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.
@@ -0,0 +1,11 @@
[Unit]
Description=Run Gitea branch consistency sentinel periodically
[Timer]
OnBootSec=2min
OnUnitActiveSec=1min
RandomizedDelaySec=10s
Persistent=true
[Install]
WantedBy=timers.target
@@ -1,9 +0,0 @@
{
"type": "branch",
"branch": "deploy/v1r4c6-production-20260711",
"base": "main",
"created_by": "chatgpt-gitops-bot",
"created_at": "2026-07-11T13:10:51Z",
"direct_main_push": false,
"owner_approval_required": true
}
@@ -1,16 +0,0 @@
{
"task_id": "TASK-CODEX-V2R1-PERSONAL-WEIXIN-B6B7-BOOTSTRAP-AND-CANARY-20260715T120000UTC8",
"phase": "A",
"type": "branch",
"branch": "patch/v2r1-personal-weixin-b6b7-impl-20260715T115309UTC8",
"base_ref": "design/v2r1-personal-weixin-golden-contract-20260715",
"base_commit": "d74229927b2dadca7719455984aea10d010c5123",
"created_by": "aiman",
"created_at": "2026-07-15T03:53:09Z",
"direct_main_push": false,
"owner_approval_required": false,
"owner_approved": true,
"owner_approval_scope": "Phase A bootstrap for owner-approved B6/B7 personal-Weixin implementation; no production, runtime, database, secret, tag, baseline, or controller/ChatGPT worktree changes.",
"production_change": false,
"user_action_required": false
}
@@ -1,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 @@
{
"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"
}
-36
View File
@@ -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,44 +0,0 @@
{
"schemaVersion": 1,
"contract": "personal-weixin-b4-outbound-quote-authority-amendment",
"ownerApproved": true,
"lineage": {
"foundationImplementationCommit": "2fcab6f5833a405a9a26683811600b6e5f1aa7b1",
"foundationResultCommit": "64a71b6199a8e2bd609ba145349e67b874888505",
"r10LineageCommit": "74e603c0c943501110577dbe3c7f6a0b18b8ec7d",
"evidenceGateCommit": "62a4067cf7a462ebe7cf29524238bc5d0a70b813",
"upstreamAuditCommit": "b2f5f531019a70801444987dbb97dc1f69cf47de"
},
"vendorClientIdMessageIdEvidence": {
"status": "absent",
"clientIdPromotable": false
},
"authorityPolicy": {
"outboundQuoteAuthority": "disabled",
"clientIdRole": "non-authoritative-correlation-only",
"authoritativeMessageIdSources": [
"validated-inbound-raw-wire.vendor_message_id"
],
"quotedSendWithoutAuthoritativeMessageId": "fail-closed",
"plainOutboundWithoutQuoteAuthority": "allowed-only-by-existing-b4-allowlist",
"syntheticMapping": false,
"timestampFallback": false,
"localSequenceFallback": false,
"contentHashFallback": false,
"outboundReceiptFallback": false
},
"implementationConstraints": {
"scopeExpansion": false,
"exactAllowlistRequired": true,
"missingAllowlistStatus": "BLOCKED_SCOPE",
"productionChangeAuthorized": false,
"canaryAuthorized": false,
"tagAuthorized": false,
"forcePushAuthorized": false
},
"production": {
"baseline": "V1R4C6",
"changed": false,
"releaseStatus": "paused"
}
}
@@ -1,23 +0,0 @@
{
"schemaVersion": 1,
"contract": "personal-weixin-r10-lineage-amendment",
"foundation": {
"node": "F0_PERSONAL_WEIXIN_FOUNDATION",
"implementationCommit": "2fcab6f5833a405a9a26683811600b6e5f1aa7b1",
"resultCommit": "64a71b6199a8e2bd609ba145349e67b874888505",
"imageDigest": "sha256:ed741d1f028c4bd507c6eda71fb1dcc11e0815c0107afcbfcbaaaaa23d5a11bb",
"status": "PASS"
},
"precedes": [
"B3",
"B4",
"B5",
"B6",
"B7"
],
"production": {
"baseline": "V1R4C6",
"changed": false,
"releaseStatus": "paused"
}
}
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Safe-mode gate for autonomous mutations.
Controllers should run `check` before writes. Exit code 75 means the
controller must stop mutation and wait for controlled recovery.
"""
from __future__ import annotations
import argparse
import datetime
import json
import os
from pathlib import Path
import sys
DEFAULT = "/data/aios-gitops/runtime/state/autonomous-safe-mode.json"
def now():
return datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
def load(path):
if not path.exists():
return None
with path.open("r", encoding="utf-8") as f:
return json.load(f)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--safe-mode-file", default=os.environ.get("AUTONOMOUS_SAFE_MODE_FILE", DEFAULT))
parser.add_argument("command", choices=["check", "enter", "clear"])
parser.add_argument("--reason", default="")
args = parser.parse_args()
path = Path(args.safe_mode_file)
if args.command == "check":
marker = load(path)
if marker:
print(json.dumps(marker, ensure_ascii=False, indent=2))
return 75
return 0
if args.command == "enter":
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({
"status": "active",
"entered_at": now(),
"reason": args.reason or "manual",
}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return 0
if args.command == "clear":
if path.exists():
path.unlink()
return 0
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,414 @@
#!/usr/bin/env python3
"""
Gitea branch metadata consistency sentinel.
Compares three views of a repository's branch state:
1. Bare Git refs under refs/heads
2. Gitea SQLite branch metadata rows with is_deleted=0
3. Gitea branch API pagination results
On drift or check failure, optionally writes an autonomous safe-mode marker so
controllers can stop mutating state before the incident expands.
"""
from __future__ import annotations
import argparse
import datetime as _dt
import json
import os
from pathlib import Path
import shlex
import sqlite3
import subprocess
import sys
import tempfile
from typing import Any
import urllib.error
import urllib.parse
import urllib.request
DEFAULT_REPO_DIR = "/data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios.git"
DEFAULT_DB_PATH = "/data/aios-gitops/runtime/gitea-bin/data/gitea.db"
DEFAULT_API_URL = "http://127.0.0.1:3000/api/v1/repos/aios/aios/branches"
DEFAULT_STATE_DIR = "/data/aios-gitops/runtime/state"
DEFAULT_SAFE_MODE_FILE = f"{DEFAULT_STATE_DIR}/autonomous-safe-mode.json"
DEFAULT_REPORT_FILE = f"{DEFAULT_STATE_DIR}/gitea-branch-consistency-report.json"
DEFAULT_DOCTOR_CMD = (
"/data/aios-gitops/runtime/bin/gitea "
"-C /data/aios-gitops/runtime/gitea-bin/custom "
"doctor check --run check-db-consistency"
)
def utc_now() -> str:
return _dt.datetime.now(_dt.timezone.utc).isoformat().replace("+00:00", "Z")
def env_bool(name: str, default: bool = False) -> bool:
value = os.environ.get(name)
if value is None or value == "":
return default
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
def atomic_write_json(path: Path, payload: dict[str, Any], mode: int = 0o644) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent))
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True)
handle.write("\n")
os.chmod(tmp_name, mode)
os.replace(tmp_name, path)
finally:
try:
os.unlink(tmp_name)
except FileNotFoundError:
pass
def run_command(command: list[str], timeout_seconds: int) -> dict[str, Any]:
try:
proc = subprocess.run(
command,
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout_seconds,
)
return {
"ok": proc.returncode == 0,
"code": proc.returncode,
"stdout": proc.stdout.strip(),
"stderr": proc.stderr.strip(),
"cmd": command,
}
except FileNotFoundError as exc:
return {"ok": False, "code": 127, "stdout": "", "stderr": str(exc), "cmd": command}
except subprocess.TimeoutExpired as exc:
return {
"ok": False,
"code": 124,
"stdout": (exc.stdout or "").strip() if isinstance(exc.stdout, str) else "",
"stderr": (exc.stderr or "").strip() if isinstance(exc.stderr, str) else "",
"cmd": command,
"timeout_seconds": timeout_seconds,
}
def git_branch_names(repo_dir: str, timeout_seconds: int) -> tuple[set[str], dict[str, Any]]:
command = ["git", "--git-dir", repo_dir, "for-each-ref", "--format=%(refname:short)", "refs/heads"]
result = run_command(command, timeout_seconds)
if not result["ok"]:
raise RuntimeError(f"git branch ref scan failed: {result['stderr'] or result['stdout']}")
names = {line.strip() for line in result["stdout"].splitlines() if line.strip()}
result["count"] = len(names)
return names, result
def sqlite_branch_names(db_path: str, repo_id: int) -> tuple[set[str], dict[str, Any]]:
path = Path(db_path)
if not path.exists():
raise RuntimeError(f"Gitea database does not exist: {db_path}")
uri = f"file:{urllib.parse.quote(str(path.resolve()))}?mode=ro"
try:
conn = sqlite3.connect(uri, uri=True, timeout=5)
try:
cursor = conn.execute(
"SELECT name FROM branch WHERE repo_id = ? AND is_deleted = 0 ORDER BY name",
(repo_id,),
)
names = {row[0] for row in cursor.fetchall()}
finally:
conn.close()
except sqlite3.Error as exc:
raise RuntimeError(f"SQLite branch metadata query failed: {exc}") from exc
return names, {"ok": True, "repo_id": repo_id, "count": len(names), "db_path": db_path}
def add_query_params(url: str, params: dict[str, Any]) -> str:
split = urllib.parse.urlsplit(url)
query = dict(urllib.parse.parse_qsl(split.query, keep_blank_values=True))
query.update({key: str(value) for key, value in params.items()})
return urllib.parse.urlunsplit(
(split.scheme, split.netloc, split.path, urllib.parse.urlencode(query), split.fragment)
)
def read_api_token() -> str | None:
token = os.environ.get("GITEA_API_TOKEN")
if token:
return token.strip()
token_file = os.environ.get("GITEA_API_TOKEN_FILE")
if token_file:
token_path = Path(token_file)
if token_path.exists():
return token_path.read_text(encoding="utf-8").strip()
return None
def api_branch_names(
api_url: str,
limit: int,
timeout_seconds: int,
max_pages: int,
) -> tuple[set[str], dict[str, Any]]:
token = read_api_token()
names: list[str] = []
pages: list[dict[str, Any]] = []
for page in range(1, max_pages + 1):
url = add_query_params(api_url, {"page": page, "limit": limit})
headers = {"Accept": "application/json"}
if token:
headers["Authorization"] = f"token {token}"
request = urllib.request.Request(url, headers=headers, method="GET")
try:
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
body = response.read()
status = getattr(response, "status", response.getcode())
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"Gitea branch API HTTP {exc.code}: {detail[:500]}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"Gitea branch API request failed: {exc}") from exc
except TimeoutError as exc:
raise RuntimeError(f"Gitea branch API request timed out: {exc}") from exc
try:
payload = json.loads(body.decode("utf-8"))
except json.JSONDecodeError as exc:
raise RuntimeError(f"Gitea branch API returned invalid JSON on page {page}") from exc
if not isinstance(payload, list):
raise RuntimeError(f"Gitea branch API returned non-list payload on page {page}: {type(payload).__name__}")
page_names = []
for item in payload:
if not isinstance(item, dict) or "name" not in item:
raise RuntimeError(f"Gitea branch API returned malformed branch item on page {page}: {item!r}")
page_names.append(str(item["name"]))
names.extend(page_names)
pages.append({"page": page, "status": status, "count": len(page_names)})
if len(page_names) < limit:
break
else:
raise RuntimeError(f"Gitea branch API exceeded max_pages={max_pages}; refusing to trust partial result")
return set(names), {
"ok": True,
"api_url": api_url,
"limit": limit,
"pages": pages,
"raw_count": len(names),
"unique_count": len(set(names)),
}
def doctor_check(command_text: str, timeout_seconds: int, required: bool) -> dict[str, Any]:
command_text = command_text.strip()
if not command_text:
return {"ok": True, "skipped": True, "reason": "empty GITEA_DOCTOR_CMD"}
command = shlex.split(command_text)
if not command:
return {"ok": True, "skipped": True, "reason": "empty parsed GITEA_DOCTOR_CMD"}
if not Path(command[0]).exists() and "/" in command[0]:
return {
"ok": not required,
"skipped": True,
"required": required,
"reason": f"doctor binary not found: {command[0]}",
"cmd": command,
}
result = run_command(command, timeout_seconds)
result["required"] = required
return result
def compare_sets(left_name: str, left: set[str], right_name: str, right: set[str]) -> dict[str, Any]:
return {
f"{left_name}_missing_from_{right_name}": sorted(left - right),
f"{right_name}_missing_from_{left_name}": sorted(right - left),
}
def build_report(args: argparse.Namespace) -> dict[str, Any]:
checks: dict[str, Any] = {}
errors: list[str] = []
git_names: set[str] = set()
db_names: set[str] = set()
api_names: set[str] = set()
try:
git_names, checks["git_refs"] = git_branch_names(args.repo_dir, args.timeout)
except Exception as exc: # noqa: BLE001 - must preserve diagnostic text
errors.append(str(exc))
checks["git_refs"] = {"ok": False, "error": str(exc), "repo_dir": args.repo_dir}
try:
db_names, checks["sqlite_branch_metadata"] = sqlite_branch_names(args.db_path, args.repo_id)
except Exception as exc: # noqa: BLE001
errors.append(str(exc))
checks["sqlite_branch_metadata"] = {"ok": False, "error": str(exc), "db_path": args.db_path}
if not args.skip_api:
try:
api_names, checks["gitea_branch_api"] = api_branch_names(
args.api_url,
args.api_limit,
args.timeout,
args.max_pages,
)
except Exception as exc: # noqa: BLE001
errors.append(str(exc))
checks["gitea_branch_api"] = {"ok": False, "error": str(exc), "api_url": args.api_url}
else:
checks["gitea_branch_api"] = {"ok": True, "skipped": True}
if not args.skip_doctor:
checks["gitea_doctor"] = doctor_check(args.doctor_cmd, args.doctor_timeout, args.doctor_required)
if not checks["gitea_doctor"].get("ok", False):
errors.append("gitea doctor consistency check failed")
else:
checks["gitea_doctor"] = {"ok": True, "skipped": True}
drift: dict[str, Any] = {}
if checks.get("git_refs", {}).get("ok") and checks.get("sqlite_branch_metadata", {}).get("ok"):
drift.update(compare_sets("git", git_names, "db", db_names))
if checks.get("git_refs", {}).get("ok") and checks.get("gitea_branch_api", {}).get("ok") and not args.skip_api:
drift.update(compare_sets("git", git_names, "api", api_names))
if checks.get("sqlite_branch_metadata", {}).get("ok") and checks.get("gitea_branch_api", {}).get("ok") and not args.skip_api:
drift.update(compare_sets("db", db_names, "api", api_names))
drift_counts = {key: len(value) for key, value in drift.items()}
drift_found = any(drift_counts.values())
counts = {
"git_refs": len(git_names) if checks.get("git_refs", {}).get("ok") else None,
"db_live_branches": len(db_names) if checks.get("sqlite_branch_metadata", {}).get("ok") else None,
"api_visible_branches": len(api_names)
if checks.get("gitea_branch_api", {}).get("ok") and not args.skip_api
else None,
}
unhealthy_checks = [
name for name, result in checks.items()
if not result.get("ok", False)
]
status = "unhealthy" if errors or drift_found or unhealthy_checks else "healthy"
return {
"service": "gitea-branch-consistency-sentinel",
"status": status,
"generated_at": utc_now(),
"repo": {
"repo_dir": args.repo_dir,
"db_path": args.db_path,
"repo_id": args.repo_id,
"api_url": args.api_url,
},
"counts": counts,
"checks": checks,
"drift": drift,
"drift_counts": drift_counts,
"errors": errors,
"unhealthy_checks": unhealthy_checks,
}
def write_safe_mode_if_needed(report: dict[str, Any], args: argparse.Namespace) -> None:
safe_file = Path(args.safe_mode_file)
report_file = Path(args.report_file)
if report["status"] != "healthy":
if args.emit_safe_mode:
marker = {
"service": "autonomous-safe-mode",
"status": "active",
"entered_at": utc_now(),
"reason": "gitea_branch_consistency_unhealthy",
"source": "gitea-branch-consistency-sentinel",
"report_file": str(report_file),
"counts": report.get("counts", {}),
"drift_counts": report.get("drift_counts", {}),
"unhealthy_checks": report.get("unhealthy_checks", []),
"operator_action": (
"Stop autonomous mutations, inspect Gitea refs/branch metadata/API drift, "
"repair minimal inconsistent state, then run runtime acceptance tests."
),
}
atomic_write_json(safe_file, marker)
return
if args.clear_safe_mode_if_healthy and safe_file.exists():
history_dir = safe_file.parent / "safe-mode-history"
history_dir.mkdir(parents=True, exist_ok=True)
archive = history_dir / f"{safe_file.stem}.cleared-{utc_now().replace(':', '').replace('-', '')}.json"
safe_file.replace(archive)
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Detect Gitea branch metadata drift and emit safe mode markers.")
parser.add_argument("--repo-dir", default=os.environ.get("GITEA_REPO_DIR", DEFAULT_REPO_DIR))
parser.add_argument("--db-path", default=os.environ.get("GITEA_DB_PATH", DEFAULT_DB_PATH))
parser.add_argument("--repo-id", type=int, default=int(os.environ.get("GITEA_REPO_ID", "1")))
parser.add_argument("--api-url", default=os.environ.get("GITEA_API_URL", DEFAULT_API_URL))
parser.add_argument("--api-limit", type=int, default=int(os.environ.get("GITEA_BRANCH_API_LIMIT", "50")))
parser.add_argument("--max-pages", type=int, default=int(os.environ.get("GITEA_BRANCH_API_MAX_PAGES", "200")))
parser.add_argument("--timeout", type=int, default=int(os.environ.get("GITEA_SENTINEL_TIMEOUT_SECONDS", "10")))
parser.add_argument("--doctor-timeout", type=int, default=int(os.environ.get("GITEA_DOCTOR_TIMEOUT_SECONDS", "45")))
parser.add_argument("--doctor-cmd", default=os.environ.get("GITEA_DOCTOR_CMD", DEFAULT_DOCTOR_CMD))
parser.add_argument("--doctor-required", action="store_true", default=env_bool("GITEA_DOCTOR_REQUIRED", False))
parser.add_argument("--skip-api", action="store_true", default=env_bool("GITEA_SENTINEL_SKIP_API", False))
parser.add_argument("--skip-doctor", action="store_true", default=env_bool("GITEA_SENTINEL_SKIP_DOCTOR", False))
parser.add_argument("--report-file", default=os.environ.get("GITEA_SENTINEL_REPORT_FILE", DEFAULT_REPORT_FILE))
parser.add_argument("--safe-mode-file", default=os.environ.get("AUTONOMOUS_SAFE_MODE_FILE", DEFAULT_SAFE_MODE_FILE))
parser.add_argument("--emit-safe-mode", action="store_true", help="Write safe-mode marker when unhealthy.")
parser.add_argument(
"--clear-safe-mode-if-healthy",
action="store_true",
help="Archive the existing safe-mode marker when all checks are healthy. Use only during controlled recovery.",
)
parser.add_argument("--json", action="store_true", help="Print full JSON report.")
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
args = parse_args(argv)
report = build_report(args)
report_path = Path(args.report_file)
atomic_write_json(report_path, report)
write_safe_mode_if_needed(report, args)
if args.json or report["status"] != "healthy":
json.dump(report, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True)
sys.stdout.write("\n")
else:
print(
"healthy: "
f"git={report['counts']['git_refs']} "
f"db={report['counts']['db_live_branches']} "
f"api={report['counts']['api_visible_branches']}"
)
return 0 if report["status"] == "healthy" else 2
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

Some files were not shown because too many files have changed in this diff Show More