Compare commits

..
Author SHA1 Message Date
openclaw 6077331add test(v2r1): make router isolation contracts authoritative 2026-07-13 02:28:32 +08:00
openclaw 75f61ac0e6 test(v2r1): cover tenant router isolation contracts 2026-07-13 02:18:04 +08:00
openclaw 0b41a3952a fix-v2r1-suppress-hermes-conflict-context 2026-07-13 02:10:23 +08:00
openclaw 8f205c0d87 test-v2r1-cover-hermes-command-provenance 2026-07-13 02:05:03 +08:00
openclaw d2a4a765ac test-v2r1-enforce-sanitized-hermes-replay-conflicts 2026-07-13 02:03:49 +08:00
openclaw b1110cad62 fix(v2r1): reject noncanonical tenant signatures 2026-07-13 01:52:00 +08:00
openclaw b483dfadbf test(v2r1): define Hermes contract acceptance evidence 2026-07-13 01:25:28 +08:00
openclaw 1e5e144318 fix(v2r1): convert duplicate Hermes events to ConflictError 2026-07-13 01:25:23 +08:00
openclaw d552876a80 release(v2r1): add single source live status page 2026-07-13 00:12:28 +08:00
openclaw 6569065198 ops(v2r1): add RC1 health check 2026-07-12 23:41:01 +08:00
openclaw 2b80c50415 test(v2r1): add RC1 acceptance gate manifest 2026-07-12 23:40:56 +08:00
openclaw 954982647e fix(v2r1): align plugin package entry with OpenClaw SDK contract 2026-07-12 23:40:52 +08:00
openclaw 8074eb31b1 ops(v2r1): add RC1 install preflight 2026-07-12 23:37:42 +08:00
openclaw 90138221ee fix(v2r1): add OpenClaw runtime extension package metadata 2026-07-12 23:37:37 +08:00
openclaw 729c7bb23b fix(v2r1): bind OpenClaw trust only to authoritative runId 2026-07-12 23:37:33 +08:00
openclaw d2de7f23d1 ops(v2r1): add RC1 validation gate 2026-07-12 23:32:51 +08:00
openclaw 73c0a233ce feat(v2r1): add aios-cell lifecycle CLI 2026-07-12 23:32:47 +08:00
openclaw 00816a13c1 feat(v2r1): register OpenClaw runtime provenance and tool hooks 2026-07-12 23:32:42 +08:00
openclaw 4920ac6192 release: define AIOS V2R1C0 RC1 manifest 2026-07-12 23:26:05 +08:00
openclaw b12ff086bf feat(v2r1): add OpenClaw tenant boundary plugin manifest 2026-07-12 23:26:00 +08:00
openclaw 57a668d87e feat(v2r1): add OpenClaw tenant boundary policy runtime 2026-07-12 23:25:57 +08:00
openclaw bab319575b feat(v2r1): add Tenant Cell manager lifecycle registry 2026-07-12 23:22:54 +08:00
openclaw c172953a3c feat(v2r1): add Hermes profile isolation primitives 2026-07-12 23:22:47 +08:00
openclaw c7617fa548 feat(v2r1): add tenant-scoped memory namespace policy 2026-07-12 23:22:41 +08:00
openclaw 2eba23d274 feat(v2r1): add tenant secret scope references 2026-07-12 23:17:56 +08:00
openclaw f2e0406a20 feat(v2r1): add append-only tenant audit chain 2026-07-12 23:17:50 +08:00
openclaw ccb02640ac feat(v2r1): add tenant-scoped exact message ledger 2026-07-12 23:17:43 +08:00
openclaw 5c52f6c82c feat(v2r1): add tenant partitioned Hermes fabric scheduler 2026-07-12 23:10:55 +08:00
openclaw 429c6875bc feat(v2r1): add fail-closed Tenant Router and registry 2026-07-12 23:10:47 +08:00
openclaw f10d0d5b17 feat(v2r1): add OpenClaw Tenant Cell lifecycle adapter 2026-07-12 23:04:34 +08:00
openclaw c9974f3e47 feat(v2r1): add signed tenant context and canonical message contract 2026-07-12 23:04:24 +08:00
openclaw c72450aeb0 docs: add V1R5 to V2R1 migration plan 2026-07-12 22:39:32 +08:00
openclaw f95be48a42 docs: add AIOS V2R1 architecture baseline 2026-07-12 22:39:28 +08:00
openclaw ba989d7340 Start AIOS V2R1C0 Tenant Cell and Hermes Shared Agent Fabric release train 2026-07-12 22:39:23 +08:00
52 changed files with 2207 additions and 4753 deletions
@@ -1,95 +0,0 @@
# GitOps 独立 Worktree 与统一运行身份迁移结果
```yaml
status: BLOCKED
technical_status: PASS
task_id: TASK-GITOPS-ISOLATED-WORKTREE-MIGRATION-20260714T233742UTC8
created_at: 2026-07-14T23:37:42+08:00
completed_at: 2026-07-15T06:24:00+08:00
timezone: Asia/Shanghai
user_action_required: false
runtime_identity:
user: aiman
group: gitops
umask: "0002"
bare_repo: /data/aios-gitops/repo.git
worktree_root: /data/aios-gitops/worktrees
controller_worktree: /data/aios-gitops/worktrees/controller
services:
aios-gitops-model-api:
state: active/running
pid: 1254370
user: aiman
group: gitops
umask: "0002"
working_directory: /data/aios-gitops/worktrees/controller
health_http_code: 200
aios-autonomous-controller: inactive/dead (pre-state preserved)
aios-autonomous-daemon: inactive/dead (pre-state preserved)
aios-agent-command-watcher: inactive/dead (pre-state preserved)
openclaw-gateway:
state: active/running
pid: 1333116
user: root
group: gitops
umask: "0002"
working_directory: /var/lib/aios/openclaw-home
resumed_at: 2026-07-15T06:21:19+08:00
listener: 127.0.0.1:18789
health_http_code: 200
weixin_accounts_running: 4
weixin_last_error: null
identity_changed: false
config_or_db_mutated: false
default_acl: PASS
preflight: PASS
concurrency_test:
status: PASS
run_id: 20260715T061051UTC8
openclaw_branch: task/openclaw/TASK-WORKTREE-CONCURRENCY-OPENCLAW-20260715T061051UTC8
openclaw_commit: dbe9284a49bccb664d042789b3deb3b21669ea0f
openclaw_interval_ns: "[1784067525001763595,1784067525404170029]"
chatgpt_branch: task/chatgpt/TASK-WORKTREE-CONCURRENCY-CHATGPT-20260715T061051UTC8
chatgpt_commit: fe85f051655afbf1e3ee69496f51f2ea64dc1100
chatgpt_interval_ns: "[1784067525005911896,1784067525404756753]"
overlap_ns: 398258133
push_mode: non-force
permission_denied_count: 0
untracked_overwrite_count: 0
git_lock_collision_count: 0
openclaw_commit_push: PASS
chatgpt_commit_push: PASS
deep_directory_acl_inheritance: PASS
shared_workspace_branch_switches: 0
owner_notifications:
received_message_id: null
terminal_message_id: null
received_status: NOT_SENT
terminal_status: NOT_SENT
gate_status: BLOCKED
reason: current operator directive prohibited WeChat; no safe verifiable target/message_id evidence exists
rollback:
triggered: false
source: /data/aios-gitops/workspace
source_head: b05eb1babe9c657e8776c09eec71302166902cc3
source_branch: task/codex/gitops-isolated-worktree-migration-20260715T001652UTC8
source_status_sha256_before: dd4c4d4e23faeda7f097bd9eadc24112be9da1c6b564a1682b4148061fe8d5f2
source_status_sha256_after: dd4c4d4e23faeda7f097bd9eadc24112be9da1c6b564a1682b4148061fe8d5f2
remaining_risks:
- Mandatory Owner notification evidence is absent; overall PASS is prohibited.
- Two invalid concurrency attempts are retained as audit evidence and are not acceptance runs.
next_step: Owner may explicitly waive the notification gate or authorize a verifiable notification; the technical migration does not require rerun.
```
## 技术执行证据
- 实际 Git 写入者为 `aios-gitops-model-api.service`。迁移前为 active/running、PID `621772`;安装 canonical identity 与 controller workdir drop-in 后,已恢复为 active/running、PID `1254370`,`aiman:gitops`、`UMask=0002`、controller worktree、`/health=200`。
- 四个写入服务的 `92-runtime-identity.conf` 均为 `root:root 0644`,SHA256 均为 `e37651f9e98869bcc0a295577ca240be0950214abbccfc9f94182bebebaf99c4`,与 task commit 中 canonical 文件一致。model-api 的 `93-controller-workdir.conf` SHA256 为 `d0a6ac1cbe48abf63a045eb2078c4582a3d05604e6821f765ad15587f5cc2613`。
- Gateway 未参与 Git writer identity 变更;迁移窗口内释放生产运行占用,验收后按原 identity 恢复。当前 active/running,18789 监听,`/health=200`,4 个 Weixin account running 且 `lastError=null`;未改 gateway config/DB。
- bare repo 与 worktree 根为 `aiman:gitops`、目录 `2775`、setgid/default ACL 继承有效。修复的 1 个 root-owned 目录与 2 个文件仅变更属主;两文件 SHA256 保持 `d83b843fd54eccfaf68a43fbe18e1c61542eeb17cc957979c04fa0e5c8dce126`、`7f58e4f7de5c72a5fdc88a56eee40aad6fa3047bc48e9b7e5a6d14f2692cfa63`,worktrees 内 root-owned remaining 为 0。
- 有效并发轮次 `20260715T061051UTC8` 由两个预启动 barrier worker 在同一 `execute_at_epoch` 执行。两条 commit+non-force push 区间真实重叠 `398258133 ns`;两侧 local OID 与 Gitea OID 一致,各自仅提交 3 个文件、worktree clean、深层 ACL PASS,权限拒绝/未跟踪覆盖/Git lock 均为 0。
- shared workspace 未切分支、未清理、未 stash、未提交;HEAD、branch 与 NUL-delimited `status --porcelain=v1 -z -uall` 哈希前后完全一致。
## 终态判定
技术迁移与并发硬门均为 PASS;但任务原文要求收到任务与终态均提供可验证 Owner 微信 `message_id`。当前操作指令明确禁止 WeChat,且不存在可安全验证的 target/message_id,因此不伪造通知,整体状态按强制门保守记为 BLOCKED。
@@ -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.
@@ -1,267 +0,0 @@
# 执行 GitOps 独立 Worktree 与统一运行身份迁移
```yaml
task_id: TASK-GITOPS-ISOLATED-WORKTREE-MIGRATION-20260714T233742UTC8
created_at: 2026-07-14T23:37:42+08:00
timezone: Asia/Shanghai
owner_approved: true
user_action_required: false
risk: high
production_business_change: false
```
## 目标
根治共享 worktree 与多身份写入导致的 ACL、checkout 和未跟踪文件覆盖问题。
实施:
1. 统一 GitOps/OpenClaw 写入身份为 `aiman:gitops`;
2. 统一 `UMask=0002`;
3. 为 GitOps 根目录设置 setgid 与默认 ACL;
4. 建立 bare repo 和每任务独立 worktree;
5. 启用任务启动前自检;
6. 验证 OpenClaw 与 ChatGPT 并发任务互不干扰;
7. 保留原共享 workspace 作为只读回滚源,禁止智能体继续在其中切换分支。
## 强制通知
收到任务后立即微信通知 Owner:
```text
【任务已接收】GitOps独立worktree与统一运行身份迁移开始。将暂停写入服务、建立独立任务工作树并验证并发执行;生产业务版本不变。
```
任务结束后无论 PASS、BLOCKED 或 ROLLED_BACK,立即微信汇报结果并记录 message_id。
## 1. 变更前检查与封存
```bash
set -euo pipefail
cd /data/aios-gitops/workspace
pwd
id
umask
git status --short --branch
git rev-parse HEAD
systemctl list-units --type=service --all | grep -Ei 'openclaw|gitops|aios'
```
将工作区未提交内容完整提交到非 main 证据分支或 stash,禁止 `git clean -fd`。
记录当前运行服务、用户、组、umask、PID、工作区 HEAD 和未跟踪文件清单。
## 2. 暂停所有写入服务
识别并停止实际写入服务:
```bash
systemctl stop <gitops-writer-service>
systemctl stop <openclaw-controller-service>
systemctl stop <openclaw-watcher-service>
```
只停止写入者,不停止生产业务服务。记录每条命令和退出码。
## 3. 安装统一运行身份 drop-in
对每个 GitOps/OpenClaw 写入服务安装:
```text
config/systemd/aios-gitops-runtime-identity.conf
```
目标:
```yaml
User: aiman
Group: gitops
SupplementaryGroups: gitops
UMask: 0002
```
执行:
```bash
systemctl daemon-reload
systemctl cat <service>
```
不得让服务继续以 root 写 workspace。
## 4. 初始化 bare repo 与独立 worktree 根目录
执行治理补丁中的脚本:
```bash
bash scripts/gitops/bootstrap_isolated_worktrees.sh
```
验收:
```yaml
bare_repo: /data/aios-gitops/repo.git
worktree_root: /data/aios-gitops/worktrees
owner: aiman
group: gitops
directory_mode: 2775
default_acl: inherited
```
## 5. 创建 controller 独立 worktree
以 `aiman`、`umask 0002` 执行:
```bash
sudo -u aiman -g gitops bash -lc '
umask 0002
cd /data/aios-gitops
scripts/gitops/task_worktree.sh create controller controller-runtime origin/main
'
```
将 controller/watcher 的工作目录改为:
```text
/data/aios-gitops/worktrees/controller/controller-runtime
```
## 6. 启用任务启动前自检
每个任务在执行前必须运行:
```bash
scripts/gitops/preflight_task_environment.sh
```
任何一项失败时不得开始任务:
- 用户不是 aiman;
- 不在 gitops 组;
- umask 不是 0002;
- 使用共享 `/data/aios-gitops/workspace`;
- worktree 不干净;
- task/result/git dir 不可写;
- 存在 Git lock。
## 7. 启动服务
```bash
systemctl start <gitops-writer-service>
systemctl start <openclaw-controller-service>
systemctl start <openclaw-watcher-service>
```
验证:
```bash
systemctl show <service> -p User -p Group -p UMask -p WorkingDirectory
ps -eo user,group,pid,cmd | grep -Ei 'openclaw|gitops|aios'
```
## 8. 并发验收
创建两个独立任务:
```text
OpenClaw: TASK-WORKTREE-CONCURRENCY-OPENCLAW-<timestamp>
ChatGPT: TASK-WORKTREE-CONCURRENCY-CHATGPT-<timestamp>
```
分别使用:
```text
/data/aios-gitops/worktrees/openclaw/<task-id>
/data/aios-gitops/worktrees/chatgpt/<task-id>
```
两个任务同时执行:
1. 写各自 `.agent/tasks/<agent>/`;
2. 写各自 `.agent/results/<agent>/`;
3. 创建深层子目录,验证默认 ACL;
4. commit;
5. push;
6. 不切换共享 workspace 分支。
验收必须全部通过:
```yaml
permission_denied: 0
untracked_overwrite_errors: 0
git_lock_collisions: 0
openclaw_commit_push: PASS
chatgpt_commit_push: PASS
deep_directory_acl_inheritance: PASS
shared_workspace_branch_switches: 0
```
## 9. 回滚
若迁移失败:
1. 停止新服务;
2. 保留失败 worktree 供审计;
3. 恢复 systemd drop-in 备份;
4. `daemon-reload`;
5. 恢复原服务状态;
6. 不删除 `/data/aios-gitops/workspace`;
7. 生产业务保持不变。
## 10. 结果文件
提交:
```text
.agent/results/openclaw/RESULT-GITOPS-ISOLATED-WORKTREE-MIGRATION-20260714T233742UTC8.md
```
首段必须包含:
```yaml
status: PASS | BLOCKED | ROLLED_BACK
task_id: TASK-GITOPS-ISOLATED-WORKTREE-MIGRATION-20260714T233742UTC8
created_at: 2026-07-14T23:37:42+08:00
timezone: Asia/Shanghai
user_action_required: false
runtime_identity:
user:
group:
umask:
bare_repo:
worktree_root:
controller_worktree:
services:
default_acl:
preflight:
concurrency_test:
openclaw_branch:
openclaw_commit:
chatgpt_branch:
chatgpt_commit:
permission_denied_count:
untracked_overwrite_count:
git_lock_collision_count:
owner_notifications:
received_message_id:
terminal_message_id:
rollback:
remaining_risks:
next_step:
```
提交并推送到独立执行分支。禁止直接推 main。
## 完成微信模板
PASS:
```text
【任务完成】GitOps独立worktree迁移已通过。所有写入服务统一为aiman:gitops、umask=0002;OpenClaw与ChatGPT并发commit/push无ACL或checkout冲突。生产业务版本未变化。
```
阻塞:
```text
【任务阻塞】GitOps独立worktree迁移未通过。原因=<具体服务/权限/并发验收>;已保留原workspace和回滚能力,生产业务未变化。
```
+89
View File
@@ -0,0 +1,89 @@
"""Append-only audit chain for AIOS V2R1."""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
import sqlite3
from typing import Any
from .tenant_context import canonical_json, isoformat, require_ident
class AuditLog:
def __init__(self, database: str | Path) -> None:
self.database = str(database)
with sqlite3.connect(self.database) as db:
db.execute("PRAGMA journal_mode=WAL")
db.execute(
"""
CREATE TABLE IF NOT EXISTS audit_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id TEXT NOT NULL,
trace_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload TEXT NOT NULL,
previous_hash TEXT NOT NULL,
event_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL
)
"""
)
def append(self, *, tenant_id: str, trace_id: str, event_type: str, payload: dict[str, Any]) -> str:
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
trace_id = require_ident("trace_id", trace_id)
event_type = require_ident("event_type", event_type)
with sqlite3.connect(self.database) as db:
previous = db.execute(
"SELECT event_hash FROM audit_events ORDER BY seq DESC LIMIT 1"
).fetchone()
previous_hash = previous[0] if previous else "GENESIS"
body = {
"tenant_id": tenant_id,
"trace_id": trace_id,
"event_type": event_type,
"payload": payload,
"previous_hash": previous_hash,
"created_at": isoformat(),
}
digest = hashlib.sha256(
canonical_json(body)
).hexdigest()
db.execute(
"INSERT INTO audit_events VALUES(NULL,?,?,?,?,?,?,?)",
(
tenant_id,
trace_id,
event_type,
json.dumps(payload, ensure_ascii=False),
previous_hash,
digest,
body["created_at"],
),
)
return digest
def verify(self) -> bool:
previous = "GENESIS"
with sqlite3.connect(self.database) as db:
rows = db.execute(
"SELECT tenant_id,trace_id,event_type,payload,previous_hash,event_hash,created_at "
"FROM audit_events ORDER BY seq"
)
for row in rows:
body = {
"tenant_id": row[0],
"trace_id": row[1],
"event_type": row[2],
"payload": json.loads(row[3]),
"previous_hash": row[4],
"created_at": row[6],
}
if row[4] != previous:
return False
digest = hashlib.sha256(canonical_json(body)).hexdigest()
if digest != row[5]:
return False
previous = digest
return True
@@ -0,0 +1,73 @@
"""AIOS V2R1 Tenant Cell lifecycle CLI primitives."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
import json
from pathlib import Path
from .tenant_context import ValidationError, require_ident
@dataclass(frozen=True)
class CellRecord:
tenant_id: str
cell_id: str
version: str
state: str
config_digest: str
created_at: str
class CellManager:
def __init__(self, registry: str | Path) -> None:
self.registry = Path(registry)
self.registry.parent.mkdir(parents=True, exist_ok=True)
if not self.registry.exists():
self.registry.write_text("{\"cells\":{}}\n", encoding="utf-8")
def _load(self) -> dict:
return json.loads(self.registry.read_text(encoding="utf-8"))
def _save(self, value: dict) -> None:
self.registry.write_text(
json.dumps(value, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def create(self, tenant_id: str, cell_id: str, version: str, digest: str) -> CellRecord:
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
cell_id = require_ident("cell_id", cell_id)
data = self._load()
if tenant_id in data["cells"]:
raise ValidationError("tenant already has a cell")
record = CellRecord(
tenant_id,
cell_id,
version,
"created",
digest,
datetime.now(timezone.utc).isoformat(),
)
data["cells"][tenant_id] = record.__dict__
self._save(data)
return record
def status(self, tenant_id: str) -> CellRecord:
data = self._load()
item = data["cells"].get(tenant_id)
if not item:
raise ValidationError("unknown tenant")
return CellRecord(**item)
def transition(self, tenant_id: str, state: str) -> CellRecord:
allowed = {"created", "starting", "healthy", "upgrading", "rollback", "failed"}
if state not in allowed:
raise ValidationError("invalid cell state")
data = self._load()
item = data["cells"].get(tenant_id)
if not item:
raise ValidationError("unknown tenant")
item["state"] = state
self._save(data)
return CellRecord(**item)
+48
View File
@@ -0,0 +1,48 @@
"""aios-cell command implementation for V2R1 Cell lifecycle."""
from __future__ import annotations
import argparse
import json
import sys
from .cell_manager import CellManager
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="aios-cell")
parser.add_argument("registry")
sub = parser.add_subparsers(dest="command", required=True)
create = sub.add_parser("create")
create.add_argument("tenant_id")
create.add_argument("cell_id")
create.add_argument("version")
create.add_argument("digest")
status = sub.add_parser("status")
status.add_argument("tenant_id")
transition = sub.add_parser("transition")
transition.add_argument("tenant_id")
transition.add_argument("state")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
manager = CellManager(args.registry)
if args.command == "create":
result = manager.create(args.tenant_id, args.cell_id, args.version, args.digest)
elif args.command == "status":
result = manager.status(args.tenant_id)
elif args.command == "transition":
result = manager.transition(args.tenant_id, args.state)
else:
return 2
print(json.dumps(result.__dict__, ensure_ascii=False, sort_keys=True))
return 0
if __name__ == "__main__":
sys.exit(main())
+106
View File
@@ -0,0 +1,106 @@
"""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]}
@@ -0,0 +1,142 @@
"""Tenant-partitioned Hermes shared agent fabric primitives."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
import sqlite3
from typing import Any
from .tenant_context import (
CanonicalMessage,
ConflictError,
ScopeError,
ValidationError,
isoformat,
require_ident,
)
@dataclass(frozen=True)
class FabricJob:
job_id: str
tenant_id: str
cell_id: str
profile_id: str
session_id: str
event_id: str
status: str = "queued"
def __post_init__(self) -> None:
for name in (
"job_id", "cell_id", "profile_id", "session_id", "event_id"
):
require_ident(name, getattr(self, name))
require_ident("tenant_id", self.tenant_id, tenant=True)
if self.status not in {"queued", "running", "completed", "failed", "cancelled"}:
raise ValidationError("invalid job status")
class HermesFabric:
"""Shared scheduler storage with mandatory tenant partition keys."""
def __init__(self, database: str | Path) -> None:
self.database = str(database)
with sqlite3.connect(self.database) as db:
db.execute("PRAGMA journal_mode=WAL")
db.execute("PRAGMA synchronous=FULL")
db.execute(
"""
CREATE TABLE IF NOT EXISTS fabric_jobs (
job_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
cell_id TEXT NOT NULL,
profile_id TEXT NOT NULL,
session_id TEXT NOT NULL,
event_id TEXT NOT NULL UNIQUE,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
db.execute(
"CREATE INDEX IF NOT EXISTS idx_fabric_tenant_queue "
"ON fabric_jobs(tenant_id, cell_id, status)"
)
def enqueue(
self,
*,
message: CanonicalMessage,
profile_id: str,
) -> FabricJob:
profile_id = require_ident("profile_id", profile_id)
if not message.command_capable:
raise ScopeError("non-command events cannot create execution jobs")
if not message.is_authenticated_human_command:
raise ScopeError("only authenticated human commands can create jobs")
job = FabricJob(
job_id=f"job-{message.event_id}",
tenant_id=message.tenant_id,
cell_id=message.cell_id,
profile_id=profile_id,
session_id=message.session_id,
event_id=message.event_id,
)
now = isoformat()
conflict: ConflictError | None = None
try:
with sqlite3.connect(self.database) as db:
db.execute("PRAGMA journal_mode=WAL")
db.execute(
"INSERT INTO fabric_jobs VALUES(?,?,?,?,?,?,?,?,?)",
(
job.job_id,
job.tenant_id,
job.cell_id,
job.profile_id,
job.session_id,
job.event_id,
job.status,
now,
now,
),
)
except sqlite3.IntegrityError:
conflict = ConflictError(
"fabric event already exists or violates queue uniqueness"
)
if conflict is not None:
raise conflict
return job
def claim(self, tenant_id: str, cell_id: str, profile_id: str) -> FabricJob | None:
require_ident("tenant_id", tenant_id, tenant=True)
require_ident("cell_id", cell_id)
require_ident("profile_id", profile_id)
with sqlite3.connect(self.database) as db:
db.execute("BEGIN IMMEDIATE")
row = db.execute(
"SELECT * FROM fabric_jobs WHERE tenant_id=? AND cell_id=? "
"AND profile_id=? AND status='queued' ORDER BY created_at LIMIT 1",
(tenant_id, cell_id, profile_id),
).fetchone()
if not row:
db.commit()
return None
db.execute(
"UPDATE fabric_jobs SET status='running', updated_at=? WHERE job_id=?",
(isoformat(), row[0]),
)
db.commit()
return FabricJob(*row[:7])
def health(self) -> dict[str, Any]:
with sqlite3.connect(self.database) as db:
count = db.execute("SELECT count(*) FROM fabric_jobs").fetchone()[0]
return {
"component": "hermes_fabric",
"jobs": count,
"checked_at": datetime.now(timezone.utc).isoformat(),
}
@@ -0,0 +1,47 @@
"""Hermes shared fabric profile isolation primitives."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import os
from .tenant_context import ValidationError, require_ident
@dataclass(frozen=True)
class HermesProfile:
tenant_id: str
cell_id: str
profile_id: str
hermes_home: Path
workspace: Path
def __post_init__(self) -> None:
require_ident("tenant_id", self.tenant_id, tenant=True)
for name in ("cell_id", "profile_id"):
require_ident(name, getattr(self, name))
if not self.hermes_home.is_absolute() or not self.workspace.is_absolute():
raise ValidationError("profile paths must be absolute")
@property
def environment(self) -> dict[str, str]:
return {
"HERMES_HOME": str(self.hermes_home),
"HOME": str(self.hermes_home),
"AIOS_TENANT_ID": self.tenant_id,
"AIOS_CELL_ID": self.cell_id,
"AIOS_PROFILE_ID": self.profile_id,
}
def prepare(self) -> None:
self.hermes_home.mkdir(parents=True, exist_ok=True)
self.workspace.mkdir(parents=True, exist_ok=True)
os.chmod(self.hermes_home, 0o700)
os.chmod(self.workspace, 0o700)
def assert_path_allowed(self, path: str | Path) -> None:
target = Path(path).resolve()
for root in (self.hermes_home.resolve(), self.workspace.resolve()):
if target == root or root in target.parents:
return
raise ValidationError("path escapes profile isolation boundary")
@@ -0,0 +1,60 @@
"""Tenant-scoped memory namespace and access policy for AIOS V2R1."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import PurePosixPath
from typing import Literal
from .tenant_context import ScopeError, ValidationError, require_ident
MemoryKind = Literal["session", "profile", "cell", "tenant", "shared_artifact"]
@dataclass(frozen=True)
class MemoryScope:
tenant_id: str
cell_id: str
profile_id: str
session_id: str | None
kind: MemoryKind
def __post_init__(self) -> None:
require_ident("tenant_id", self.tenant_id, tenant=True)
for name in ("cell_id", "profile_id"):
require_ident(name, getattr(self, name))
if self.session_id is not None:
require_ident("session_id", self.session_id)
if self.kind not in {"session", "profile", "cell", "tenant", "shared_artifact"}:
raise ValidationError("invalid memory kind")
if self.kind == "session" and self.session_id is None:
raise ValidationError("session memory requires session_id")
@property
def namespace(self) -> str:
parts = ["tenant", self.tenant_id, "cell", self.cell_id]
if self.kind in {"profile", "session"}:
parts.extend(["profile", self.profile_id])
if self.kind == "session":
parts.extend(["session", str(self.session_id)])
parts.extend(["memory", self.kind])
return str(PurePosixPath(*parts))
def assert_access(self, requested: "MemoryScope", *, write: bool = False) -> None:
if self.tenant_id != requested.tenant_id:
raise ScopeError("cross-tenant memory access denied")
if self.cell_id != requested.cell_id:
raise ScopeError("cross-cell memory access denied")
if requested.kind == "shared_artifact":
if write:
raise ScopeError("shared artifacts are immutable")
return
if self.profile_id != requested.profile_id and requested.kind in {"profile", "session"}:
raise ScopeError("cross-profile memory access denied")
if requested.kind == "session" and self.session_id != requested.session_id:
raise ScopeError("cross-session memory access denied")
def qualify_memory_key(scope: MemoryScope, key: str) -> str:
key = require_ident("memory_key", key)
return f"{scope.namespace}/{key}"
@@ -0,0 +1,172 @@
"""Tenant-scoped append-only message ledger and exact quote resolver."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
import os
from pathlib import Path
import sqlite3
from typing import Iterable
from .tenant_context import (
CanonicalMessage,
ConflictError,
ScopeError,
ValidationError,
isoformat,
normalize_message_id,
parse_time,
utc_now,
)
@dataclass(frozen=True)
class LedgerRecord:
tenant_id: str
cell_id: str
channel: str
channel_account_id: str
peer_id: str
direction: str
message_id: str
event_id: str
body: str
occurred_at: str
expires_at: str
class MessageLedger:
"""Exact-ID ledger.
The resolver never uses time windows, prefixes, similarity, or nearest-message
fallbacks. Ambiguity and absence fail closed.
"""
def __init__(self, database: str | Path, *, ttl_days: int = 7) -> None:
if not 1 <= ttl_days <= 365:
raise ValidationError("ttl_days must be between 1 and 365")
self.database = Path(database)
self.ttl_days = ttl_days
self.database.parent.mkdir(parents=True, exist_ok=True)
self._initialize()
try:
os.chmod(self.database, 0o600)
except OSError:
pass
def _connect(self) -> sqlite3.Connection:
db = sqlite3.connect(str(self.database), timeout=30)
db.row_factory = sqlite3.Row
db.execute("PRAGMA journal_mode=WAL")
db.execute("PRAGMA synchronous=FULL")
db.execute("PRAGMA busy_timeout=30000")
return db
def _initialize(self) -> None:
with self._connect() as db:
db.executescript(
"""
CREATE TABLE IF NOT EXISTS message_ledger (
tenant_id TEXT NOT NULL,
cell_id TEXT NOT NULL,
channel TEXT NOT NULL,
channel_account_id TEXT NOT NULL,
peer_id TEXT NOT NULL,
direction TEXT NOT NULL CHECK(direction IN ('inbound','outbound','internal')),
message_id TEXT NOT NULL,
event_id TEXT NOT NULL,
body TEXT NOT NULL,
occurred_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY(
tenant_id, cell_id, channel, channel_account_id,
peer_id, direction, message_id
),
UNIQUE(tenant_id, cell_id, event_id)
);
CREATE INDEX IF NOT EXISTS idx_message_reply_lookup ON message_ledger(
tenant_id, cell_id, channel, channel_account_id,
peer_id, message_id, expires_at
);
"""
)
def remember(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,
)
with self._connect() 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["event_id"] != message.event_id or 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)
def resolve_quote(self, message: CanonicalMessage) -> str:
if message.quoted_body is not None:
return message.quoted_body
if message.reply_to_message_id is None:
raise ScopeError("quoted_text_unavailable")
reply_id = normalize_message_id(
message.reply_to_message_id,
field_name="reply_to_message_id",
)
with self._connect() as db:
rows = db.execute(
"SELECT direction, body, event_id FROM message_ledger "
"WHERE tenant_id=? AND cell_id=? AND channel=? AND channel_account_id=? "
"AND peer_id=? AND message_id=? AND expires_at>?",
(
message.tenant_id,
message.cell_id,
message.channel,
message.channel_account_id,
message.peer_id,
reply_id,
isoformat(),
),
).fetchall()
if len(rows) == 1:
return str(rows[0]["body"])
if len(rows) > 1:
raise ConflictError("ambiguous reply ID across message directions")
raise ScopeError("quoted_text_unavailable")
def purge_expired(self) -> int:
with self._connect() as db:
cursor = db.execute(
"DELETE FROM message_ledger WHERE expires_at<=?", (isoformat(),)
)
return cursor.rowcount
def integrity_check(self) -> str:
with self._connect() as db:
return str(db.execute("PRAGMA integrity_check").fetchone()[0])
def count(self) -> int:
with self._connect() as db:
return int(db.execute("SELECT count(*) FROM message_ledger").fetchone()[0])
+335
View File
@@ -0,0 +1,335 @@
"""Authoritative tenant registry and fail-closed message router."""
from __future__ import annotations
from contextlib import contextmanager
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 self._connect() 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 self._connect() 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 self._connect() 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
@@ -0,0 +1,33 @@
"""Tenant and Cell scoped secret reference model.
This module never stores secret values. It stores ownership and retrieval scope
metadata for integration with a real secret broker.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from .tenant_context import ValidationError, require_ident
@dataclass(frozen=True)
class SecretRef:
secret_id: str
tenant_id: str
cell_id: str
kind: Literal["channel", "model", "tool", "runtime"]
version: str
broker_path: str
def __post_init__(self) -> None:
for name in ("secret_id", "cell_id", "version"):
require_ident(name, getattr(self, name))
require_ident("tenant_id", self.tenant_id, tenant=True)
if self.kind not in {"channel", "model", "tool", "runtime"}:
raise ValidationError("invalid secret kind")
if not self.broker_path.startswith("secret://"):
raise ValidationError("secret broker path required")
def can_be_used_by(self, *, tenant_id: str, cell_id: str) -> bool:
return self.tenant_id == tenant_id and self.cell_id == cell_id
@@ -0,0 +1,316 @@
"""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",
)
-59
View File
@@ -1,59 +0,0 @@
version: 1
policy_id: gitops-isolated-worktree-governance-20260714T232948UTC8
runtime_identity:
user: aiman
group: gitops
umask: "0002"
prohibit_root_workspace_writes: true
repository:
source_worktree: /data/aios-gitops/workspace
bare_repo: /data/aios-gitops/repo.git
worktree_root: /data/aios-gitops/worktrees
remote: origin
worktrees:
controller: /data/aios-gitops/worktrees/controller
task_pattern: /data/aios-gitops/worktrees/{agent}/{task_id}
allowed_agents:
- openclaw
- chatgpt
- controller
require_clean_start: true
remove_after_successful_push: true
retain_on_failure: true
permissions:
owner: aiman
group: gitops
directory_mode: "2775"
file_mode: "0664"
default_acl:
user:aiman: rwx
group:gitops: rwx
apply_to:
- /data/aios-gitops
- /data/aios-gitops/worktrees
preflight:
required_user: aiman
required_group: gitops
required_umask: "0002"
require_writable_git_dir: true
require_writable_task_dir: true
require_writable_result_dir: true
reject_shared_source_worktree: true
reject_existing_git_lock: true
notifications:
owner_on_received: required
owner_on_terminal: required
preferred_channel: weixin
redact_secrets: true
rollback:
preserve_source_worktree: true
remove_created_worktrees: true
restore_service_dropins: true
restore_service_state: true
@@ -1,14 +0,0 @@
# Install as a systemd drop-in for GitOps/OpenClaw writer services.
# Example: /etc/systemd/system/<service>.service.d/10-runtime-identity.conf
[Service]
User=aiman
Group=gitops
SupplementaryGroups=gitops
UMask=0002
Environment=AIOS_GITOPS_ROOT=/data/aios-gitops
Environment=AIOS_BARE_REPO=/data/aios-gitops/repo.git
Environment=AIOS_WORKTREE_ROOT=/data/aios-gitops/worktrees
Environment=AIOS_RUNTIME_USER=aiman
Environment=AIOS_RUNTIME_GROUP=gitops
Environment=AIOS_RUNTIME_UMASK=0002
+20
View File
@@ -0,0 +1,20 @@
#!/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
@@ -0,0 +1,29 @@
#!/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
@@ -0,0 +1,27 @@
#!/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
@@ -0,0 +1,26 @@
# 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.
@@ -0,0 +1,49 @@
# AIOS V2R1 OpenClaw Tenant Cell + Hermes Shared Agent Fabric Architecture
## Status
Release train: V2R1C0
Purpose: replace shared runtime trust assumptions with tenant-isolated Cell architecture.
## Principles
1. OpenClaw Gateway is a trust boundary.
2. Each production tenant receives an isolated Cell.
3. Hermes provides shared scheduling/fabric capability but not shared tenant state.
4. Tenant identity, origin, session and task scope are mandatory in every event.
5. Unknown provenance fails closed.
## Target Components
- Tenant Router
- OpenClaw Cell Manager
- Canonical Message Envelope
- Tenant Inbox/Outbox Ledger
- Secret Scope Manager
- Memory Namespace Manager
- Hermes Profile Worker Fabric
- Sandbox Execution Layer
- Audit Event Pipeline
## Migration Order
Phase 1: Tenant Router + Cell Manager
Phase 2: Canonical Message and Policy Layer
Phase 3: Hermes Shared Agent Fabric
Phase 4: Memory, Secret and Audit isolation
Phase 5: Production tenant migration
## Release Gate
A V2R1 production release requires:
- tenant isolation tests passed
- message provenance tests passed
- runtime commit matches release commit
- OpenClaw plugin runtime inspection passed
- rollback verified
@@ -1,113 +0,0 @@
# 微信文本-图片缓存隔离事故报告
## 结论
本次事故已经按最新备份链路回滚并完成第二轮评审修复。`3d9782fc346d1e7defde090ab8637e62ab28ab8a` 进一步修复了英文诊断文本和未确认引用文本误入图片路径的问题,`706c0b0` 则是后续最小修复提交。当前文档只记录证据、部署和验收边界,不把“真实 owner inbound 已完成”写成结论。
## 现象
- 纯文本消息被误答成旧图片回复,属于 stale-image reply on pure text。
- 现场证据显示 `types=1` 且 `hasMedia=false`,但后续又进入 `message_type=image`、`image_count=0`、`implicit_image_message`,最终落到 `cached_image_expired`。
- 这说明“诊断文本”被误判成图片意图后,又继承了 sender 级旧图缓存。
## 根因
- `fixes/patch_openclaw_weixin_hermes.py:768-839` 把文本意图判断和缓存回退放在同一条链上。
- `looksLikeHermesImageFollowup()` 会把含图片关键字的诊断文本推进 image intent。
- `resolveHermesFollowupImageContext()` 随后会读取 sender 级缓存;一旦缓存命中失败或过期,就会落到 `cached_image_expired`,从而让纯文本沾上旧图上下文。
- `handleHermesImplicitImage()` 继续把消息送入 `handleHermes(..., "implicit_image_message")`,所以误判会沿着同一条图片处理通道传播。
## 护栏不变量
- 图片处理上下文必须来自当前消息图片、已确认的引用图片,或精确匹配的缓存图片 ID;缺少这三类证据时,纯文本不能进入 `handleHermesImplicitImage()`。
- 未确认的引用文本不能继承 sender 级旧图缓存,必须返回 `referenced_image_not_confirmed`。
- 只有精确缓存图片 ID 命中且缓存仍有效时,才允许走 `resolveHermesCachedImageEntry()` 的 `cached_image_matched` 路径。
- `/hermes` 这类 slash 路径保持原有分流,slash/channel 面不变。
- 这次修复的守卫目标,是把中英文诊断文本、未确认引用文本与图片缓存回退拆开,避免再把普通文本提升成图片意图。
## TDD 与幂等性
- `66d637fc0364a617c7b9fd8cd9baa68fb0368675`(`fix(weixin): isolate text from stale image cache`)引入了隔离修复和 `tests/test_weixin_image_followup_intent.py`。
- `2de29c59bb946cc3db3e14e8a06270391ede8401`(`fix(weixin): add process-only runtime patch mode`)继续收紧 process-only 运行模式,保持回放幂等。
- `3d9782fc346d1e7defde090ab8637e62ab28ab8a`(`fix(weixin): require confirmed image reference context`)补齐英文诊断文本和未确认引用文本用例,并把图片引用收紧到已确认上下文。
- 用户实测的 TDD 路径为 RED `8 PASS / 1 FAIL`,随后 GREEN `9 PASS`。
- 最小修复提交为 `706c0b0`,对应 quoted pure text 的最后一层收敛修复。
- 第二轮回归结果为 `8 PASS`;node check 同样通过,重复应用补丁保持幂等。
- 生产 npm 分发侧本身缺少 `tsconfig/tests`,baseline 与 patched 共享这一限制,所以这里不把分发目录的缺口当成回归点;验收以 node check 和 8 tests 为准。
## 用户实测补充
- 普通文本两项为 PASS。
- quoted pure text 为 FAIL,日志证据包含 `ref_msg.message_item.type=0/NONE`、quoted id、`implicit_image_message`、`referenced_image_unresolved`。
- 这组证据说明 quoted pure text 没有被当成真正的图片 follow-up,且没有越过引用确认边界。
## 回滚与备份
- 回滚按最新备份执行,基线指向 `/data/aios-gitops/runtime/latest-backup-dir.txt` 所示的最新备份目录。
- 备份路径链路按供给证据记录为 `T064506Z`、`T074212Z`、`T081658Z`。
- 第一轮恢复后的部署过程哈希记录为 `e9625d12` 和 `c00c58f8`。
- v2 部署备份为 `/var/backups/aios/weixin-text-cache-isolation-20260711T094908Z`。
- v2 部署文件哈希为 TS `024c6498284d1871b048f6157d5d3dbd4d34e54b53a05afd744b00458ceeb79f`、JS `d73b15d6752056192a1d1939b04107334419785b41175b39f1cbc1132ac65c47`;slash/channel 口径保持不变。
- 首次部署因为 `-- 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`。
- v2 现场/供给证据为 `active`、监听 `18789`、`error_count=0`、`channel gate pass`、`cache preserved`。
- 最终 read-only gate 通过,说明部署后运行态未出现新的插件错误,channel 状态和缓存均保持预期。
- quoted pure text 的第二次 owner live acceptance 仍为 PENDING。
## 未完成的真实验收
- owner 纯文本
- diagnostic text
- genuine image-followup message
- quoted pure text 的第二次 owner live acceptance
以上四类消息仍需要由 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。
- 报告里没有任何凭据、密钥或原始敏感 ID。
@@ -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,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`.
-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())
File diff suppressed because it is too large Load Diff
@@ -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())
+86
View File
@@ -0,0 +1,86 @@
import {
classifyRun,
decideToolCall,
runKey,
} from "./policy.js";
const activeRuns = new Map();
const MAX_RUN_AGE_MS = 60 * 60 * 1000;
function prune(now = Date.now()) {
for (const [key, value] of activeRuns.entries()) {
if (now - value.recordedAt > MAX_RUN_AGE_MS) activeRuns.delete(key);
}
}
export default {
id: "aios-tenant-boundary",
name: "AIOS Tenant Boundary",
description: "Fail-closed user-origin and side-effect policy for Tenant Cells.",
register(api) {
api.on(
"before_agent_run",
async (event, ctx) => {
prune();
const config = event.context?.pluginConfig ?? api.pluginConfig ?? {};
const trust = classifyRun(ctx, config);
const key = runKey(event, ctx);
if (key) {
activeRuns.set(key, { ...trust, recordedAt: Date.now() });
}
if (
config.blockUnknownAgentRuns === true &&
trust.trusted !== true &&
trust.reason !== "job_id_present"
) {
return {
outcome: "block",
reason: trust.reason,
message: "This message cannot be verified as coming from the active authenticated user.",
};
}
return { outcome: "pass" };
},
{ priority: 100 },
);
api.on(
"before_tool_call",
async (event, ctx) => {
prune();
const config = event.context?.pluginConfig ?? api.pluginConfig ?? {};
const key = runKey(event, ctx);
const trust = key ? activeRuns.get(key) : undefined;
const decision = decideToolCall(event.toolName, trust, config);
if (decision.action === "block") {
return {
block: true,
blockReason: `AIOS V2R1 origin policy denied tool call: ${decision.code}`,
};
}
if (decision.action === "approval") {
return {
requireApproval: {
title: `Approve ${String(event.toolName)}`,
description: "Authenticated user requested a side-effecting tool call in this Tenant Cell.",
severity: "warning",
timeoutMs: Number(config.approvalTimeoutMs ?? 60000),
allowedDecisions: ["allow-once", "deny"],
pluginId: "aios-tenant-boundary",
},
};
}
return undefined;
},
{ priority: 100 },
);
api.on("agent_end", async (event, ctx) => {
const key = runKey(event, ctx);
if (key) activeRuns.delete(key);
});
},
};
+71
View File
@@ -0,0 +1,71 @@
const DEFAULT_READ_ONLY_TOOLS = Object.freeze([
"read", "grep", "glob", "find", "ls", "web_search", "web_fetch",
"memory_search", "sessions_list", "sessions_history", "session_status",
]);
function strings(value) {
return Array.isArray(value)
? value.filter((item) => typeof item === "string" && item.trim())
.map((item) => item.trim())
: [];
}
export function resolveSenderId(ctx = {}) {
const direct = typeof ctx.senderId === "string" ? ctx.senderId.trim() : "";
const nested = typeof ctx.channelContext?.sender?.id === "string"
? ctx.channelContext.sender.id.trim()
: "";
return direct || nested || undefined;
}
export function classifyRun(ctx = {}, config = {}) {
const senderId = resolveSenderId(ctx);
const trusted = new Set(strings(config.trustedSenderIds));
const blocked = new Set(strings(config.blockedSenderIds));
if (typeof ctx.jobId === "string" && ctx.jobId) {
return { trusted: false, origin: "automation", reason: "job_id_present" };
}
if (!senderId) {
return { trusted: false, origin: "system_or_unknown", reason: "sender_id_missing" };
}
if (blocked.has(senderId)) {
return { trusted: false, origin: "blocked_sender", reason: "sender_blocked", senderId };
}
if (config.requireExplicitTrustedSenders !== false && !trusted.has(senderId)) {
return { trusted: false, origin: "untrusted_sender", reason: "sender_not_allowlisted", senderId };
}
return {
trusted: true,
origin: "authenticated_channel_user",
reason: "sender_verified",
senderId,
};
}
export function decideToolCall(toolName, runTrust, config = {}) {
const name = typeof toolName === "string" ? toolName.trim().toLowerCase() : "";
const configured = strings(config.readOnlyTools).map((item) => item.toLowerCase());
const readOnly = new Set(configured.length ? configured : DEFAULT_READ_ONLY_TOOLS);
if (!name) {
return { action: "block", code: "tool_name_missing" };
}
if (readOnly.has(name)) {
return { action: "allow", code: "read_only" };
}
if (!runTrust || runTrust.trusted !== true) {
return {
action: "block",
code: runTrust?.reason || "run_provenance_missing",
};
}
if (config.requireApprovalForHumanSideEffects === false) {
return { action: "allow", code: "trusted_side_effect" };
}
return { action: "approval", code: "human_side_effect" };
}
export function runKey(event = {}, ctx = {}) {
const key = event.runId || ctx.runId;
return typeof key === "string" && key.trim() ? key.trim() : undefined;
}
@@ -0,0 +1,18 @@
{
"id": "aios-tenant-boundary",
"name": "AIOS Tenant Boundary",
"description": "Enforces AIOS V2R1 tenant provenance and tool policy boundaries.",
"version": "2.1.0-rc1",
"entry": "dist/index.js",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"requireExplicitTrustedSenders": { "type": "boolean", "default": true },
"trustedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
"blockedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
"readOnlyTools": { "type": "array", "items": { "type": "string" } },
"requireApprovalForHumanSideEffects": { "type": "boolean", "default": true }
}
}
}
@@ -0,0 +1,18 @@
{
"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,9 +0,0 @@
{
"id": "patch-1784043007770-e97206c3",
"patch_name": "gitops-isolated-worktree-governance-20260714T232948UTC8",
"branch": "patch/gitops-isolated-worktree-governance-20260714T232948UTC8",
"risk": "high",
"description": "Root-cause remediation for recurring ACL and shared-worktree failures: unified aiman:gitops runtime identity, UMask=0002, inherited default ACLs, per-task isolated Git worktrees, startup preflight, migration/rollback scripts, systemd drop-in templates, audit evidence, and an OpenClaw execution task. No production business deployment in this patch.",
"created_by": "chatgpt-gitops-bot",
"created_at": "2026-07-14T15:30:07Z"
}
@@ -1,9 +1,9 @@
{ {
"type": "branch", "type": "branch",
"branch": "deploy/v1r4c6-production-20260711", "branch": "release/v2r1c0-tenant-cell-fabric-20260712",
"base": "main", "base": "main",
"created_by": "chatgpt-gitops-bot", "created_by": "chatgpt-gitops-bot",
"created_at": "2026-07-11T13:10:51Z", "created_at": "2026-07-12T14:39:23Z",
"direct_main_push": false, "direct_main_push": false,
"owner_approval_required": true "owner_approval_required": true
} }
@@ -1,9 +0,0 @@
{
"type": "branch",
"branch": "task/codex/gitops-isolated-worktree-migration-20260715T001652UTC8",
"base": "patch/gitops-isolated-worktree-governance-20260714T232948UTC8",
"created_by": "chatgpt-gitops-bot",
"created_at": "2026-07-14T16:17:28Z",
"direct_main_push": false,
"owner_approval_required": true
}
+36
View File
@@ -0,0 +1,36 @@
# 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.
+72
View File
@@ -0,0 +1,72 @@
# AIOS V2R1C0 RC1 Live Status
Last verified: 2026-07-13 Europe/Amsterdam
## Source of truth
- Release branch: `release/v2r1c0-tenant-cell-fabric-20260712`
- Verified branch tree: `6569065`
- Current promoted baseline: `V1R4C6`
- Production baseline state: not yet promoted to V2R1C0
This file is the single human-readable progress page for V2R1C0 RC1. Update it in the same commit that changes a release gate.
## Overall status
`DEVELOPMENT COMPLETE / EXECUTABLE ACCEPTANCE INCOMPLETE / NOT DEPLOYED`
## Component status
| Workstream | Status | Evidence | Remaining gate |
|---|---|---|---|
| Architecture baseline | Done | `docs/architecture/AIOS_V2R1_OpenClaw_Tenant_Cell_Hermes_Shared_Agent_Fabric_Architecture.md` | none |
| Tenant Context | Done | `active-runtime/app/aios_v2r1/tenant_context.py` | executable security tests |
| Canonical Message | Done | `tenant_context.py` | channel adapter integration test |
| Tenant Router | Done | `router.py` | cross-tenant executable tests |
| Cell lifecycle adapter | Done | `fleet.py`, `cell_manager.py`, `cli.py` | real OpenClaw Fleet probe |
| Message Ledger | Done | `message_ledger.py` | concurrency and restart tests |
| Hermes Fabric | Done | `hermes_fabric.py` | worker/profile runtime test |
| Memory isolation | Done | `memory_scope.py` | cross-tenant executable tests |
| Secret scope | Done | `secret_scope.py` | secret broker integration |
| Audit chain | Done | `audit.py` | tamper executable test |
| Hermes Profile isolation | Done | `hermes_profile.py` | container/profile runtime test |
| OpenClaw boundary plugin | Code complete | `openclaw/plugins/aios-tenant-boundary/` | install + runtime hook inspection |
| Python automated tests | Blocked | no committed executable suite | add and run tests |
| Node plugin automated tests | Blocked | no committed executable suite | add and run tests |
| Staging Cell deployment | Not started | none | tests must pass first |
| Rollback rehearsal | Not started | none | staging deployment required |
| Merge to `main` | Not started | release branch differs from main | owner approval after gates pass |
| RC tag | Not created | no V2R1 tag | owner approval |
| Production deployment | Not started | baseline remains V1R4C6 | owner production approval |
## Release gates
- [ ] Python executable tests committed and passing
- [ ] Node executable tests committed and passing
- [ ] OpenClaw plugin installs successfully
- [ ] Runtime inspection proves hooks registered
- [ ] Tenant Cell status and doctor pass
- [ ] Staging message and tool-policy smoke tests pass
- [ ] Rollback succeeds
- [ ] Running commit matches approved release commit
- [ ] Owner approves merge, tag and production cutover
## Parallel work ownership
- Track A: Python isolation and message tests
- Track B: OpenClaw plugin tests and runtime inspection
- Track C: Cell staging deployment, health and rollback
- Track D: release evidence, digests and final acceptance report
## Update rule
Every worker must update this page with:
1. exact command,
2. exit code,
3. test counts,
4. commit hash,
5. artifact digest,
6. blocker or PASS result.
No item may be marked Done from prose-only reports.
@@ -1,47 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="${AIOS_GITOPS_ROOT:-/data/aios-gitops}"
SOURCE="${AIOS_SOURCE_WORKTREE:-$ROOT/workspace}"
BARE="${AIOS_BARE_REPO:-$ROOT/repo.git}"
WORKTREES="${AIOS_WORKTREE_ROOT:-$ROOT/worktrees}"
RUNTIME_USER="${AIOS_RUNTIME_USER:-aiman}"
RUNTIME_GROUP="${AIOS_RUNTIME_GROUP:-gitops}"
if [[ "$(id -u)" -ne 0 ]]; then
echo "ERROR: bootstrap must run as root" >&2
exit 2
fi
getent passwd "$RUNTIME_USER" >/dev/null
getent group "$RUNTIME_GROUP" >/dev/null
[[ -d "$SOURCE/.git" || -f "$SOURCE/.git" ]] || {
echo "ERROR: source worktree not found: $SOURCE" >&2
exit 3
}
install -d -o "$RUNTIME_USER" -g "$RUNTIME_GROUP" -m 2775 "$ROOT" "$WORKTREES"
install -d -o "$RUNTIME_USER" -g "$RUNTIME_GROUP" -m 2775 \
"$WORKTREES/openclaw" "$WORKTREES/chatgpt" "$WORKTREES/controller"
if [[ ! -d "$BARE" ]]; then
sudo -u "$RUNTIME_USER" -g "$RUNTIME_GROUP" git clone --bare "$SOURCE" "$BARE"
fi
chown -R "$RUNTIME_USER:$RUNTIME_GROUP" "$BARE" "$WORKTREES"
find "$BARE" "$WORKTREES" -type d -exec chmod 2775 {} +
find "$BARE" "$WORKTREES" -type f -exec chmod ug+rw {} +
setfacl -R -m "u:${RUNTIME_USER}:rwX,g:${RUNTIME_GROUP}:rwX,m::rwX" "$BARE" "$WORKTREES"
find "$BARE" "$WORKTREES" -type d -exec \
setfacl -m "d:u:${RUNTIME_USER}:rwx,d:g:${RUNTIME_GROUP}:rwx,d:m::rwx" {} +
cat <<EOF
bootstrap_status: PASS
root: $ROOT
source_worktree: $SOURCE
bare_repo: $BARE
worktree_root: $WORKTREES
runtime_user: $RUNTIME_USER
runtime_group: $RUNTIME_GROUP
EOF
@@ -1,48 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
EXPECTED_USER="${AIOS_RUNTIME_USER:-aiman}"
EXPECTED_GROUP="${AIOS_RUNTIME_GROUP:-gitops}"
EXPECTED_UMASK="${AIOS_RUNTIME_UMASK:-0002}"
TASK_DIR="${AIOS_TASK_DIR:-.agent/tasks}"
RESULT_DIR="${AIOS_RESULT_DIR:-.agent/results}"
fail() {
echo "preflight_status: FAIL"
echo "reason: $1"
exit 1
}
USER_NOW="$(id -un)"
GROUPS_NOW="$(id -Gn)"
UMASK_NOW="$(umask)"
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
GIT_DIR="$(git rev-parse --git-dir 2>/dev/null || true)"
BRANCH="$(git branch --show-current 2>/dev/null || true)"
[[ "$USER_NOW" == "$EXPECTED_USER" ]] || fail "runtime user is $USER_NOW, expected $EXPECTED_USER"
[[ " $GROUPS_NOW " == *" $EXPECTED_GROUP "* ]] || fail "runtime user is not in $EXPECTED_GROUP"
[[ "$UMASK_NOW" == "$EXPECTED_UMASK" || "$UMASK_NOW" == "002" ]] || fail "umask is $UMASK_NOW, expected $EXPECTED_UMASK"
[[ -n "$ROOT" ]] || fail "not inside a git worktree"
[[ "$ROOT" == /data/aios-gitops/worktrees/* ]] || fail "shared source worktree is prohibited: $ROOT"
[[ -w "$ROOT" ]] || fail "worktree root is not writable"
[[ -w "$GIT_DIR" ]] || fail "git dir is not writable: $GIT_DIR"
[[ ! -e "$GIT_DIR/index.lock" ]] || fail "git index lock exists"
install -d -m 2775 "$TASK_DIR" "$RESULT_DIR"
[[ -w "$TASK_DIR" ]] || fail "task directory is not writable: $TASK_DIR"
[[ -w "$RESULT_DIR" ]] || fail "result directory is not writable: $RESULT_DIR"
[[ -z "$(git status --porcelain)" ]] || fail "worktree is not clean at task start"
cat <<EOF
preflight_status: PASS
runtime_user: $USER_NOW
runtime_groups: $GROUPS_NOW
umask: $UMASK_NOW
worktree: $ROOT
git_dir: $GIT_DIR
branch: $BRANCH
workspace_clean: true
task_path_writable: true
result_path_writable: true
git_lock_present: false
EOF
-73
View File
@@ -1,73 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-}"
AGENT="${2:-}"
TASK_ID="${3:-}"
BASE_REF="${4:-origin/main}"
ROOT="${AIOS_GITOPS_ROOT:-/data/aios-gitops}"
BARE="${AIOS_BARE_REPO:-$ROOT/repo.git}"
WORKTREES="${AIOS_WORKTREE_ROOT:-$ROOT/worktrees}"
RUNTIME_USER="${AIOS_RUNTIME_USER:-aiman}"
RUNTIME_GROUP="${AIOS_RUNTIME_GROUP:-gitops}"
usage() {
echo "usage: $0 create|remove <openclaw|chatgpt|controller> <task-id> [base-ref]" >&2
exit 64
}
[[ "$ACTION" =~ ^(create|remove)$ ]] || usage
[[ "$AGENT" =~ ^(openclaw|chatgpt|controller)$ ]] || usage
[[ "$TASK_ID" =~ ^[A-Za-z0-9._-]+$ ]] || {
echo "ERROR: invalid task id" >&2
exit 65
}
[[ "$(id -un)" == "$RUNTIME_USER" ]] || {
echo "ERROR: must run as $RUNTIME_USER" >&2
exit 66
}
[[ "$(id -gn)" == "$RUNTIME_GROUP" || " $(id -Gn) " == *" $RUNTIME_GROUP "* ]] || {
echo "ERROR: user must be in $RUNTIME_GROUP" >&2
exit 67
}
[[ "$(umask)" == "0002" || "$(umask)" == "002" ]] || {
echo "ERROR: umask must be 0002" >&2
exit 68
}
[[ -d "$BARE" ]] || {
echo "ERROR: bare repository missing: $BARE" >&2
exit 69
}
PATHNAME="$WORKTREES/$AGENT/$TASK_ID"
BRANCH="task/$AGENT/$TASK_ID"
case "$ACTION" in
create)
[[ ! -e "$PATHNAME" ]] || {
echo "ERROR: worktree already exists: $PATHNAME" >&2
exit 70
}
git --git-dir="$BARE" fetch --all --prune
git --git-dir="$BARE" worktree add -b "$BRANCH" "$PATHNAME" "$BASE_REF"
install -d -m 2775 "$PATHNAME/.agent/tasks/$AGENT" "$PATHNAME/.agent/results/$AGENT"
setfacl -m "u:${RUNTIME_USER}:rwx,g:${RUNTIME_GROUP}:rwx,m::rwx" "$PATHNAME"
find "$PATHNAME" -type d -exec chmod g+s {} +
find "$PATHNAME" -type d -exec \
setfacl -m "d:u:${RUNTIME_USER}:rwx,d:g:${RUNTIME_GROUP}:rwx,d:m::rwx" {} +
printf 'worktree: %s\nbranch: %s\nbase: %s\n' "$PATHNAME" "$BRANCH" "$BASE_REF"
;;
remove)
[[ -d "$PATHNAME" ]] || {
echo "ERROR: worktree not found: $PATHNAME" >&2
exit 71
}
[[ -z "$(git -C "$PATHNAME" status --porcelain)" ]] || {
echo "ERROR: worktree is not clean; retain for investigation" >&2
exit 72
}
git --git-dir="$BARE" worktree remove "$PATHNAME"
printf 'removed: %s\n' "$PATHNAME"
;;
esac
-63
View File
@@ -1,63 +0,0 @@
from __future__ import annotations
import importlib.util
import json
import subprocess
from pathlib import Path
PATCHER_PATH = Path(__file__).resolve().parents[1] / 'fixes' / 'patch_openclaw_weixin_bigint.py'
def load_patcher():
spec = importlib.util.spec_from_file_location('patch_openclaw_weixin_bigint', PATCHER_PATH)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_api_patch_is_idempotent_and_scoped() -> None:
patcher = load_patcher()
ts = '\n'.join([
'import { fileURLToPath } from "node:url";',
'import { redactBody, redactUrl } from "../util/redact.js";',
'const resp: GetUpdatesResp = JSON.parse(rawText);',
])
js = '\n'.join([
'import { fileURLToPath } from "node:url";',
'import { redactBody, redactUrl } from "../util/redact.js";',
'const resp = JSON.parse(rawText);',
])
types = 'export interface WeixinMessage {\n message_id?: number;\n}\n'
ts_once = patcher.patch_api_ts(ts)
js_once = patcher.patch_api_js(js)
types_once = patcher.patch_types_ts(types)
assert patcher.patch_api_ts(ts_once) == ts_once
assert patcher.patch_api_js(js_once) == js_once
assert patcher.patch_types_ts(types_once) == types_once
assert 'parseWeixinGetUpdatesJson(rawText)' in ts_once
assert 'parseWeixinGetUpdatesJson(rawText)' in js_once
assert 'message_id?: string | number;' in types_once
def test_node_reviver_preserves_only_weixin_exact_ids() -> None:
script = '''
const ids = new Set(["message_id", "msg_id", "root_id", "parent_id", "server_msg_id", "new_msg_id"]);
const parsed = JSON.parse('{"ret":0,"seq":42,"msgs":[{"message_id":7482038281766381234,"create_time_ms":1783856936191,"item_list":[{"msg_id":"3123141975110186863"}]}]}', (key, value, context) => {
if (!ids.has(key) || value == null || typeof value === "string") return value;
if (typeof value !== "number" || !context?.source || !/^(?:0|[1-9]\\d*)$/.test(context.source)) throw new Error("unsafe id");
return context.source;
});
console.log(JSON.stringify({ messageId: parsed.msgs[0].message_id, msgId: parsed.msgs[0].item_list[0].msg_id, seqType: typeof parsed.seq, timestampType: typeof parsed.msgs[0].create_time_ms }));
'''
completed = subprocess.run(['node', '-e', script], check=True, capture_output=True, text=True)
result = json.loads(completed.stdout)
assert result == {
'messageId': '7482038281766381234',
'msgId': '3123141975110186863',
'seqType': 'number',
'timestampType': 'number',
}
-563
View File
@@ -1,563 +0,0 @@
from __future__ import annotations
import importlib.util
import json
import subprocess
from pathlib import Path
import pytest
def load_js_process_helpers() -> str:
patcher_path = Path(__file__).resolve().parents[1] / 'fixes' / 'patch_openclaw_weixin_hermes.py'
assert patcher_path.exists(), f'missing patcher: {patcher_path}'
spec = importlib.util.spec_from_file_location('patch_openclaw_weixin_hermes', patcher_path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.JS_PROCESS_HELPERS
def load_patcher_module() -> object:
patcher_path = Path(__file__).resolve().parents[1] / 'fixes' / 'patch_openclaw_weixin_hermes.py'
assert patcher_path.exists(), f'missing patcher: {patcher_path}'
spec = importlib.util.spec_from_file_location('patch_openclaw_weixin_hermes', patcher_path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def run_js_process_helpers(tmp_path: Path, body: str) -> dict:
js_process_helpers = load_js_process_helpers()
node_prelude = '\n'.join(
[
'const hermesTestFs = require("node:fs");',
'globalThis.existsSync = hermesTestFs.existsSync;',
'globalThis.readFileSync = hermesTestFs.readFileSync;',
'globalThis.writeFileSync = hermesTestFs.writeFileSync;',
'globalThis.mkdirSync = hermesTestFs.mkdirSync;',
'globalThis.appendFileSync = hermesTestFs.appendFileSync;',
'globalThis.path = require("node:path");',
'globalThis.logger = { info() {}, warn() {}, error() {} };',
]
)
node_script = tmp_path / 'hermes_image_followup_check.js'
node_script.write_text(node_prelude + '\n\n' + js_process_helpers + '\n\n' + body, encoding='utf-8')
completed = subprocess.run(['node', str(node_script)], check=True, capture_output=True, text=True)
return json.loads(completed.stdout.strip())
@pytest.mark.parametrize('is_ts', [True, False], ids=['ts', 'js'])
def test_replace_or_insert_process_helpers_is_byte_idempotent(is_ts: bool) -> None:
patcher = load_patcher_module()
marker = 'export async function processOneMessage'
source = marker + '(message) {\n return message;\n}\n'
once = patcher.replace_or_insert_process_helpers(source, is_ts=is_ts)
twice = patcher.replace_or_insert_process_helpers(once, is_ts=is_ts)
helpers = patcher.TS_PROCESS_HELPERS if is_ts else patcher.JS_PROCESS_HELPERS
assert helpers + '\n\n' + marker in once
assert twice == once
@pytest.mark.parametrize(
('is_ts', 'hook_name'),
[(True, 'TS_PROCESS_HOOK'), (False, 'JS_PROCESS_HOOK')],
ids=['ts', 'js'],
)
def test_remove_and_reinsert_process_hook_is_byte_idempotent(is_ts: bool, hook_name: str) -> None:
patcher = load_patcher_module()
hook = getattr(patcher, hook_name)
indent = ' ' if is_ts else ' '
needle = indent + 'ctx.CommandAuthorized = commandAuthorized;\n'
next_nonblank = indent + 'continueProcessing();\n'
def cycle(text: str) -> str:
without_hook = patcher.remove_existing_process_hermes_hook(text)
return without_hook.replace(needle, needle + hook + '\n', 1)
once = cycle(needle + next_nonblank)
twice = cycle(once)
assert hook + '\n' + next_nonblank in once
assert twice == once
def test_looks_like_hermes_image_followup_distinguishes_diagnostics_from_real_image_questions(tmp_path: Path) -> None:
cases = {
'diag_cache_expired': ('\u56fe\u7247\u7f13\u5b58\u5df2\u5931\u6548\uff0c\u8bf7\u91cd\u65b0\u53d1\u9001\u56fe\u7247\u3002', False),
'diag_cache_error_en': ('image cache error', False),
'diag_cache_expired_en': ('the image cache expired, please resend', False),
'diag_copying_text_shows_cache_error': ('\u4e3a\u4ec0\u4e48\u6211\u590d\u5236\u53d1\u7ed9\u4f60\u7684\u6587\u5b57\uff0c\u4f60\u90fd\u663e\u793a\u56fe\u7247\u7f13\u5b58\u5df2\u5931\u6548\uff0c\u8bf7\u91cd\u65b0\u53d1\u9001\u56fe\u7247\u3002', False),
'diag_not_image': ('\u8fd9\u4e0d\u662f\u56fe\u7247\uff0c\u662f\u6587\u5b57\u6d88\u606f', False),
'diag_text_not_image_en': ('this is a text message, not an image', False),
'diag_no_image_sent': ('\u6211\u6ca1\u6709\u53d1\u56fe\u7247', False),
'diag_system_logs': ('\u8bf7\u68c0\u67e5\u7cfb\u7edf\u65e5\u5fd7\uff0c\u4ece\u673a\u5236\u4e0a\u4fee\u590d\u9519\u8bef', False),
'image_cn_1': ('\u8bf7\u770b\u4e00\u4e0b\u8fd9\u5f20\u56fe\u7247', True),
'image_cn_2': ('\u8fd9\u56fe\u662f\u4ec0\u4e48', True),
'image_cn_3': ('\u8bc6\u522b\u56fe\u7247\u5185\u5bb9', True),
'image_en': ('look at this image', True),
}
results = run_js_process_helpers(
tmp_path,
'const cases = '
+ json.dumps({key: text for key, (text, _) in cases.items()}, ensure_ascii=False)
+ ';\nconst results = {};\n'
+ 'for (const [key, text] of Object.entries(cases)) {\n'
+ ' results[key] = looksLikeHermesImageFollowup(text);\n'
+ '}\n'
+ 'console.log(JSON.stringify(results));\n',
)
for key, (_, expected) in cases.items():
assert results[key] is expected, f'{key}: expected {expected}, got {results[key]!r}'
def test_resolve_hermes_referenced_image_followup_requires_exact_cache(tmp_path: Path) -> None:
cache_path = tmp_path / 'hermes-last-image-cache.json'
results = run_js_process_helpers(
tmp_path,
'\n'.join(
[
'const fs = require("fs");',
f'const cachePath = {json.dumps(str(cache_path))};',
'const accountId = "account-a";',
'const senderId = "sender-b";',
'const exactQuotedId = "quoted-123";',
'const cache = {};',
'cache[hermesLastImageCacheKey(accountId, senderId)] = {',
' mediaUrl: "data:image/png;base64,iVBORw0KGgo=",',
' timestampMs: Date.now(),',
' messageId: "latest-999",',
' mediaType: "image/png",',
'};',
'cache[hermesExactImageCacheKey(accountId, senderId, exactQuotedId)] = {',
' mediaUrl: "data:image/png;base64,iVBORw0KGgo=",',
' timestampMs: Date.now(),',
' messageId: exactQuotedId,',
' mediaType: "image/png",',
'};',
'fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), "utf-8");',
'process.env.AIOS_HERMES_WEIXIN_LAST_IMAGE_CACHE = cachePath;',
'const generic = resolveHermesReferencedImageFollowup(accountId, senderId, "plain quoted text", "quoted-999");',
'const exact = resolveHermesReferencedImageFollowup(accountId, senderId, "plain quoted text", exactQuotedId);',
'const forced = resolveHermesFollowupImageContext(accountId, senderId, "plain quoted text", exactQuotedId, false, true);',
'console.log(JSON.stringify({ generic, exact, forced }));',
]
),
)
assert results['generic']['imageIntent'] is False
assert results['generic']['matched'] is False
assert results['generic']['reason'] == 'referenced_image_not_confirmed'
assert results['exact']['imageIntent'] is True
assert results['exact']['matched'] is True
assert results['exact']['reason'] == 'cached_image_matched'
assert results['forced']['imageIntent'] is True
assert results['forced']['matched'] is True
def test_referenced_text_without_media_evidence_stays_out_of_image_cache(tmp_path: Path) -> None:
cache_path = tmp_path / 'hermes-last-image-cache.json'
results = run_js_process_helpers(
tmp_path,
'\n'.join(
[
'const fs = require("fs");',
f'const cachePath = {json.dumps(str(cache_path))};',
'const accountId = "account-a";',
'const senderId = "sender-b";',
'const cache = {};',
'cache[hermesLastImageCacheKey(accountId, senderId)] = {',
' mediaUrl: "data:image/png;base64,iVBORw0KGgo=",',
' timestampMs: Date.now(),',
' messageId: "latest-999",',
' mediaType: "image/png",',
'};',
'fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), "utf-8");',
'process.env.AIOS_HERMES_WEIXIN_LAST_IMAGE_CACHE = cachePath;',
'const full = { item_list: [{ ref_msg: { message_item: { type: 0, id: "quoted-999" } } }] };',
'const hermesContent = "\u8fd9\u662f\u4ec0\u4e48";',
'const hermesReferenceContextPresent = hasHermesReferenceContext(full.item_list);',
'const hermesQuotedMessageId = findHermesReferencedMessageId(full.item_list);',
'const refMediaItem = undefined;',
'const hermesReferencedMediaIsImage = Boolean(refMediaItem && hermesCurrentMessageIsImage({}, refMediaItem));',
'const hermesReferencedImageFollowupIntent = hermesReferenceContextPresent && !refMediaItem;',
'const hermesCurrentImage = false;',
'const hermesFollowup = hermesCurrentImage',
' ? { imageIntent: false, matched: false, reason: "current_image_message", cacheStrategy: "current", quotedMessageId: hermesQuotedMessageId }',
' : (hermesReferencedMediaIsImage',
' ? resolveHermesFollowupImageContext(accountId, senderId, hermesContent, hermesQuotedMessageId, false, true)',
' : (hermesReferencedImageFollowupIntent',
' ? resolveHermesReferencedImageFollowup(accountId, senderId, hermesContent, hermesQuotedMessageId)',
' : resolveHermesFollowupImageContext(accountId, senderId, hermesContent)));',
'console.log(JSON.stringify({ hermesReferenceContextPresent, hermesQuotedMessageId, hermesReferencedMediaIsImage, hermesReferencedImageFollowupIntent, hermesFollowup }));',
]
),
)
assert results['hermesReferenceContextPresent'] is True
assert results['hermesQuotedMessageId'] == 'quoted-999'
assert results['hermesReferencedMediaIsImage'] is False
assert results['hermesReferencedImageFollowupIntent'] is True
assert results['hermesFollowup']['imageIntent'] is False
assert results['hermesFollowup']['matched'] is False
assert results['hermesFollowup']['reason'] == 'referenced_image_not_confirmed'
def test_explicit_quoted_text_builds_grounded_plain_text_context(tmp_path: Path) -> None:
results = run_js_process_helpers(
tmp_path,
'\n'.join(
[
'const available = typeof resolveHermesQuotedTextContext === "function";',
'const items = [{ type: 1, ref_msg: { message_item: { type: 1, text_item: { text: "被引用的正文" } } } }];',
'const result = available ? resolveHermesQuotedTextContext(items, "这是什么意思?") : null;',
'console.log(JSON.stringify({ available, result }));',
]
),
)
assert results['available'] is True
assert results['result'] == {
'referenced': True,
'handled': False,
'prompt': '[被引用文字]\n被引用的正文\n[/被引用文字]\n\n[当前问题]\n这是什么意思?\n[/当前问题]',
'reason': 'quoted_text_resolved',
}
def test_visible_ref_title_builds_grounded_plain_text_context(tmp_path: Path) -> None:
results = run_js_process_helpers(
tmp_path,
'\n'.join(
[
'const items = [{ type: 1, ref_msg: { title: "客户端可见的引用正文", message_item: { type: 0, msg_id: "quoted-123" } } }];',
'const result = resolveHermesQuotedTextContext(items, "这是什么意思?");',
'console.log(JSON.stringify(result));',
]
),
)
assert results == {
'referenced': True,
'handled': False,
'prompt': '[被引用文字]\n客户端可见的引用正文\n[/被引用文字]\n\n[当前问题]\n这是什么意思?\n[/当前问题]',
'reason': 'quoted_text_resolved',
}
def test_nested_generic_title_is_not_treated_as_quoted_text(tmp_path: Path) -> None:
results = run_js_process_helpers(
tmp_path,
'\n'.join(
[
'const items = [{ type: 1, ref_msg: { message_item: { type: 0, title: "非协议引用摘要" } } }];',
'const result = resolveHermesQuotedTextContext(items, "这是什么意思?");',
'console.log(JSON.stringify(result));',
]
),
)
assert results == {
'referenced': True,
'handled': True,
'replyText': '无法读取被引用文字,请复制原文后再提问。',
'reason': 'quoted_text_unavailable',
}
def test_exact_message_id_cache_resolves_server_reference_without_guessing(tmp_path: Path) -> None:
cache_path = tmp_path / 'hermes-message-text-cache.json'
results = run_js_process_helpers(
tmp_path,
'\n'.join(
[
f'process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE = {json.dumps(str(cache_path))};',
'const accountId = "account-a";',
'const senderId = "sender-b";',
'const original = { item_list: [{ type: 1, msg_id: "7481729105240418312", text_item: { text: "客户端已经显示的原始正文" } }] };',
'rememberHermesTextMessage(accountId, senderId, original, "客户端已经显示的原始正文");',
'const quotedItems = [{ type: 1, ref_msg: { message_item: { type: 0, msg_id: "7481729105240418312" } }, text_item: { text: "这是什么意思?" } }];',
'const quotedId = findHermesReferencedMessageId(quotedItems);',
'const exact = resolveHermesCachedQuotedText(accountId, senderId, quotedId);',
'const wrong = resolveHermesCachedQuotedText(accountId, senderId, "7481729105240418313");',
'const resolved = resolveHermesQuotedTextContext(quotedItems, "这是什么意思?", exact);',
'const mode = (hermesTestFs.statSync(process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE).mode & 0o777).toString(8);',
'console.log(JSON.stringify({ quotedId, exact, wrong, resolved, mode }));',
]
),
)
assert results['quotedId'] == '7481729105240418312'
assert results['exact'] == '客户端已经显示的原始正文'
assert results['wrong'] == ''
assert results['resolved'] == {
'referenced': True,
'handled': False,
'prompt': '[被引用文字]\n客户端已经显示的原始正文\n[/被引用文字]\n\n[当前问题]\n这是什么意思?\n[/当前问题]',
'reason': 'quoted_text_resolved',
}
assert results['mode'] == '600'
def test_expired_message_id_cache_entry_is_not_resolved(tmp_path: Path) -> None:
cache_path = tmp_path / 'hermes-message-text-cache.json'
results = run_js_process_helpers(
tmp_path,
'\n'.join(
[
f'process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE = {json.dumps(str(cache_path))};',
'const key = hermesTextMessageCacheKey("account-a", "sender-b", "quoted-old");',
'hermesTestFs.writeFileSync(process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE, JSON.stringify({ [key]: { text: "过期正文", timestampMs: Date.now() - (8 * 24 * 60 * 60 * 1000) } }), { encoding: "utf-8", mode: 0o600 });',
'const result = resolveHermesCachedQuotedText("account-a", "sender-b", "quoted-old");',
'console.log(JSON.stringify({ result }));',
]
),
)
assert results['result'] == ''
def test_unsafe_numeric_message_id_is_rejected_before_cache_keying(tmp_path: Path) -> None:
results = run_js_process_helpers(
tmp_path,
'\n'.join(
[
'const exactString = extractHermesMessageId({ message_id: "7482038281766381234" });',
'const unsafeNumber = extractHermesMessageId({ message_id: 7482038281766381234 });',
'const safeNumber = extractHermesMessageId({ message_id: 12345 });',
'console.log(JSON.stringify({ exactString, unsafeNumber: unsafeNumber ?? null, safeNumber }));',
]
),
)
assert results == {
'exactString': '7482038281766381234',
'unsafeNumber': None,
'safeNumber': '12345',
}
def test_exact_outbound_create_time_resolves_quoted_bot_text(tmp_path: Path) -> None:
cache_path = tmp_path / 'hermes-message-text-cache.json'
results = run_js_process_helpers(
tmp_path,
'\n'.join(
[
f'process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE = {json.dumps(str(cache_path))};',
'const accountId = "account-a";',
'const senderId = "sender-b";',
'const createTimeMs = 1783857900123;',
'rememberHermesOutboundTextMessage(accountId, senderId, "机器人出站正文", createTimeMs);',
'const quotedItems = [{ type: 1, ref_msg: { message_item: { type: 0, msg_id: "server-quoted", create_time_ms: createTimeMs } }, text_item: { text: "进展汇报" } }];',
'const referencedTime = findHermesReferencedCreateTimeMs(quotedItems);',
'const exact = resolveHermesCachedQuotedText(accountId, senderId, "server-quoted", referencedTime);',
'const wrong = resolveHermesCachedQuotedText(accountId, senderId, "server-quoted", createTimeMs + 1);',
'const resolved = resolveHermesQuotedTextContext(quotedItems, "进展汇报", exact);',
'console.log(JSON.stringify({ referencedTime, exact, wrong, resolved }));',
]
),
)
assert results['referencedTime'] == '1783857900123'
assert results['exact'] == '机器人出站正文'
assert results['wrong'] == ''
assert results['resolved']['reason'] == 'quoted_text_resolved'
assert '机器人出站正文' in results['resolved']['prompt']
def test_outbound_create_time_collision_fails_closed(tmp_path: Path) -> None:
cache_path = tmp_path / 'hermes-message-text-cache.json'
results = run_js_process_helpers(
tmp_path,
'\n'.join(
[
f'process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE = {json.dumps(str(cache_path))};',
'const createTimeMs = 1783857900123;',
'rememberHermesOutboundTextMessage("account-a", "sender-b", "第一条", createTimeMs);',
'rememberHermesOutboundTextMessage("account-a", "sender-b", "第二条", createTimeMs);',
'const resolved = resolveHermesCachedQuotedText("account-a", "sender-b", undefined, createTimeMs);',
'console.log(JSON.stringify({ resolved }));',
]
),
)
assert results['resolved'] == ''
def test_missing_quoted_text_fails_closed_without_model_prompt(tmp_path: Path) -> None:
results = run_js_process_helpers(
tmp_path,
'\n'.join(
[
'const available = typeof resolveHermesQuotedTextContext === "function";',
'const items = [{ type: 1, ref_msg: { message_item: { type: 0, msg_id: "7481729105240418312" } } }];',
'const result = available ? resolveHermesQuotedTextContext(items, "这是什么意思?") : null;',
'let modelCalls = 0;',
'if (result && !result.handled) modelCalls += 1;',
'console.log(JSON.stringify({ available, result, modelCalls }));',
]
),
)
assert results['available'] is True
assert results['result'] == {
'referenced': True,
'handled': True,
'replyText': '无法读取被引用文字,请复制原文后再提问。',
'reason': 'quoted_text_unavailable',
}
assert results['modelCalls'] == 0
@pytest.mark.parametrize('hook_name', ['TS_PROCESS_HOOK', 'JS_PROCESS_HOOK'])
def test_process_hook_stops_unavailable_quote_before_model_dispatch(hook_name: str) -> None:
patcher = load_patcher_module()
hook = getattr(patcher, hook_name)
guard_name = 'TS_QUOTED_TEXT_DISPATCH_GUARD' if hook_name.startswith('TS_') else 'JS_QUOTED_TEXT_DISPATCH_GUARD'
guard = getattr(patcher, guard_name)
assert 'rememberHermesTextMessage(deps.accountId, hermesSenderId, full, textBody)' in hook
assert 'findHermesReferencedCreateTimeMs(full.item_list)' in hook
assert 'resolveHermesCachedQuotedText(deps.accountId, hermesSenderId, hermesQuotedMessageId, hermesQuotedCreateTimeMs)' in hook
assert 'resolveHermesQuotedTextContext(full.item_list, hermesContent, hermesCachedQuotedText)' in hook
assert '!hermesQuotedTextContext.referenced && !textBody.startsWith("/")' in hook
assert 'ctx.Body = hermesQuotedTextContext.prompt' in hook
assert 'await dispatchHermesQuotedTextFailClosed(hermesQuotedTextContext, dispatcher)' in guard
assert 'markDispatchIdle();' in guard
def test_dispatch_quoted_text_fail_closed_uses_dispatcher_without_model(tmp_path: Path) -> None:
result = run_js_process_helpers(
tmp_path,
'\n'.join(
[
'(async () => {',
' const calls = [];',
' const dispatcher = {',
' sendFinalReply(payload) { calls.push(["final", payload]); },',
' markComplete() { calls.push(["complete"]); },',
' async waitForIdle() { calls.push(["idle"]); },',
' };',
' const context = resolveHermesQuotedTextContext([{ ref_msg: { message_item: { type: 0, msg_id: "quoted-1" } } }], "这是什么意思?");',
' let modelCalls = 0;',
' const handled = await dispatchHermesQuotedTextFailClosed(context, dispatcher);',
' if (!handled) modelCalls += 1;',
' console.log(JSON.stringify({ handled, modelCalls, calls }));',
'})();',
]
),
)
assert result['handled'] is True
assert result['modelCalls'] == 0
assert result['calls'] == [
['final', {'text': '无法读取被引用文字,请复制原文后再提问。'}],
['complete'],
['idle'],
]
@pytest.mark.parametrize(
('is_ts', 'guard_name', 'needle'),
[
(True, 'TS_QUOTED_TEXT_DISPATCH_GUARD', ' logger.debug(`dispatchReplyFromConfig: starting agentId=${route.agentId ?? "(none)"}`);\n'),
(False, 'JS_QUOTED_TEXT_DISPATCH_GUARD', ' logger.debug(`dispatchReplyFromConfig: starting agentId=${route.agentId ?? "(none)"}`);\n'),
],
ids=['ts', 'js'],
)
def test_dispatch_guard_remove_and_reinsert_is_byte_idempotent(is_ts: bool, guard_name: str, needle: str) -> None:
patcher = load_patcher_module()
guard = getattr(patcher, guard_name)
def cycle(text: str) -> str:
without = patcher.remove_existing_process_hermes_hook(text)
return without.replace(needle, guard + '\n' + needle, 1)
once = cycle(needle)
twice = cycle(once)
assert guard + '\n' + needle in once
assert twice == once
def test_reference_metadata_content_is_not_treated_as_quoted_text(tmp_path: Path) -> None:
result = run_js_process_helpers(
tmp_path,
'\n'.join(
[
'const items = [{ ref_msg: { message_item: { type: 0, msg_id: "quoted-1", content: "metadata only" } } }];',
'console.log(JSON.stringify(resolveHermesQuotedTextContext(items, "这是什么意思?")));',
]
),
)
assert result['handled'] is True
assert result['reason'] == 'quoted_text_unavailable'
assert result['replyText'] == '无法读取被引用文字,请复制原文后再提问。'
def test_patch_plugin_process_only_skips_slash_and_channel(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
patcher = load_patcher_module()
calls: list[tuple[str, Path]] = []
def record(name: str, result):
def _inner(root: Path):
calls.append((name, root))
return result
return _inner
monkeypatch.setattr(patcher, 'patch_ts', record('patch_ts', False))
monkeypatch.setattr(patcher, 'patch_js', record('patch_js', False))
monkeypatch.setattr(patcher, 'patch_process_messages', record('patch_process_messages', {'src/messaging/process-message.ts': True, 'dist/src/messaging/process-message.js': False}))
monkeypatch.setattr(patcher, 'patch_channel_files', record('patch_channel_files', {'src/channel.ts': False, 'dist/src/channel.js': False}))
monkeypatch.setattr(patcher, 'verify_plugin_hook', record('verify_plugin_hook', {
'hook_status': {},
'hook_installed': False,
'process_status': {'src/messaging/process-message.ts': True, 'dist/src/messaging/process-message.js': True},
'process_hook_installed': True,
'channel_status': {},
'channel_hook_installed': False,
'dispatch_reference_found': False,
'dispatch_reference_files': [],
}))
result = patcher.patch_plugin(tmp_path, process_only=True)
assert calls == [
('patch_process_messages', tmp_path),
('verify_plugin_hook', tmp_path),
]
assert result['mode'] == 'process-only'
assert result['plugin_root'] == str(tmp_path)
assert result['slash_changes'] == {}
assert result['channel_changes'] == {}
assert result['process_changes'] == {'src/messaging/process-message.ts': True, 'dist/src/messaging/process-message.js': False}
assert result['behavior_changes'] == {'src/messaging/process-message.ts': True, 'dist/src/messaging/process-message.js': False}
assert result['verification']['process_hook_installed'] is True
def test_parse_args_supports_default_root_and_process_only_flag() -> None:
patcher = load_patcher_module()
defaults = patcher.parse_args([])
assert defaults.plugin_root == patcher.DEFAULT_PLUGIN_ROOT
assert defaults.process_only is False
process_only = patcher.parse_args(['--process-only'])
assert process_only.plugin_root == patcher.DEFAULT_PLUGIN_ROOT
assert process_only.process_only is True
custom_root = Path('/tmp/custom-plugin-root')
custom = patcher.parse_args([str(custom_root), '--process-only'])
assert custom.plugin_root == custom_root
assert custom.process_only is True
@@ -1,75 +0,0 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
PATCHER_PATH = Path(__file__).resolve().parents[1] / 'fixes' / 'patch_openclaw_weixin_outbound_quotes.py'
def load_patcher():
spec = importlib.util.spec_from_file_location('patch_openclaw_weixin_outbound_quotes', PATCHER_PATH)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_send_patch_adds_exact_create_time_and_is_idempotent() -> None:
patcher = load_patcher()
ts = '''function buildTextMessageReq(params: {
to: string;
text: string;
contextToken?: string;
runId?: string;
clientId: string;
}): SendMessageReq {
const { to, text, contextToken, runId, clientId } = params;
return { msg: { client_id: clientId, message_type: MessageType.BOT } };
}
export async function sendMessageWeixin(params: {}): Promise<{ messageId: string }> {
const { to, text, opts } = params;
const clientId = generateClientId();
const req = buildSendMessageReq({
payload: {},
clientId,
});
try {
await sendMessageApi({ body: req });
} catch (err) {
throw err;
}
return { messageId: clientId };
}
/** Send a single structured MessageItem downstream. */'''
js = '''function buildTextMessageReq(params) {
const { to, text, contextToken, runId, clientId } = params;
return { msg: { client_id: clientId, message_type: MessageType.BOT } };
}
export async function sendMessageWeixin(params) {
const clientId = generateClientId();
const req = buildSendMessageReq({
payload: {},
clientId,
});
try {
await sendMessageApi({ body: req });
}
catch (err) {
throw err;
}
return { messageId: clientId };
}
/** Send a single structured MessageItem downstream. */'''
ts_once = patcher.patch_send_ts(ts)
js_once = patcher.patch_send_js(js)
assert patcher.patch_send_ts(ts_once) == ts_once
assert patcher.patch_send_js(js_once) == js_once
for result in (ts_once, js_once):
assert 'const createTimeMs = Date.now();' in result
assert 'text item missing' in result
assert 'req.msg.item_list[0].create_time_ms = createTimeMs' in result
assert 'req.msg.create_time_ms = createTimeMs' not in result
assert 'return { messageId: clientId, createTimeMs }' in result
+33
View File
@@ -0,0 +1,33 @@
# AIOS V2R1C0 RC1 Acceptance
## Tenant Isolation
- cross tenant memory access: DENY
- cross cell context: DENY
- cross tenant secret scope: DENY
- cross tenant queue claim: DENY
## Message Safety
- unsafe numeric message id: DENY
- exact reply resolution only: PASS
- quote text separate from command body: PASS
- heartbeat side effects: DENY
- cron side effects: DENY
- assistant output execution: DENY
## Hermes
- profile HOME isolation: PASS
- workspace escape: DENY
- tenant queue partition: PASS
## OpenClaw
- plugin manifest validation: PASS
- runtime hook registration: REQUIRED
- side effect approval: REQUIRED
## Release Gate
No production deployment until executable tests and runtime evidence pass.
+16
View File
@@ -0,0 +1,16 @@
# Hermes Fabric Contract Tests
Required executable assertions:
1. Duplicate event_id must raise ConflictError, never raw sqlite3.IntegrityError.
2. Same tenant/cell/profile worker can claim its own queue.
3. Cross tenant queue claim must return no task or be denied.
4. Non-command messages cannot enqueue execution jobs.
5. Unknown provenance cannot create execution jobs.
Evidence required:
- command
- exit code
- pass count
- commit
+88
View File
@@ -0,0 +1,88 @@
"""Gate 1 contract tests for the Hermes shared agent fabric."""
from __future__ import annotations
import sqlite3
from tempfile import TemporaryDirectory
import unittest
from aios_v2r1.hermes_fabric import HermesFabric
from aios_v2r1.tenant_context import CanonicalMessage, ConflictError, ScopeError
def authenticated_command(
event_id: str,
*,
command_capable: bool = True,
origin_type: str = "channel_user",
) -> CanonicalMessage:
return CanonicalMessage(
tenant_id="tenant-a",
cell_id="cell-a",
principal_id="user-a",
principal_type="authenticated_user",
origin_type=origin_type,
channel="weixin",
channel_account_id="account-a",
peer_id="peer-a",
conversation_id="conversation-a",
session_id="session-a",
task_id=None,
event_id=event_id,
message_id="1234567890123456789",
reply_to_message_id=None,
direction="inbound",
command_capable=command_capable,
occurred_at="2026-07-13T00:00:00+00:00",
received_at="2026-07-13T00:00:01+00:00",
body="/run",
quoted_body=None,
adapter_name="weixin",
adapter_version="v2",
)
class HermesFabricContractTests(unittest.TestCase):
def test_duplicate_event_returns_sanitized_conflict(self) -> None:
with TemporaryDirectory() as directory:
fabric = HermesFabric(f"{directory}/fabric.sqlite3")
message = authenticated_command("event-a")
first = fabric.enqueue(message=message, profile_id="profile-a")
self.assertEqual(first.event_id, "event-a")
with self.assertRaises(ConflictError) as raised:
fabric.enqueue(message=message, profile_id="profile-a")
error = raised.exception
self.assertNotIsInstance(error, sqlite3.IntegrityError)
self.assertNotIn("sqlite", str(error).lower())
self.assertIsNone(error.__cause__)
self.assertIsNone(error.__context__)
def test_non_command_event_cannot_enqueue(self) -> None:
with TemporaryDirectory() as directory:
fabric = HermesFabric(f"{directory}/fabric.sqlite3")
with self.assertRaises(ScopeError):
fabric.enqueue(
message=authenticated_command(
"event-non-command", command_capable=False
),
profile_id="profile-a",
)
def test_heartbeat_provenance_cannot_enqueue(self) -> None:
with TemporaryDirectory() as directory:
fabric = HermesFabric(f"{directory}/fabric.sqlite3")
with self.assertRaises(ScopeError):
fabric.enqueue(
message=authenticated_command(
"event-heartbeat", origin_type="heartbeat"
),
profile_id="profile-a",
)
if __name__ == "__main__":
unittest.main()
+144
View File
@@ -0,0 +1,144 @@
"""Gate 1 contract tests for authoritative TenantRouter isolation."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from tempfile import TemporaryDirectory
import unittest
from aios_v2r1.router import TenantRouter
from aios_v2r1.tenant_context import CanonicalMessage, ConflictError, ScopeError
class MutableClock:
def __init__(self, now: datetime) -> None:
self.now = now
def __call__(self) -> datetime:
return self.now
def canonical_message(*, tenant_id: str = "tenant-a", cell_id: str = "cell-a") -> CanonicalMessage:
return CanonicalMessage(
tenant_id=tenant_id,
cell_id=cell_id,
principal_id="user-a",
principal_type="authenticated_user",
origin_type="channel_user",
channel="weixin",
channel_account_id="account-a",
peer_id="peer-a",
conversation_id="conversation-a",
session_id="session-a",
task_id=None,
event_id="event-a",
message_id="message-a",
reply_to_message_id=None,
direction="inbound",
command_capable=True,
occurred_at="2026-07-13T00:00:00+00:00",
received_at="2026-07-13T00:00:01+00:00",
body="hello",
quoted_body=None,
adapter_name="weixin",
adapter_version="v2",
)
class TenantRouterIsolationContractTests(unittest.TestCase):
def setUp(self) -> None:
self.directory = TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.database = f"{self.directory.name}/router.sqlite3"
self.router = TenantRouter(self.database)
self.router.register_tenant("tenant-a", "cell-a")
self.router.register_tenant("tenant-b", "cell-b")
self.router.bind_channel(
tenant_id="tenant-a", channel="weixin", channel_account_id="account-a"
)
self.router.bind_channel(
tenant_id="tenant-b", channel="weixin", channel_account_id="account-b"
)
def test_channel_account_cannot_bind_two_tenants(self) -> None:
with self.assertRaises(ConflictError):
self.router.bind_channel(
tenant_id="tenant-b", channel="weixin", channel_account_id="account-a"
)
def test_cell_cannot_bind_two_tenants(self) -> None:
with self.assertRaises(ConflictError):
self.router.register_tenant("tenant-c", "cell-a")
def test_principal_cannot_bind_across_tenant_boundary(self) -> None:
with self.assertRaises(ScopeError):
self.router.bind_principal(
tenant_id="tenant-b",
channel="weixin",
channel_account_id="account-a",
principal_id="user-a",
)
def test_session_cannot_cross_cell(self) -> None:
with self.assertRaises(ScopeError):
self.router.bind_session(
tenant_id="tenant-a",
session_id="session-a",
cell_id="cell-b",
principal_id="user-a",
)
def test_session_cannot_cross_principal(self) -> None:
self.router.bind_session(
tenant_id="tenant-a",
session_id="session-a",
cell_id="cell-a",
principal_id="user-a",
)
with self.assertRaises(ConflictError):
self.router.bind_session(
tenant_id="tenant-a",
session_id="session-a",
cell_id="cell-a",
principal_id="user-b",
)
def test_expired_session_is_rejected_without_private_storage_access(self) -> None:
clock = MutableClock(datetime(2026, 7, 13, tzinfo=timezone.utc))
router = TenantRouter(f"{self.directory.name}/expiry.sqlite3", clock=clock)
router.register_tenant("tenant-a", "cell-a")
router.bind_channel(
tenant_id="tenant-a", channel="weixin", channel_account_id="account-a"
)
router.bind_session(
tenant_id="tenant-a",
session_id="session-a",
cell_id="cell-a",
principal_id="user-a",
)
clock.now += timedelta(days=2)
with self.assertRaises(ScopeError):
router.resolve(
channel="weixin",
channel_account_id="account-a",
principal_id="user-a",
session_id="session-a",
)
def test_self_reported_tenant_or_cell_mismatch_fails_against_authority(self) -> None:
self.router.bind_session(
tenant_id="tenant-a",
session_id="session-a",
cell_id="cell-a",
principal_id="user-a",
)
expected = "message claims do not match authoritative route"
with self.assertRaisesRegex(ScopeError, expected):
self.router.assert_message_route(canonical_message(tenant_id="tenant-b"))
with self.assertRaisesRegex(ScopeError, expected):
self.router.assert_message_route(canonical_message(cell_id="cell-b"))
if __name__ == "__main__":
unittest.main()
+51
View File
@@ -0,0 +1,51 @@
"""Gate 1 contract tests for canonical tenant-context signatures."""
from __future__ import annotations
from datetime import datetime, timezone
import unittest
from aios_v2r1.tenant_context import TenantContext, ValidationError
class TenantContextSignatureContractTests(unittest.TestCase):
key = b"k" * 32
now = datetime(2026, 7, 13, tzinfo=timezone.utc)
def setUp(self) -> None:
self.context = TenantContext(
tenant_id="tenant-a",
cell_id="cell-a",
principal_id="user-a",
principal_type="user",
session_id="session-a",
task_id=None,
channel="hermes",
channel_account_id="account-a",
issued_at="2026-07-13T00:00:00+00:00",
expires_at="2026-07-13T00:05:00+00:00",
nonce="nonce-b",
)
self.token = self.context.sign(self.key)
def test_valid_canonical_token_verifies(self) -> None:
verified = TenantContext.verify(self.token, self.key, now=self.now)
self.assertEqual(verified, self.context)
def test_rejects_padded_signature_encoding(self) -> None:
with self.assertRaises(ValidationError):
TenantContext.verify(f"{self.token}=", self.key, now=self.now)
def test_rejects_standard_base64_signature_encoding(self) -> None:
payload, signature = self.token.rsplit(".", 1)
standard_signature = signature.replace("-", "+").replace("_", "/")
self.assertNotEqual(signature, standard_signature)
with self.assertRaises(ValidationError):
TenantContext.verify(
f"{payload}.{standard_signature}", self.key, now=self.now
)
if __name__ == "__main__":
unittest.main()