Compare commits
73
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49a9c2e0cc | ||
|
|
e110cda996 | ||
|
|
8d9c6da90f | ||
|
|
631fee9254 | ||
|
|
9577613497 | ||
|
|
a9a298f8a7 | ||
|
|
53c67bd978 | ||
|
|
1d334b1cca | ||
|
|
a1f933e500 | ||
|
|
74e603c0c9 | ||
|
|
d5b51e1342 | ||
|
|
d74229927b | ||
|
|
68d2787910 | ||
|
|
57103c11d2 | ||
|
|
a9a0b4ae39 | ||
|
|
60ae6db60c | ||
|
|
e2d0c6aaf3 | ||
|
|
c21a326e81 | ||
|
|
14b03fc893 | ||
|
|
04100a1aae | ||
|
|
e13f601cff | ||
|
|
1e7d1969cd | ||
|
|
d2a3814a2f | ||
|
|
c444622279 | ||
|
|
3cf9958cb9 | ||
|
|
6b70a33080 | ||
|
|
8d23692320 | ||
|
|
33d3947da6 | ||
|
|
806163b8ba | ||
|
|
13c2a18286 | ||
|
|
b8f10951ea | ||
|
|
2fcf0879c6 | ||
|
|
1136b4186d | ||
|
|
cca73ad52f | ||
|
|
424bf36c03 | ||
|
|
9eb00da0c4 | ||
|
|
9269e81997 | ||
|
|
6642820aa0 | ||
|
|
8374c6b1c7 | ||
|
|
c8a3251834 | ||
|
|
2418f56273 | ||
|
|
297582941f | ||
|
|
958a61cbc4 | ||
|
|
3096212919 | ||
|
|
aa2b091966 | ||
|
|
1a9cb9095e | ||
|
|
0312159168 | ||
|
|
e204a90398 | ||
|
|
d8cf6058e9 | ||
|
|
2aefc19e3d | ||
|
|
28a430066f | ||
|
|
1013aef461 | ||
|
|
bb3210d74f | ||
|
|
745395e93b | ||
|
|
ae49bf0fa3 | ||
|
|
8410cd895e | ||
|
|
dc1642c2e8 | ||
|
|
c75b865710 | ||
|
|
e992b624e0 | ||
|
|
c60e4cd02c | ||
|
|
4b2996ca9f | ||
|
|
086076b74f | ||
|
|
7b2d694272 | ||
|
|
0583060c7c | ||
|
|
20e707fb52 | ||
|
|
6c75ec6bb2 | ||
|
|
e9fefd4c18 | ||
|
|
3ece481c74 | ||
|
|
33177548c3 | ||
|
|
ed1413a05e | ||
|
|
05d1e64228 | ||
|
|
64bbe1b51c | ||
|
|
3c6f2c8c65 |
+397
@@ -0,0 +1,397 @@
|
||||
# Personal-Weixin B3/B4 Owner Evidence Authorization
|
||||
|
||||
```yaml
|
||||
authorization_id: AUTH-CODEX-V2R1-PERSONAL-WEIXIN-B3-B4-EVIDENCE-20260716
|
||||
owner_approved: true
|
||||
executor: Codex isolated runner
|
||||
|
||||
foundation_implementation_commit: 2fcab6f5833a405a9a26683811600b6e5f1aa7b1
|
||||
foundation_result_commit: 64a71b6199a8e2bd609ba145349e67b874888505
|
||||
r10_lineage_commit: 74e603c0c943501110577dbe3c7f6a0b18b8ec7d
|
||||
evidence_gate_commit: 62a4067cf7a462ebe7cf29524238bc5d0a70b813
|
||||
upstream_audit_commit: b2f5f531019a70801444987dbb97dc1f69cf47de
|
||||
|
||||
production_baseline: V1R4C6
|
||||
production_changed: false
|
||||
release_status: paused
|
||||
force_push_authorized: false
|
||||
```
|
||||
|
||||
## 1. 授权结论
|
||||
|
||||
Owner 明确授权 Codex:
|
||||
|
||||
1. 完成 B3 owner evidence packet;
|
||||
2. 在 vendor `client_id → authoritative message_id` 证据缺失时,采用 B4 quote-authority-disabled amendment;
|
||||
3. B3、B4 均 PASS 后,按已有 exact allowlist 继续 B5–B7;
|
||||
4. B3–B7 全部 PASS 后才能开始 Release Preflight;
|
||||
5. Release Preflight PASS 后才允许 Canary;
|
||||
6. Codex 不得直接修改生产。
|
||||
|
||||
该授权不允许 Codex编造 raw-wire、credential、vendor 或 reviewer 证据。
|
||||
|
||||
## 2. B3 owner evidence 必须包含
|
||||
|
||||
B3 evidence manifest 必须引用上述五个权威提交,并包含以下五类证据。
|
||||
|
||||
### A. Raw-wire evidence
|
||||
|
||||
至少包含:
|
||||
|
||||
```yaml
|
||||
raw_wire:
|
||||
- direction: inbound
|
||||
- direction: outbound
|
||||
```
|
||||
|
||||
每项必须记录:
|
||||
|
||||
```yaml
|
||||
restricted_path:
|
||||
restricted_sha256:
|
||||
restricted_bytes:
|
||||
redacted_path:
|
||||
redacted_sha256:
|
||||
captured_at:
|
||||
credential_label: credential-a | credential-b
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
* unrestricted raw-wire 只能保存在 owner 批准的受限存储;
|
||||
* Git 中只能提交 SHA-256、大小、受限路径引用及 redacted view;
|
||||
* 不得提交 token、API key、OAuth code、sessionKey、cookie 或完整 credential;
|
||||
* inbound 与 outbound 必须各至少一份;
|
||||
-两份 credential 都必须实际覆盖。
|
||||
|
||||
### B. Authority map
|
||||
|
||||
必须提供 commit-safe authority map,明确:
|
||||
|
||||
```yaml
|
||||
vendor_message_id_field:
|
||||
client_id_field:
|
||||
client_id_authoritative: false
|
||||
mapping_verified: true
|
||||
```
|
||||
|
||||
Authority map 必须区分:
|
||||
|
||||
```yaml
|
||||
authoritative:
|
||||
- verified inbound vendor message_id
|
||||
|
||||
non_authoritative:
|
||||
- local client_id
|
||||
- outbound correlation ID
|
||||
- local sequence
|
||||
- timestamp
|
||||
- content hash
|
||||
- outbound receipt ID
|
||||
```
|
||||
|
||||
禁止把任一非权威值提升为 message authority。
|
||||
|
||||
### C. 双 credential 证据
|
||||
|
||||
必须有精确两项:
|
||||
|
||||
```yaml
|
||||
credentials:
|
||||
- label: credential-a
|
||||
fingerprint_sha256:
|
||||
secret_included: false
|
||||
scope:
|
||||
test_result: PASS
|
||||
|
||||
- label: credential-b
|
||||
fingerprint_sha256:
|
||||
secret_included: false
|
||||
scope:
|
||||
test_result: PASS
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
* 两个 fingerprint 必须不同;
|
||||
* 不得提交 secret 原文;
|
||||
* 两个 credential 必须各自通过实际测试;
|
||||
* credential scope 必须明确。
|
||||
|
||||
### D. Mode evidence
|
||||
|
||||
必须明确处于下列模式之一:
|
||||
|
||||
```yaml
|
||||
mode:
|
||||
name: sandbox | shadow | restricted-capture
|
||||
production_traffic: false
|
||||
writes_production: false
|
||||
capture_only: true
|
||||
```
|
||||
|
||||
不得使用生产流量或写生产数据来补证据。
|
||||
|
||||
### E. Reviewer evidence
|
||||
|
||||
必须由独立 reviewer 签署:
|
||||
|
||||
```yaml
|
||||
review:
|
||||
reviewer:
|
||||
independent_from_executor: true
|
||||
decision: PASS
|
||||
reviewed_manifest_sha256:
|
||||
reviewed_at:
|
||||
```
|
||||
|
||||
Reviewer 必须核对:
|
||||
|
||||
* raw-wire 哈希;
|
||||
* redacted view;
|
||||
* authority map;
|
||||
-两个 credential fingerprint;
|
||||
* mode;
|
||||
* manifest 完整 SHA-256。
|
||||
|
||||
## 3. B3 PASS 条件
|
||||
|
||||
只有以下全部成立才允许 B3 `status: PASS`:
|
||||
|
||||
```yaml
|
||||
raw_wire_inbound: PASS
|
||||
raw_wire_outbound: PASS
|
||||
dual_credential: PASS
|
||||
authority_map: PASS
|
||||
client_id_authoritative: false
|
||||
mode_non_production: PASS
|
||||
reviewer_independent: PASS
|
||||
reviewer_decision: PASS
|
||||
secrets_committed: false
|
||||
production_changed: false
|
||||
baseline_after: V1R4C6
|
||||
release_status: paused
|
||||
```
|
||||
|
||||
真实证据缺失时必须继续:
|
||||
|
||||
```yaml
|
||||
status: BLOCKED_EVIDENCE_REQUIRED
|
||||
```
|
||||
|
||||
不得生成模拟证据代替 owner/vendor 证据。
|
||||
|
||||
## 4. B4 Owner Amendment
|
||||
|
||||
Owner 明确批准以下 B4 amendment:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"contract": "personal-weixin-b4-outbound-quote-authority-amendment",
|
||||
"ownerApproved": true,
|
||||
"lineage": {
|
||||
"foundationImplementationCommit": "2fcab6f5833a405a9a26683811600b6e5f1aa7b1",
|
||||
"foundationResultCommit": "64a71b6199a8e2bd609ba145349e67b874888505",
|
||||
"r10LineageCommit": "74e603c0c943501110577dbe3c7f6a0b18b8ec7d",
|
||||
"evidenceGateCommit": "62a4067cf7a462ebe7cf29524238bc5d0a70b813",
|
||||
"upstreamAuditCommit": "b2f5f531019a70801444987dbb97dc1f69cf47de"
|
||||
},
|
||||
"vendorClientIdMessageIdEvidence": {
|
||||
"status": "absent",
|
||||
"clientIdPromotable": false
|
||||
},
|
||||
"authorityPolicy": {
|
||||
"outboundQuoteAuthority": "disabled",
|
||||
"clientIdRole": "non-authoritative-correlation-only",
|
||||
"authoritativeMessageIdSources": [
|
||||
"validated-inbound-raw-wire.vendor_message_id"
|
||||
],
|
||||
"quotedSendWithoutAuthoritativeMessageId": "fail-closed",
|
||||
"plainOutboundWithoutQuoteAuthority": "allowed-only-by-existing-b4-allowlist",
|
||||
"syntheticMapping": false,
|
||||
"timestampFallback": false,
|
||||
"localSequenceFallback": false,
|
||||
"contentHashFallback": false,
|
||||
"outboundReceiptFallback": false
|
||||
},
|
||||
"implementationConstraints": {
|
||||
"scopeExpansion": false,
|
||||
"exactAllowlistRequired": true,
|
||||
"missingAllowlistStatus": "BLOCKED_SCOPE",
|
||||
"productionChangeAuthorized": false,
|
||||
"canaryAuthorized": false,
|
||||
"tagAuthorized": false,
|
||||
"forcePushAuthorized": false
|
||||
},
|
||||
"production": {
|
||||
"baseline": "V1R4C6",
|
||||
"changed": false,
|
||||
"releaseStatus": "paused"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
该 JSON 必须作为独立 control-plane amendment 提交。
|
||||
|
||||
## 5. B4 强制语义
|
||||
|
||||
B4 实现必须遵守:
|
||||
|
||||
```yaml
|
||||
client_id:
|
||||
role: correlation-only
|
||||
authoritative: false
|
||||
|
||||
vendor_message_id:
|
||||
authoritative_only_when:
|
||||
- observed in validated inbound raw-wire
|
||||
- present in committed authority map
|
||||
|
||||
quoted_send:
|
||||
without_authoritative_vendor_message_id: fail-closed
|
||||
|
||||
plain_outbound:
|
||||
allowed_only_when:
|
||||
- existing B4 exact allowlist permits it
|
||||
- no quote/reply authority field is emitted
|
||||
```
|
||||
|
||||
禁止:
|
||||
|
||||
* `client_id` → `message_id` alias;
|
||||
* client ID hash fallback;
|
||||
* timestamp fallback;
|
||||
* local sequence fallback;
|
||||
* content hash fallback;
|
||||
* outbound receipt fallback;
|
||||
* synthetic message ID;
|
||||
-“先发送后补 authority”。
|
||||
|
||||
## 6. B4 路径授权
|
||||
|
||||
该 amendment 不增加任何实现路径。
|
||||
|
||||
Codex必须先找到已有 B4 exact allowlist。
|
||||
|
||||
若不存在或不完整:
|
||||
|
||||
```yaml
|
||||
status: BLOCKED_SCOPE
|
||||
```
|
||||
|
||||
不得由 Codex自行发明路径。
|
||||
|
||||
## 7. B5–B7 条件
|
||||
|
||||
只有 B3 和 B4 均有 qualifying PASS RESULT 后,才可继续 B5–B7。
|
||||
|
||||
每个阶段必须:
|
||||
|
||||
* 独立 branch;
|
||||
* 独立 worktree;
|
||||
* 已提交 exact allowlist;
|
||||
* 一个非 merge implementation commit;
|
||||
* 独立 RESULT;
|
||||
* 依赖实际 F0 commit 与 immutable image digest;
|
||||
* 不修改生产;
|
||||
* 不 force push。
|
||||
|
||||
任一阶段缺少 exact allowlist:
|
||||
|
||||
```yaml
|
||||
status: BLOCKED_SCOPE
|
||||
```
|
||||
|
||||
## 8. Release Preflight
|
||||
|
||||
只有以下全部 PASS 后才能开始:
|
||||
|
||||
```yaml
|
||||
F0: PASS
|
||||
B3: PASS
|
||||
B4: PASS
|
||||
B5: PASS
|
||||
B6: PASS
|
||||
B7: PASS
|
||||
R10_lineage: PASS
|
||||
immutable_image_digest: present
|
||||
gitleaks: PASS
|
||||
rollback_contract: present
|
||||
```
|
||||
|
||||
Preflight 不得创建 tag 或部署生产。
|
||||
|
||||
## 9. Canary
|
||||
|
||||
仅在 Release Preflight PASS 后授权一次 Canary。
|
||||
|
||||
Canary 必须:
|
||||
|
||||
* 使用固定 immutable image digest;
|
||||
* 使用已验证 rollback target;
|
||||
* 不修改生产 schema;
|
||||
* 不暴露 secrets;
|
||||
* 有明确成功指标;
|
||||
* 有自动/立即 rollback 条件。
|
||||
|
||||
Canary 失败:
|
||||
|
||||
```yaml
|
||||
rollback: required
|
||||
release_status: paused
|
||||
production_baseline: V1R4C6
|
||||
```
|
||||
|
||||
不得继续 production promotion。
|
||||
|
||||
## 10. Production 边界
|
||||
|
||||
Codex不得直接执行生产部署。
|
||||
|
||||
只有以下全部提交并核验后,Codex才可创建 deployment request:
|
||||
|
||||
```yaml
|
||||
F0_and_B3_B7: PASS
|
||||
release_preflight: PASS
|
||||
canary: PASS
|
||||
immutable_release_digest: present
|
||||
rollback_target: verified
|
||||
rollback_procedure: verified
|
||||
go_live_evidence: committed
|
||||
```
|
||||
|
||||
实际 V2R1C0 部署只能由:
|
||||
|
||||
```text
|
||||
owner/admin GitOps release action
|
||||
```
|
||||
|
||||
执行。
|
||||
|
||||
在该动作前:
|
||||
|
||||
```yaml
|
||||
production: V1R4C6
|
||||
production_changed: false
|
||||
release: paused
|
||||
V2R1C0_tag: absent
|
||||
```
|
||||
|
||||
必须保持不变。
|
||||
|
||||
## 11. Codex 下一步
|
||||
|
||||
立即执行顺序:
|
||||
|
||||
1. 在现有 control branch 提交本授权文件;
|
||||
2. 提交 byte-exact B4 amendment;
|
||||
3. 检查 B3 packet 中是否已有真实 owner raw-wire、双 credential、authority map、mode 和 reviewer evidence;
|
||||
4. 有真实证据则完成 B3 manifest 与审核;
|
||||
5. 无真实证据则继续 `BLOCKED_EVIDENCE_REQUIRED`,明确缺失项;
|
||||
6. B3 PASS 后按已有 exact allowlist执行 B3;
|
||||
7. B4 按 quote-authority-disabled 模式执行;
|
||||
8. B3/B4 PASS 后再检查 B5–B7;
|
||||
9. 不得跳到 Release Preflight、Canary 或生产。
|
||||
|
||||
本授权已解除 B4 vendor mapping 阻塞,但没有伪造或豁免 B3 owner evidence。
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
# RESULT-CODEX-V2R1-PERSONAL-WEIXIN-B3-REPAIR3-GATE0-20260717T214100UTC8
|
||||
|
||||
```yaml
|
||||
result_scope: RESULT-only independent review
|
||||
review_outcome: PASS
|
||||
repair3_state: PREPARED_NOT_ACCEPTED
|
||||
openclaw_gate0: PASS
|
||||
implementation_commit: e110cda9968cadc5c7b7179ec5003b05aad11ce4
|
||||
implementation_parent: 8d9c6da90fc8caa69b691d243c6fefb40b6b0575
|
||||
implementation_branch: patch/codex/v2r1-personal-weixin-b3-repair3-gate0-20260717T203000UTC8
|
||||
force_push_detected: false
|
||||
runner_recovered: NO
|
||||
baseline: V1R4C6
|
||||
release: paused
|
||||
production_release: NOT_STARTED
|
||||
real_b3: NOT_STARTED
|
||||
b4: NOT_STARTED
|
||||
merge: NOT_STARTED
|
||||
staging: NOT_STARTED
|
||||
canary: NOT_STARTED
|
||||
production: NOT_STARTED
|
||||
```
|
||||
|
||||
## 结论
|
||||
|
||||
Personal-Weixin Repair 3 候选实现通过 OpenClaw Gate 0 与独立 RESULT-only 审查。该结论只确认
|
||||
候选提交的 exact-4 范围、安全合同、测试证据、Gitleaks 和三方 OID;不等于真实 B3 Evidence
|
||||
PASS,不授权 B4、合并、staging、Canary 或生产发布。
|
||||
|
||||
保持:
|
||||
|
||||
```text
|
||||
RUNNER_RECOVERED=NO
|
||||
B3_REPAIR3=PREPARED_NOT_ACCEPTED
|
||||
BASELINE=V1R4C6
|
||||
RELEASE=paused
|
||||
PRODUCTION_RELEASE=NOT_STARTED
|
||||
```
|
||||
|
||||
## 范围与 lineage
|
||||
|
||||
候选 `e110cda9968cadc5c7b7179ec5003b05aad11ce4` 是洁净 base
|
||||
`8d9c6da90fc8caa69b691d243c6fefb40b6b0575` 的单一 append-only 子提交。变更精确限制为:
|
||||
|
||||
```text
|
||||
.agent/tasks/codex/TASK-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-COLLECT-20260716.md
|
||||
tools/personal_weixin/README-B3-EVIDENCE.md
|
||||
tools/personal_weixin/pwx_b3_evidence_facts.template.json
|
||||
tools/personal_weixin/pwx_b3_evidence_tool.py
|
||||
```
|
||||
|
||||
未检测到 amend、reset、rebase 或 force push;implementation worktree 在提交、复测和推送后均
|
||||
clean。
|
||||
|
||||
## 安全合同审查
|
||||
|
||||
- `prepare-review`、`finalize` 强制 `--trust-policy`;
|
||||
- capture、credential、reviewer 的 attestation、signature、principal 全部必填,三类
|
||||
`ssh-keygen -Y verify` 均实际进入业务路径;
|
||||
- policy 与 allowed-signers 采用绝对路径、UID 0、`mode & 0022 == 0`、逐级 no-follow dirfd
|
||||
检查,并重算 allowed-signers SHA-256;
|
||||
- namespace 精确固定为 `aios-pwx-b3-capture`、`aios-pwx-b3-credential`、
|
||||
`aios-pwx-b3-review`;principal 来自 facts/attestation,namespace 只来自 policy;
|
||||
- reviewer 与 executor、全部 capture/credential principal 隔离,签署 byte-exact manifest
|
||||
SHA-256,时间不早于所有 capture/test;
|
||||
- inbound vendor message ID 和 client ID 均要求严格 `present: true`;missing、`false`、
|
||||
`null`、`<ABSENT>` fail-closed,`client_id` 永远非权威;
|
||||
- 输入原始绝对路径通过 no-follow dirfd component walk;输出使用 parent dirfd、
|
||||
`O_DIRECTORY|O_NOFOLLOW`、inode/device 复核、dirfd temp、fsync 与原子发布;
|
||||
- 默认模板保持空证据并 fail-closed;README 不存在 unsigned/no-policy 降级路径;
|
||||
- 所有负向接口为 exit code `2`、stdout 精确 `BLOCKED_EVIDENCE_REQUIRED\n`、stderr 单行稳定
|
||||
error code,且无外部命令输出或 traceback。
|
||||
|
||||
## 测试与扫描
|
||||
|
||||
Gate 0 前、OpenClaw Gate 0 内及 Gate 0 后均执行 Repair 3 真实签名 harness。最终 post-gate
|
||||
结果:
|
||||
|
||||
```text
|
||||
REPAIR3_SECURITY_TESTS=PASS
|
||||
REAL_SSH_SIGNATURES=capture,credential,reviewer
|
||||
DIRFD_ATOMIC_WRITE=PASS
|
||||
NEGATIVE_CASES=policy_required,digest,namespace,policy_signer_owner_mode_symlink,
|
||||
wrong_signer,wrong_principal,self_review,principal_conflict,stale_review,
|
||||
authority_missing_false_null_absent,ancestor_and_leaf_symlink_input_output,
|
||||
template_fail_closed
|
||||
```
|
||||
|
||||
每个负向同时断言:exit `2`、精确 blocked stdout、单行稳定 stderr code、无意外输出。
|
||||
|
||||
其他最终验证:
|
||||
|
||||
```text
|
||||
python compile: PASS (173 tracked Python files)
|
||||
shell syntax: PASS (42 tracked shell files)
|
||||
JSON parse: PASS
|
||||
git diff --check: PASS
|
||||
unittest: PASS (26 tests)
|
||||
full pytest: PASS (314 passed, 34 subtests passed)
|
||||
Gitleaks: PASS (1 implementation commit scanned, no leaks found)
|
||||
exact-4 audit: PASS
|
||||
```
|
||||
|
||||
## OpenClaw Gate 0
|
||||
|
||||
持久 gate 文件:
|
||||
|
||||
```text
|
||||
/var/lib/aios/openclaw-home/.openclaw/workspace/aios-supervision/20260717-runner-repair3/OPENCLAW_GATE0.json
|
||||
```
|
||||
|
||||
该文件以 `status=PASS` 绑定 candidate/base、single-parent、exact-4、clean worktree、无 force
|
||||
push、真实 SSH 签名测试、Python/JSON/diff/Gitleaks 与全部 mandatory security properties。
|
||||
|
||||
## 三方 OID
|
||||
|
||||
```text
|
||||
WORKTREE_OID=e110cda9968cadc5c7b7179ec5003b05aad11ce4
|
||||
BARE_OID=e110cda9968cadc5c7b7179ec5003b05aad11ce4
|
||||
GITEA_OID=e110cda9968cadc5c7b7179ec5003b05aad11ce4
|
||||
OID_TRIPLET=PASS
|
||||
```
|
||||
|
||||
## Track A 并行状态
|
||||
|
||||
Runner adapter 仍被 OpenClaw 判定为 `BLOCKED`:live host 不存在
|
||||
`/usr/libexec/aios-spark-runner/secret-ref-probe`,无法形成完整 live
|
||||
`SECRET_PROVIDER_CONTRACT`。因此未创建 adapter branch,未修改受保护提交
|
||||
`bbd27cf06acf1f589eef291eab54d4b127aa3aea`,`RUNNER_RECOVERED=NO`。
|
||||
|
||||
## 后续门禁
|
||||
|
||||
本 RESULT 通过后只允许开始真实 B3 Evidence intake。真实 capture/credential/reviewer evidence
|
||||
任一缺失时必须保持 `BLOCKED_EVIDENCE_REQUIRED`;只有真实 B3 独立 PASS 后才允许 B4。B4、
|
||||
主线合并、staging、Canary 和生产仍未开始。
|
||||
@@ -0,0 +1,156 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,138 @@
|
||||
# TASK-CODEX-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-COLLECT-20260716
|
||||
|
||||
```yaml
|
||||
task_id: TASK-CODEX-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-COLLECT-20260716
|
||||
executor: Codex isolated runner
|
||||
source_base: 53c67bd9780074bad9781fb97971463156bc6609
|
||||
authorization_commit: a1f933e500151dc8afc1b25b30448a31899a8f64 # gitleaks:allow
|
||||
b4_amendment_commit: 1d334b1ccaac54c3c3ec4dd84693ab68fc67cef3
|
||||
production: V1R4C6
|
||||
release: paused
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
**B3 status**: `BLOCKED_EVIDENCE_REQUIRED` (evidence collection not started, owner must prepare facts and produce restricted raw data)
|
||||
**B2 status**: `NOT_APPLICABLE` (reserved, not yet reached)
|
||||
**Production baseline**: V1R4C6 (the most recent approved production release)
|
||||
**Repair 3**: `PREPARED_NOT_ACCEPTED`;只有 `OPENCLAW_GATE0=PASS` 后才允许复测、推送、OID 与 RESULT-only 审查。
|
||||
|
||||
## Scope
|
||||
|
||||
本任务仅使用 Git 中的以下工具与模板,采集并验证真实 owner evidence:
|
||||
|
||||
```text
|
||||
tools/personal_weixin/pwx_b3_evidence_tool.py
|
||||
tools/personal_weixin/pwx_b3_evidence_facts.template.json
|
||||
tools/personal_weixin/README-B3-EVIDENCE.md
|
||||
```
|
||||
|
||||
工具是 verifier/generator,不是网络 capture client。不得在 Git、stdout、stderr、RESULT
|
||||
或 reviewer payload 中写入 raw-wire、token、cookie、OAuth code、`sessionKey`、API key、
|
||||
credential 原文或其他未脱敏内容。
|
||||
|
||||
## Two-stage evidence flow
|
||||
|
||||
1. Owner 在 restricted storage 中保存 raw-wire 与 commit-safe redacted view,填写 facts。
|
||||
2. `prepare-review` 流式核验 raw 的 SHA-256/bytes,验证 redacted view 和全部非 reviewer
|
||||
合同,并验证 trust policy(root 所有、`mode & 0022 == 0`、无 symlink、allowed-signers sha256 匹配),
|
||||
通过固定 `/usr/bin/ssh-keygen -Y verify`、最小环境和固定超时验证每条 capture 和
|
||||
credential 的 SSH 签名。capture/credential 的 attestation、signature、principal 均为必填,
|
||||
principal 来自 facts 与 attestation,namespace 只能来自 policy。
|
||||
生成 byte-exact canonical reviewer payload 与 SHA-256。该阶段始终返回
|
||||
`BLOCKED_EVIDENCE_REQUIRED`,不能作为 B3 PASS。
|
||||
3. 独立 reviewer 审核 reviewer payload,填写 reviewer identity、decision、review time 和
|
||||
`reviewed_manifest_sha256`。
|
||||
4. `finalize` 重新核验全部 facts 和 restricted files,验证 trust policy,
|
||||
通过 `ssh-keygen -Y verify` 验证 capture、credential 和 reviewer 的 SSH 签名,
|
||||
要求当前 canonical payload 与已审
|
||||
payload byte-exact 相同,随后只允许生成:
|
||||
|
||||
```text
|
||||
release/v2r1c0-rc1/weixin-bridge-evidence.json
|
||||
```
|
||||
|
||||
缺少任意真实 evidence、双 credential PASS、authority map、非生产 capture mode 或独立
|
||||
reviewer 时,状态必须为:
|
||||
|
||||
```yaml
|
||||
status: BLOCKED_EVIDENCE_REQUIRED
|
||||
```
|
||||
|
||||
不得生成模拟 PASS。
|
||||
|
||||
## Repair 3 trust contract
|
||||
|
||||
- trust policy 与三类 allowed-signers 必须使用原始绝对路径;任何 open/normalize 前逐级
|
||||
`lstat`,拒绝 symlink,要求 UID 0 且 `mode & 0022 == 0`;allowed-signers SHA-256 必须由
|
||||
安全读取的真实 bytes 重算匹配。
|
||||
- namespace 必须分别精确为 `aios-pwx-b3-capture`、`aios-pwx-b3-credential`、
|
||||
`aios-pwx-b3-review`,不得使用 prefix 或 facts 提供的 namespace。
|
||||
- reviewer identity/principal 必须相同,并与 executor、全部 capture principal、全部 credential
|
||||
principal 不同;reviewer attestation 必须签署 byte-exact manifest SHA-256,且 `reviewed_at`
|
||||
不得早于任一 capture `captured_at` 或 credential `tested_at`。
|
||||
- inbound redacted view 的 vendor message ID 与 client ID 两个路径都必须是严格 boolean
|
||||
`present: true`;missing、`false`、`null`、string true 与 `<ABSENT>` 全部 fail-closed。
|
||||
`client_id_authoritative` 永远为 `false`,不得参与 authority 决策。
|
||||
- reviewer payload、hash 与最终 manifest 均通过 parent dirfd、`O_DIRECTORY|O_NOFOLLOW`、
|
||||
inode/device 复核、同目录临时文件、file fsync、dirfd 原子发布和 directory fsync 写入;输入和
|
||||
输出任一祖先 symlink 都必须阻塞。
|
||||
- 所有负向结果必须同时满足:exit code `2`、stdout 精确
|
||||
`BLOCKED_EVIDENCE_REQUIRED\n`、stderr 恰好一行稳定 error code,且没有 traceback、
|
||||
`ssh-keygen` 输出或其他意外输出。
|
||||
|
||||
Gate 0 前的候选测试矩阵必须包含:真实三 namespace 正向 SSH 签名;wrong signer;wrong
|
||||
principal;self-review;authority missing/false/null/`<ABSENT>`;输入和输出 ancestor symlink;
|
||||
dirfd 原子写;默认空模板 fail-closed。测试材料只允许位于 `/tmp` 或 owner restricted storage,
|
||||
不得形成第五个 tracked path。
|
||||
|
||||
## Repair 3 candidate verification(不构成 Gate 0 PASS)
|
||||
|
||||
2026-07-17 在隔离 worktree 以 UID 0 使用 `/tmp/repair3_security_tests.py` 通过真实
|
||||
`ssh-keygen` 生成、签名和验证三类 namespace;正向 finalize 与 dirfd 原子写均 PASS。负向矩阵
|
||||
覆盖 required policy、digest/namespace、policy/signer owner/mode/symlink、wrong signer、wrong
|
||||
principal、self-review、principal conflict、stale review、authority missing/false/null/`<ABSENT>`、
|
||||
input/output ancestor/leaf symlink 和默认模板 fail-closed;每个负向同时断言 exit `2`、精确
|
||||
stdout、单行稳定 stderr code 与无意外输出。
|
||||
|
||||
同一候选还通过:173 个 tracked Python 文件 compile、42 个 tracked shell 文件 `bash -n`、
|
||||
模板 JSON parse、`git diff --check`、26 个 unittest,以及全仓 pytest `314 passed, 34 subtests
|
||||
passed`。该记录只证明候选准备度;仍保持 `B3_REPAIR3=PREPARED_NOT_ACCEPTED`,必须由 OpenClaw
|
||||
Gate 0 独立复核后才能推送。
|
||||
|
||||
## Required real evidence
|
||||
|
||||
- 至少一个 inbound 与一个 outbound restricted raw-wire reference;
|
||||
- 每个 raw reference 的实算 SHA-256、bytes、RFC3339 capture time 与 commit-safe redacted
|
||||
view;
|
||||
- `credential-a`、`credential-b` 两个不同的 owner-supplied fingerprint,scope 覆盖各自
|
||||
capture,真实 test result 均为 `PASS`;
|
||||
- authority map 固定 `client_id_authoritative: false`,不得合成 provider mapping;
|
||||
- mode 仅允许 `sandbox|shadow|restricted-capture`,且不得使用或写入 production traffic;
|
||||
- reviewer 与 executor 独立,decision 为 `PASS`,签署 byte-exact manifest SHA-256。
|
||||
|
||||
## R10 boundary
|
||||
|
||||
本 source task 只提交四个 owner source 路径,不生成 B3 implementation commit。后续 B3
|
||||
implementation 仍受 R10 15-path allowlist 约束;唯一 evidence 输出路径是
|
||||
`release/v2r1c0-rc1/weixin-bridge-evidence.json`。只有 qualifying B3 PASS RESULT 后才允许
|
||||
启动 B4 implementation。
|
||||
|
||||
## Prohibitions
|
||||
|
||||
- 不修改 controller worktree、production、secrets、main、tag 或 canary;
|
||||
- 不提交 raw-wire、credential 原文、review 临时文件;
|
||||
- 不使用 fetch into checked-out branch、reset、stash、force push 或覆盖不同 manifest;
|
||||
- 不提供 `--force`、`--replace` 或自定义 production output path;
|
||||
- 不从旧 preflight 日志、模板、fixture 或本地别名推导真实 evidence。
|
||||
|
||||
## Result
|
||||
|
||||
执行后生成:
|
||||
|
||||
```text
|
||||
.agent/results/codex/RESULT-CODEX-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-<timestamp>.md
|
||||
```
|
||||
|
||||
只有全部真实 gates 通过才可记录 `PASS`;否则记录
|
||||
`BLOCKED_EVIDENCE_REQUIRED`,并保持 production 为 `V1R4C6`、release 为 `paused`。
|
||||
@@ -11,3 +11,7 @@ runtime/tmp/
|
||||
runtime/cache/
|
||||
logs/
|
||||
config/test_write_perms
|
||||
/data/webops.sqlite3
|
||||
/data/webops.sqlite3-wal
|
||||
/data/webops.sqlite3-shm
|
||||
/data/webops.sqlite3-journal
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Canonical AIOS V2R1 runtime package."""
|
||||
|
||||
__version__ = "2.1.0-rc.1"
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Append-only, tamper-evident audit chain for AIOS V2R1."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import closing
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from .tenant_context import ConflictError, canonical_json, isoformat, require_ident
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class AuditLog:
|
||||
def __init__(self, database: str | Path) -> None:
|
||||
self.database = Path(database)
|
||||
self.database.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._initialize()
|
||||
os.chmod(self.database, 0o600)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
db = sqlite3.connect(str(self.database), timeout=30)
|
||||
db.execute("PRAGMA journal_mode=WAL")
|
||||
db.execute("PRAGMA synchronous=FULL")
|
||||
db.execute("PRAGMA busy_timeout=30000")
|
||||
return db
|
||||
|
||||
def _storage(self, operation: Callable[[], T]) -> T:
|
||||
failure: ConflictError | None = None
|
||||
try:
|
||||
return operation()
|
||||
except sqlite3.Error:
|
||||
failure = ConflictError("audit storage unavailable")
|
||||
raise failure
|
||||
|
||||
def _initialize(self) -> None:
|
||||
def initialize() -> None:
|
||||
with closing(self._connect()) as connection, connection as db:
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS audit_events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tenant_id TEXT NOT NULL,
|
||||
trace_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
previous_hash TEXT NOT NULL,
|
||||
event_hash TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._storage(initialize)
|
||||
|
||||
def append(
|
||||
self, *, tenant_id: str, trace_id: str, event_type: str,
|
||||
payload: dict[str, Any],
|
||||
) -> str:
|
||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
||||
trace_id = require_ident("trace_id", trace_id)
|
||||
event_type = require_ident("event_type", event_type)
|
||||
|
||||
def write() -> str:
|
||||
with closing(self._connect()) as connection, connection as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
previous = db.execute(
|
||||
"SELECT event_hash FROM audit_events ORDER BY seq DESC LIMIT 1"
|
||||
).fetchone()
|
||||
previous_hash = previous[0] if previous else "GENESIS"
|
||||
body = {
|
||||
"tenant_id": tenant_id, "trace_id": trace_id,
|
||||
"event_type": event_type, "payload": payload,
|
||||
"previous_hash": previous_hash, "created_at": isoformat(),
|
||||
}
|
||||
digest = hashlib.sha256(canonical_json(body)).hexdigest()
|
||||
db.execute(
|
||||
"INSERT INTO audit_events VALUES(NULL,?,?,?,?,?,?,?)",
|
||||
(
|
||||
tenant_id, trace_id, event_type,
|
||||
json.dumps(payload, ensure_ascii=False),
|
||||
previous_hash, digest, body["created_at"],
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
return digest
|
||||
|
||||
return self._storage(write)
|
||||
|
||||
def verify(self) -> bool:
|
||||
def check() -> bool:
|
||||
previous = "GENESIS"
|
||||
with closing(self._connect()) as connection, connection as db:
|
||||
rows = db.execute(
|
||||
"SELECT tenant_id,trace_id,event_type,payload,previous_hash,"
|
||||
"event_hash,created_at FROM audit_events ORDER BY seq"
|
||||
)
|
||||
for row in rows:
|
||||
try:
|
||||
payload = json.loads(row[3])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return False
|
||||
body = {
|
||||
"tenant_id": row[0], "trace_id": row[1],
|
||||
"event_type": row[2], "payload": payload,
|
||||
"previous_hash": row[4], "created_at": row[6],
|
||||
}
|
||||
if row[4] != previous:
|
||||
return False
|
||||
digest = hashlib.sha256(canonical_json(body)).hexdigest()
|
||||
if digest != row[5]:
|
||||
return False
|
||||
previous = digest
|
||||
return True
|
||||
|
||||
return self._storage(check)
|
||||
@@ -0,0 +1,162 @@
|
||||
"""AIOS V2R1 Tenant Cell lifecycle and atomic registry primitives."""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
from typing import Iterator
|
||||
|
||||
from .tenant_context import ValidationError, require_ident
|
||||
|
||||
_THREAD_LOCKS_GUARD = threading.Lock()
|
||||
_THREAD_LOCKS: dict[str, threading.RLock] = {}
|
||||
_STATES = {"created", "starting", "healthy", "upgrading", "rollback", "failed"}
|
||||
_TRANSITIONS = {
|
||||
"created": {"starting", "failed"},
|
||||
"starting": {"healthy", "failed"},
|
||||
"healthy": {"upgrading", "failed"},
|
||||
"upgrading": {"healthy", "rollback", "failed"},
|
||||
"rollback": {"healthy", "failed"},
|
||||
"failed": {"starting"},
|
||||
}
|
||||
|
||||
|
||||
def _lock_for(path: Path) -> threading.RLock:
|
||||
key = str(path.resolve())
|
||||
with _THREAD_LOCKS_GUARD:
|
||||
return _THREAD_LOCKS.setdefault(key, threading.RLock())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CellRecord:
|
||||
tenant_id: str
|
||||
cell_id: str
|
||||
version: str
|
||||
state: str
|
||||
config_digest: str
|
||||
created_at: str
|
||||
|
||||
|
||||
class CellManager:
|
||||
def __init__(self, registry: str | Path) -> None:
|
||||
self.registry = Path(registry)
|
||||
self.registry.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock_file = self.registry.with_name(self.registry.name + ".lock")
|
||||
self._thread_lock = _lock_for(self.registry)
|
||||
with self._exclusive():
|
||||
if not self.registry.exists():
|
||||
self._save({"cells": {}})
|
||||
else:
|
||||
self._load()
|
||||
|
||||
@contextmanager
|
||||
def _exclusive(self) -> Iterator[None]:
|
||||
with self._thread_lock:
|
||||
with self._lock_file.open("a+", encoding="utf-8") as lock:
|
||||
os.chmod(self._lock_file, 0o600)
|
||||
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
def _load(self) -> dict[str, dict[str, dict[str, str]]]:
|
||||
failure: ValidationError | None = None
|
||||
try:
|
||||
value = json.loads(self.registry.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
failure = ValidationError("cell registry unavailable")
|
||||
if failure is not None:
|
||||
raise failure
|
||||
if not isinstance(value, dict) or set(value) != {"cells"}:
|
||||
raise ValidationError("cell registry unavailable")
|
||||
cells = value["cells"]
|
||||
if not isinstance(cells, dict):
|
||||
raise ValidationError("cell registry unavailable")
|
||||
expected = {
|
||||
"tenant_id", "cell_id", "version", "state",
|
||||
"config_digest", "created_at",
|
||||
}
|
||||
for tenant_id, item in cells.items():
|
||||
if (
|
||||
not isinstance(item, dict) or set(item) != expected
|
||||
or item.get("tenant_id") != tenant_id
|
||||
or item.get("state") not in _STATES
|
||||
or any(not isinstance(item.get(name), str) for name in expected)
|
||||
):
|
||||
raise ValidationError("cell registry unavailable")
|
||||
return value
|
||||
|
||||
def _save(self, value: dict[str, object]) -> None:
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{self.registry.name}.", dir=self.registry.parent, text=True
|
||||
)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as output:
|
||||
json.dump(value, output, ensure_ascii=False, indent=2)
|
||||
output.write("\n")
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, self.registry)
|
||||
directory = os.open(self.registry.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
finally:
|
||||
if temporary.exists():
|
||||
temporary.unlink()
|
||||
|
||||
def create(
|
||||
self, tenant_id: str, cell_id: str, version: str, digest: str,
|
||||
) -> CellRecord:
|
||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
||||
cell_id = require_ident("cell_id", cell_id)
|
||||
version = require_ident("version", version)
|
||||
digest = require_ident("digest", digest)
|
||||
with self._exclusive():
|
||||
data = self._load()
|
||||
if tenant_id in data["cells"]:
|
||||
raise ValidationError("tenant already has a cell")
|
||||
record = CellRecord(
|
||||
tenant_id, cell_id, version, "created", digest,
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
data["cells"][tenant_id] = record.__dict__
|
||||
self._save(data)
|
||||
return record
|
||||
|
||||
def status(self, tenant_id: str) -> CellRecord:
|
||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
||||
with self._exclusive():
|
||||
data = self._load()
|
||||
item = data["cells"].get(tenant_id)
|
||||
if not item:
|
||||
raise ValidationError("unknown tenant")
|
||||
return CellRecord(**item)
|
||||
|
||||
def transition(self, tenant_id: str, state: str) -> CellRecord:
|
||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
||||
if state not in _STATES:
|
||||
raise ValidationError("invalid cell state")
|
||||
with self._exclusive():
|
||||
data = self._load()
|
||||
item = data["cells"].get(tenant_id)
|
||||
if not item:
|
||||
raise ValidationError("unknown tenant")
|
||||
current = item["state"]
|
||||
if state == current:
|
||||
return CellRecord(**item)
|
||||
if state not in _TRANSITIONS[current]:
|
||||
raise ValidationError(f"invalid cell transition: {current}->{state}")
|
||||
item["state"] = state
|
||||
self._save(data)
|
||||
return CellRecord(**item)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""The only V2R1 ingress adapter: verified context to routed ledger record."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from threading import RLock
|
||||
from typing import Callable, Mapping, Any
|
||||
|
||||
from .message_ledger import LedgerRecord, MessageLedger
|
||||
from .router import Route, TenantRouter
|
||||
from .session_pool import SessionLease, SessionPool
|
||||
from .tenant_context import CanonicalMessage, TenantContext, utc_now
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IngressResult:
|
||||
record: LedgerRecord
|
||||
route: Route
|
||||
session: SessionLease
|
||||
|
||||
|
||||
class ChannelAdapter:
|
||||
"""Fail-closed runtime ingress composition.
|
||||
|
||||
The adapter intentionally accepts no tenant or Cell authority outside the
|
||||
signed context and TenantRouter. A process lock keeps rollback handling
|
||||
deterministic for a single runtime while SQLite protects cross-process
|
||||
ledger writes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
router: TenantRouter,
|
||||
ledger: MessageLedger,
|
||||
session_pool: SessionPool,
|
||||
signing_key: bytes,
|
||||
clock: Callable[[], datetime] = utc_now,
|
||||
) -> None:
|
||||
self._router = router
|
||||
self._ledger = ledger
|
||||
self._session_pool = session_pool
|
||||
self._signing_key = signing_key
|
||||
self._clock = clock
|
||||
self._lock = RLock()
|
||||
|
||||
def ingest(self, *, token: str, message: Mapping[str, Any]) -> IngressResult:
|
||||
with self._lock:
|
||||
context = TenantContext.verify(token, self._signing_key, now=self._clock())
|
||||
canonical = CanonicalMessage.from_mapping(message)
|
||||
canonical.assert_context(context)
|
||||
route = self._router.assert_context_route(context)
|
||||
self._router.assert_message_route(canonical)
|
||||
self._session_pool.assert_available(context, route)
|
||||
|
||||
existed = self._ledger.matches(canonical)
|
||||
record = self._ledger.append(canonical)
|
||||
try:
|
||||
lease = self._session_pool.acquire(context, route)
|
||||
except Exception:
|
||||
if not existed:
|
||||
self._ledger.remove(record)
|
||||
raise
|
||||
return IngressResult(record=record, route=route, session=lease)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""aios-cell command implementation for V2R1 Cell lifecycle."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from .cell_manager import CellManager
|
||||
from .ingress_service import serve
|
||||
from .tenant_context import V2R1Error, ValidationError
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="aios-cell")
|
||||
parser.add_argument("registry")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
create = sub.add_parser("create")
|
||||
create.add_argument("tenant_id")
|
||||
create.add_argument("cell_id")
|
||||
create.add_argument("version")
|
||||
create.add_argument("digest")
|
||||
|
||||
status = sub.add_parser("status")
|
||||
status.add_argument("tenant_id")
|
||||
|
||||
transition = sub.add_parser("transition")
|
||||
transition.add_argument("tenant_id")
|
||||
transition.add_argument("state")
|
||||
return parser
|
||||
|
||||
|
||||
def build_serve_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="aios-cell serve")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=18792)
|
||||
return parser
|
||||
|
||||
|
||||
def _domain_failure(error: V2R1Error) -> int:
|
||||
code = "VALIDATION_ERROR" if isinstance(error, ValidationError) else "RUNTIME_ERROR"
|
||||
print(
|
||||
json.dumps({"code": code, "error": str(error)}, ensure_ascii=False, sort_keys=True),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 3
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
values = list(sys.argv[1:] if argv is None else argv)
|
||||
service_mode = (
|
||||
bool(values) and values[0] == "serve"
|
||||
and (len(values) == 1 or values[1].startswith("-"))
|
||||
)
|
||||
if service_mode:
|
||||
serve_args = build_serve_parser().parse_args(values[1:])
|
||||
serve(serve_args.host, serve_args.port)
|
||||
return 0
|
||||
|
||||
args = build_parser().parse_args(values)
|
||||
try:
|
||||
manager = CellManager(args.registry)
|
||||
if args.command == "create":
|
||||
result = manager.create(
|
||||
args.tenant_id, args.cell_id, args.version, args.digest
|
||||
)
|
||||
elif args.command == "status":
|
||||
result = manager.status(args.tenant_id)
|
||||
elif args.command == "transition":
|
||||
result = manager.transition(args.tenant_id, args.state)
|
||||
else:
|
||||
return 2
|
||||
except V2R1Error as error:
|
||||
return _domain_failure(error)
|
||||
|
||||
print(json.dumps(result.__dict__, ensure_ascii=False, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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,164 @@
|
||||
"""Tenant-partitioned Hermes shared agent fabric primitives."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import closing
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from .tenant_context import (
|
||||
CanonicalMessage, ConflictError, ScopeError, ValidationError,
|
||||
isoformat, require_ident,
|
||||
)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FabricJob:
|
||||
job_id: str
|
||||
tenant_id: str
|
||||
cell_id: str
|
||||
profile_id: str
|
||||
session_id: str
|
||||
event_id: str
|
||||
status: str = "queued"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("job_id", "cell_id", "profile_id", "session_id", "event_id"):
|
||||
require_ident(name, getattr(self, name))
|
||||
require_ident("tenant_id", self.tenant_id, tenant=True)
|
||||
if self.status not in {"queued", "running", "completed", "failed", "cancelled"}:
|
||||
raise ValidationError("invalid job status")
|
||||
|
||||
|
||||
class HermesFabric:
|
||||
"""Shared scheduler storage with mandatory tenant partition keys."""
|
||||
|
||||
def __init__(self, database: str | Path) -> None:
|
||||
self.database = str(database)
|
||||
Path(self.database).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._initialize()
|
||||
os.chmod(self.database, 0o600)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
db = sqlite3.connect(self.database, timeout=30)
|
||||
db.execute("PRAGMA journal_mode=WAL")
|
||||
db.execute("PRAGMA synchronous=FULL")
|
||||
db.execute("PRAGMA busy_timeout=30000")
|
||||
return db
|
||||
|
||||
def _storage(self, operation: Callable[[], T]) -> T:
|
||||
failure: ConflictError | None = None
|
||||
try:
|
||||
return operation()
|
||||
except sqlite3.Error:
|
||||
failure = ConflictError("fabric storage unavailable")
|
||||
raise failure
|
||||
|
||||
def _initialize(self) -> None:
|
||||
def initialize() -> None:
|
||||
with closing(self._connect()) as connection, connection as db:
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS fabric_jobs (
|
||||
job_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
cell_id TEXT NOT NULL,
|
||||
profile_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
db.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_fabric_tenant_queue "
|
||||
"ON fabric_jobs(tenant_id, cell_id, status)"
|
||||
)
|
||||
self._storage(initialize)
|
||||
|
||||
def enqueue(
|
||||
self, *, message: CanonicalMessage, profile_id: str,
|
||||
) -> FabricJob:
|
||||
profile_id = require_ident("profile_id", profile_id)
|
||||
if not message.command_capable:
|
||||
raise ScopeError("non-command events cannot create execution jobs")
|
||||
if not message.is_authenticated_human_command:
|
||||
raise ScopeError("only authenticated human commands can create jobs")
|
||||
job = FabricJob(
|
||||
job_id=f"job-{message.event_id}", tenant_id=message.tenant_id,
|
||||
cell_id=message.cell_id, profile_id=profile_id,
|
||||
session_id=message.session_id, event_id=message.event_id,
|
||||
)
|
||||
now = isoformat()
|
||||
failure: ConflictError | None = None
|
||||
try:
|
||||
with closing(self._connect()) as connection, connection as db:
|
||||
db.execute(
|
||||
"INSERT INTO fabric_jobs VALUES(?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
job.job_id, job.tenant_id, job.cell_id, job.profile_id,
|
||||
job.session_id, job.event_id, job.status, now, now,
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
failure = ConflictError(
|
||||
"fabric event already exists or violates queue uniqueness"
|
||||
)
|
||||
except sqlite3.Error:
|
||||
failure = ConflictError("fabric storage unavailable")
|
||||
if failure is not None:
|
||||
raise failure
|
||||
return job
|
||||
|
||||
def claim(
|
||||
self, tenant_id: str, cell_id: str, profile_id: str,
|
||||
) -> FabricJob | None:
|
||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
||||
cell_id = require_ident("cell_id", cell_id)
|
||||
profile_id = require_ident("profile_id", profile_id)
|
||||
|
||||
def claim_one() -> FabricJob | None:
|
||||
with closing(self._connect()) as connection, connection as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
row = db.execute(
|
||||
"SELECT * FROM fabric_jobs WHERE tenant_id=? AND cell_id=? "
|
||||
"AND profile_id=? AND status='queued' ORDER BY created_at LIMIT 1",
|
||||
(tenant_id, cell_id, profile_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
db.commit()
|
||||
return None
|
||||
updated = db.execute(
|
||||
"UPDATE fabric_jobs SET status='running', updated_at=? "
|
||||
"WHERE job_id=? AND status='queued'",
|
||||
(isoformat(), row[0]),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
db.rollback()
|
||||
return None
|
||||
db.commit()
|
||||
return FabricJob(
|
||||
job_id=row[0], tenant_id=row[1], cell_id=row[2],
|
||||
profile_id=row[3], session_id=row[4], event_id=row[5],
|
||||
status="running",
|
||||
)
|
||||
|
||||
return self._storage(claim_one)
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
def query() -> int:
|
||||
with closing(self._connect()) as connection, connection as db:
|
||||
return int(db.execute("SELECT count(*) FROM fabric_jobs").fetchone()[0])
|
||||
|
||||
return {
|
||||
"component": "hermes_fabric", "jobs": self._storage(query),
|
||||
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Hermes shared-fabric profile isolation primitives."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from .tenant_context import ValidationError, require_ident
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HermesProfile:
|
||||
tenant_id: str
|
||||
cell_id: str
|
||||
profile_id: str
|
||||
hermes_home: Path
|
||||
workspace: Path
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_ident("tenant_id", self.tenant_id, tenant=True)
|
||||
for name in ("cell_id", "profile_id"):
|
||||
require_ident(name, getattr(self, name))
|
||||
if (
|
||||
not isinstance(self.hermes_home, Path)
|
||||
or not isinstance(self.workspace, Path)
|
||||
or not self.hermes_home.is_absolute()
|
||||
or not self.workspace.is_absolute()
|
||||
):
|
||||
raise ValidationError("profile paths must be absolute")
|
||||
|
||||
@property
|
||||
def environment(self) -> dict[str, str]:
|
||||
return {
|
||||
"HERMES_HOME": str(self.hermes_home),
|
||||
"HOME": str(self.hermes_home),
|
||||
"AIOS_TENANT_ID": self.tenant_id,
|
||||
"AIOS_CELL_ID": self.cell_id,
|
||||
"AIOS_PROFILE_ID": self.profile_id,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _reject_symlink_components(root: Path) -> None:
|
||||
current = Path(root.anchor)
|
||||
for part in root.parts[1:]:
|
||||
current = current / part
|
||||
if current.is_symlink():
|
||||
raise ValidationError("profile root contains symlink")
|
||||
|
||||
def _validate_roots(self) -> None:
|
||||
for root in (self.hermes_home, self.workspace):
|
||||
self._reject_symlink_components(root)
|
||||
|
||||
def prepare(self) -> None:
|
||||
self._validate_roots()
|
||||
for root in (self.hermes_home, self.workspace):
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
self._validate_roots()
|
||||
for root in (self.hermes_home, self.workspace):
|
||||
os.chmod(root, 0o700)
|
||||
|
||||
def assert_path_allowed(self, path: str | Path) -> None:
|
||||
self._validate_roots()
|
||||
target = Path(path).resolve()
|
||||
for root in (self.hermes_home.resolve(), self.workspace.resolve()):
|
||||
if target == root or root in target.parents:
|
||||
return
|
||||
raise ValidationError("path escapes profile isolation boundary")
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Fail-closed identity binding for authenticated external ingress.
|
||||
|
||||
External payload claims are not authority. An HMAC-authenticated envelope is
|
||||
the sole factory for :class:`AuthenticatedPrincipal`; the resolver then checks
|
||||
every claim against the authoritative ``TenantRouter`` before the runtime signs
|
||||
an internal ``TenantContext``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .router import Route, TenantRouter
|
||||
from .tenant_context import ScopeError, ValidationError, canonical_json, require_ident
|
||||
|
||||
|
||||
_PROVENANCE = object()
|
||||
_SIGNATURE_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
_SCOPE_FIELDS = (
|
||||
"principal_id", "tenant_id", "cell_id", "channel",
|
||||
"channel_account_id", "session_id", "message",
|
||||
)
|
||||
|
||||
|
||||
class AuthenticationError(ValidationError):
|
||||
"""Authentication or identity resolution failed without disclosing scope."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, init=False)
|
||||
class AuthenticatedPrincipal:
|
||||
"""A principal which only the verified envelope factory can create."""
|
||||
|
||||
principal_id: str
|
||||
|
||||
def __init__(self, principal_id: str, *, _provenance: object) -> None:
|
||||
if _provenance is not _PROVENANCE:
|
||||
raise TypeError("AuthenticatedPrincipal is created by authenticated ingress only")
|
||||
object.__setattr__(self, "principal_id", require_ident("principal_id", principal_id))
|
||||
|
||||
@classmethod
|
||||
def _verified(cls, principal_id: str) -> "AuthenticatedPrincipal":
|
||||
return cls(principal_id, _provenance=_PROVENANCE)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IdentityClaims:
|
||||
tenant_id: str
|
||||
cell_id: str
|
||||
channel: str
|
||||
channel_account_id: str
|
||||
session_id: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_ident("tenant_id", self.tenant_id, tenant=True)
|
||||
for field in ("cell_id", "channel", "channel_account_id", "session_id"):
|
||||
require_ident(field, getattr(self, field))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthenticatedIngress:
|
||||
principal: AuthenticatedPrincipal
|
||||
claims: IdentityClaims
|
||||
message: Mapping[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeIdentity:
|
||||
tenant_id: str
|
||||
cell_id: str
|
||||
principal_id: str
|
||||
channel: str
|
||||
channel_account_id: str
|
||||
session_id: str
|
||||
|
||||
|
||||
class IngressAuthenticator:
|
||||
"""Verify a canonical HMAC envelope before any identity lookup occurs."""
|
||||
|
||||
def __init__(self, hmac_key: bytes) -> None:
|
||||
if not isinstance(hmac_key, bytes) or len(hmac_key) < 32:
|
||||
raise ValidationError("ingress HMAC key must be at least 32 bytes")
|
||||
self._hmac_key = hmac_key
|
||||
|
||||
@staticmethod
|
||||
def _signed_payload(request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(request, Mapping):
|
||||
raise ValidationError("ingress request must be an object")
|
||||
if set(request) != {*_SCOPE_FIELDS, "signature"}:
|
||||
raise ValidationError("ingress request fields are invalid")
|
||||
values = {field: request[field] for field in _SCOPE_FIELDS}
|
||||
if not isinstance(values["message"], Mapping):
|
||||
raise ValidationError("ingress message must be an object")
|
||||
return values
|
||||
|
||||
def authenticate(self, request: Mapping[str, Any]) -> AuthenticatedIngress:
|
||||
values = self._signed_payload(request)
|
||||
signature = request["signature"]
|
||||
if not isinstance(signature, str) or not _SIGNATURE_RE.fullmatch(signature):
|
||||
raise AuthenticationError("invalid ingress signature")
|
||||
expected = hmac.new(
|
||||
self._hmac_key, canonical_json(values), hashlib.sha256
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(expected, signature):
|
||||
raise AuthenticationError("invalid ingress signature")
|
||||
return AuthenticatedIngress(
|
||||
principal=AuthenticatedPrincipal._verified(values["principal_id"]),
|
||||
claims=IdentityClaims(
|
||||
tenant_id=values["tenant_id"],
|
||||
cell_id=values["cell_id"],
|
||||
channel=values["channel"],
|
||||
channel_account_id=values["channel_account_id"],
|
||||
session_id=values["session_id"],
|
||||
),
|
||||
message=dict(values["message"]),
|
||||
)
|
||||
|
||||
|
||||
class RuntimeIdentityResolver:
|
||||
"""Resolve authenticated ingress identity through ``TenantRouter`` only."""
|
||||
|
||||
def __init__(self, router: TenantRouter) -> None:
|
||||
self._router = router
|
||||
|
||||
def resolve(
|
||||
self, principal: AuthenticatedPrincipal, claims: IdentityClaims
|
||||
) -> RuntimeIdentity:
|
||||
if not isinstance(principal, AuthenticatedPrincipal):
|
||||
raise AuthenticationError("untrusted principal type")
|
||||
try:
|
||||
route: Route = self._router.resolve(
|
||||
channel=claims.channel,
|
||||
channel_account_id=claims.channel_account_id,
|
||||
principal_id=principal.principal_id,
|
||||
session_id=claims.session_id,
|
||||
)
|
||||
except (ScopeError, ValidationError) as exc:
|
||||
raise AuthenticationError("identity is not registered") from None
|
||||
expected = (
|
||||
claims.tenant_id, claims.cell_id, claims.channel,
|
||||
claims.channel_account_id, principal.principal_id, claims.session_id,
|
||||
)
|
||||
actual = (
|
||||
route.tenant_id, route.cell_id, route.channel,
|
||||
route.channel_account_id, route.principal_id, route.session_id,
|
||||
)
|
||||
if actual != expected:
|
||||
raise AuthenticationError("identity claims do not match authoritative route")
|
||||
return RuntimeIdentity(
|
||||
tenant_id=route.tenant_id,
|
||||
cell_id=route.cell_id,
|
||||
principal_id=route.principal_id,
|
||||
channel=route.channel,
|
||||
channel_account_id=route.channel_account_id,
|
||||
session_id=route.session_id or "",
|
||||
)
|
||||
@@ -0,0 +1,512 @@
|
||||
"""Fail-closed loopback HTTP shell for V2R1 ingress processors."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Mapping
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
MAX_REQUEST_BYTES = 1024 * 1024
|
||||
MAX_RESPONSE_BYTES = 4096
|
||||
REQUEST_IO_TIMEOUT_SECONDS = 0.5
|
||||
REQUEST_NETWORK_DEADLINE_SECONDS = 1.0
|
||||
HEX_DIGITS = frozenset("0123456789abcdefABCDEF")
|
||||
HEADER_NAME_BYTES = frozenset(
|
||||
b"!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
)
|
||||
INTERNAL_ERROR_BODY = b'{"status":"INTERNAL_ERROR"}'
|
||||
MUTATION_ROUTES = frozenset(
|
||||
{"/v1/ingress", "/v1/receipts/status", "/v1/outbound-receipts"}
|
||||
)
|
||||
Processor = Callable[[str, bytes], tuple[int, Mapping[str, Any]]]
|
||||
|
||||
|
||||
class RequestRejected(Exception):
|
||||
def __init__(self, status: int, payload_status: str) -> None:
|
||||
super().__init__(payload_status)
|
||||
self.status = status
|
||||
self.payload_status = payload_status
|
||||
|
||||
|
||||
class IngressHTTPServer(ThreadingHTTPServer):
|
||||
"""Threaded loopback server with an optional externally supplied processor."""
|
||||
|
||||
daemon_threads = False
|
||||
block_on_close = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
address: tuple[str, int],
|
||||
handler: type[BaseHTTPRequestHandler],
|
||||
processor: Processor | None,
|
||||
) -> None:
|
||||
self._closing = threading.Event()
|
||||
self._lifecycle_lock = threading.RLock()
|
||||
self._connections_lock = threading.Lock()
|
||||
self._connections: set[socket.socket] = set()
|
||||
self._server_close_lock = threading.Lock()
|
||||
self.processor = processor
|
||||
super().__init__(address, handler)
|
||||
|
||||
@property
|
||||
def is_closing(self) -> bool:
|
||||
return self._closing.is_set()
|
||||
|
||||
def get_request(self) -> tuple[socket.socket, tuple[str, int]]:
|
||||
"""Register each accepted socket before handler scheduling can begin."""
|
||||
connection, client_address = super().get_request()
|
||||
with self._lifecycle_lock:
|
||||
if self._closing.is_set():
|
||||
reject = True
|
||||
else:
|
||||
with self._connections_lock:
|
||||
self._connections.add(connection)
|
||||
reject = False
|
||||
if reject:
|
||||
self._shutdown_and_close_connection(connection)
|
||||
raise OSError("server is closing")
|
||||
return connection, client_address
|
||||
|
||||
def unregister_connection(self, connection: socket.socket) -> None:
|
||||
with self._connections_lock:
|
||||
self._connections.discard(connection)
|
||||
|
||||
def _shutdown_and_close_connection(self, connection: socket.socket) -> None:
|
||||
try:
|
||||
connection.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
connection.close()
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
self.unregister_connection(connection)
|
||||
|
||||
def _close_accepted_connections(self) -> None:
|
||||
with self._connections_lock:
|
||||
connections = tuple(self._connections)
|
||||
for connection in connections:
|
||||
self._shutdown_and_close_connection(connection)
|
||||
|
||||
def process_request(
|
||||
self,
|
||||
request: socket.socket,
|
||||
client_address: tuple[str, int],
|
||||
) -> None:
|
||||
"""Linearize request admission with the transition to closing."""
|
||||
with self._lifecycle_lock:
|
||||
if self._closing.is_set():
|
||||
reject = True
|
||||
else:
|
||||
reject = False
|
||||
try:
|
||||
super().process_request(request, client_address)
|
||||
except Exception:
|
||||
self._shutdown_and_close_connection(request)
|
||||
raise
|
||||
if reject:
|
||||
self._shutdown_and_close_connection(request)
|
||||
|
||||
def shutdown_request(self, request: socket.socket) -> None:
|
||||
try:
|
||||
request.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
super().shutdown_request(request)
|
||||
finally:
|
||||
self.unregister_connection(request)
|
||||
|
||||
def call_processor(self, path: str, body: bytes) -> tuple[bool, Any]:
|
||||
"""Start processing atomically with respect to server shutdown."""
|
||||
with self._lifecycle_lock:
|
||||
if self._closing.is_set() or self.processor is None:
|
||||
return False, None
|
||||
return True, self.processor(path, body)
|
||||
|
||||
def _begin_closing(self) -> None:
|
||||
with self._lifecycle_lock:
|
||||
self._closing.set()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._begin_closing()
|
||||
self._close_accepted_connections()
|
||||
super().shutdown()
|
||||
|
||||
def server_close(self) -> None:
|
||||
"""Interrupt accepted sockets before the base class joins handlers."""
|
||||
with self._server_close_lock:
|
||||
self._begin_closing()
|
||||
self._close_accepted_connections()
|
||||
super().server_close()
|
||||
|
||||
def handle_error(
|
||||
self,
|
||||
request: socket.socket,
|
||||
client_address: tuple[str, int],
|
||||
) -> None:
|
||||
"""Close unexpected handler failures without printing request data."""
|
||||
try:
|
||||
request.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class _ValidatingHeaderReader:
|
||||
"""Validate raw header lines before the standard library normalizes them."""
|
||||
|
||||
def __init__(self, handler: "IngressRequestHandler", reader: Any) -> None:
|
||||
self._handler = handler
|
||||
self._reader = reader
|
||||
|
||||
def readline(self, limit: int = -1) -> bytes:
|
||||
try:
|
||||
line = self._reader.readline(limit)
|
||||
except TimeoutError as exc:
|
||||
raise RequestRejected(408, "REQUEST_TIMEOUT") from exc
|
||||
self._handler._ensure_network_read_active()
|
||||
self._validate(line)
|
||||
return line
|
||||
|
||||
@staticmethod
|
||||
def _validate(line: bytes) -> None:
|
||||
if line == b"\r\n":
|
||||
return
|
||||
if not line or not line.endswith(b"\r\n"):
|
||||
raise RequestRejected(400, "BAD_REQUEST")
|
||||
raw = line[:-2]
|
||||
if not raw or raw[:1] in {b" ", b"\t"}:
|
||||
raise RequestRejected(400, "BAD_REQUEST")
|
||||
separator = raw.find(b":")
|
||||
if separator <= 0:
|
||||
raise RequestRejected(400, "BAD_REQUEST")
|
||||
name = raw[:separator]
|
||||
value = raw[separator + 1 :]
|
||||
if any(byte not in HEADER_NAME_BYTES for byte in name):
|
||||
raise RequestRejected(400, "BAD_REQUEST")
|
||||
if any((byte < 0x20 and byte != 0x09) or byte == 0x7F for byte in value):
|
||||
raise RequestRejected(400, "BAD_REQUEST")
|
||||
if name.lower() == b"content-length":
|
||||
numeric = value[1:] if value.startswith(b" ") else value
|
||||
if not numeric or any(byte < 0x30 or byte > 0x39 for byte in numeric):
|
||||
raise RequestRejected(400, "BAD_REQUEST")
|
||||
|
||||
|
||||
class IngressRequestHandler(BaseHTTPRequestHandler):
|
||||
"""Bounded JSON transport; application processing is injected later."""
|
||||
|
||||
server: IngressHTTPServer
|
||||
|
||||
def setup(self) -> None:
|
||||
super().setup()
|
||||
self.connection.settimeout(REQUEST_IO_TIMEOUT_SECONDS)
|
||||
self._network_read_lock = threading.Lock()
|
||||
self._network_read_expired = threading.Event()
|
||||
self._network_read_deadline: float | None = None
|
||||
self._network_read_timer: threading.Timer | None = None
|
||||
self._network_read_active = False
|
||||
|
||||
def finish(self) -> None:
|
||||
self._complete_network_read()
|
||||
try:
|
||||
super().finish()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _start_network_read(self) -> None:
|
||||
self._complete_network_read()
|
||||
timer = threading.Timer(
|
||||
REQUEST_NETWORK_DEADLINE_SECONDS,
|
||||
self._expire_network_read,
|
||||
)
|
||||
timer.name = f"aios-ingress-read-deadline-{id(self)}"
|
||||
timer.daemon = True
|
||||
with self._network_read_lock:
|
||||
self._network_read_expired.clear()
|
||||
self._network_read_deadline = (
|
||||
time.monotonic() + REQUEST_NETWORK_DEADLINE_SECONDS
|
||||
)
|
||||
self._network_read_active = True
|
||||
self._network_read_timer = timer
|
||||
timer.start()
|
||||
|
||||
def _expire_network_read(self) -> None:
|
||||
with self._network_read_lock:
|
||||
if not self._network_read_active:
|
||||
return
|
||||
self._network_read_expired.set()
|
||||
self.close_connection = True
|
||||
try:
|
||||
self.connection.shutdown(socket.SHUT_RD)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _complete_network_read(self) -> None:
|
||||
timer: threading.Timer | None
|
||||
with getattr(self, "_network_read_lock", threading.Lock()):
|
||||
self._network_read_active = False
|
||||
self._network_read_deadline = None
|
||||
timer = self._network_read_timer
|
||||
self._network_read_timer = None
|
||||
if timer is not None:
|
||||
timer.cancel()
|
||||
if timer is not threading.current_thread():
|
||||
timer.join(timeout=REQUEST_IO_TIMEOUT_SECONDS)
|
||||
|
||||
def _ensure_network_read_active(self) -> None:
|
||||
if self.server.is_closing:
|
||||
raise RequestRejected(503, "SERVICE_UNAVAILABLE")
|
||||
deadline = self._network_read_deadline
|
||||
if self._network_read_expired.is_set() or (
|
||||
deadline is not None and time.monotonic() >= deadline
|
||||
):
|
||||
self._network_read_expired.set()
|
||||
raise RequestRejected(408, "REQUEST_TIMEOUT")
|
||||
|
||||
def parse_request(self) -> bool:
|
||||
original_reader = self.rfile
|
||||
self.rfile = _ValidatingHeaderReader(self, original_reader) # type: ignore[assignment]
|
||||
try:
|
||||
return super().parse_request()
|
||||
finally:
|
||||
self.rfile = original_reader
|
||||
|
||||
def handle_one_request(self) -> None:
|
||||
self._start_network_read()
|
||||
try:
|
||||
super().handle_one_request()
|
||||
except RequestRejected as rejection:
|
||||
self.close_connection = True
|
||||
self._send_safely(rejection.status, {"status": rejection.payload_status})
|
||||
except TimeoutError:
|
||||
self.close_connection = True
|
||||
self._send_safely(408, {"status": "REQUEST_TIMEOUT"})
|
||||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||
self.close_connection = True
|
||||
except Exception:
|
||||
self.close_connection = True
|
||||
self._send_safely(500, {"status": "INTERNAL_ERROR"})
|
||||
finally:
|
||||
self._complete_network_read()
|
||||
|
||||
def send_error(
|
||||
self,
|
||||
code: int,
|
||||
message: str | None = None,
|
||||
explain: str | None = None,
|
||||
) -> None:
|
||||
"""Replace BaseHTTPRequestHandler HTML errors with bounded JSON."""
|
||||
self.close_connection = True
|
||||
if code == 501:
|
||||
self._method_not_allowed()
|
||||
return
|
||||
self._send(int(code), {"status": "HTTP_ERROR"})
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
return
|
||||
|
||||
def _send_safely(self, status: int, payload: Mapping[str, Any]) -> None:
|
||||
try:
|
||||
self._send(status, payload)
|
||||
except OSError:
|
||||
self.close_connection = True
|
||||
|
||||
def _path(self) -> str:
|
||||
target = self.path
|
||||
if not target or any(
|
||||
ord(character) < 0x20 or ord(character) == 0x7F for character in target
|
||||
):
|
||||
raise RequestRejected(400, "BAD_REQUEST")
|
||||
for index, character in enumerate(target):
|
||||
if character == "%" and (
|
||||
index + 2 >= len(target)
|
||||
or any(value not in HEX_DIGITS for value in target[index + 1 : index + 3])
|
||||
):
|
||||
raise RequestRejected(400, "BAD_REQUEST")
|
||||
try:
|
||||
parts = urlsplit(target)
|
||||
except ValueError as exc:
|
||||
raise RequestRejected(400, "BAD_REQUEST") from exc
|
||||
if parts.fragment:
|
||||
raise RequestRejected(400, "BAD_REQUEST")
|
||||
if target.startswith("/"):
|
||||
if parts.scheme or parts.netloc:
|
||||
raise RequestRejected(400, "BAD_REQUEST")
|
||||
elif parts.scheme in {"http", "https"} and parts.netloc:
|
||||
if parts.hostname is None:
|
||||
raise RequestRejected(400, "BAD_REQUEST")
|
||||
try:
|
||||
_ = parts.port
|
||||
except ValueError as exc:
|
||||
raise RequestRejected(400, "BAD_REQUEST") from exc
|
||||
else:
|
||||
raise RequestRejected(400, "BAD_REQUEST")
|
||||
return parts.path or "/"
|
||||
|
||||
@staticmethod
|
||||
def _encoded_response(
|
||||
status: object,
|
||||
payload: object,
|
||||
) -> tuple[int, bytes]:
|
||||
if (
|
||||
type(status) is not int
|
||||
or not 200 <= status <= 599
|
||||
or status in {204, 205, 304}
|
||||
or not isinstance(payload, Mapping)
|
||||
):
|
||||
return 500, INTERNAL_ERROR_BODY
|
||||
try:
|
||||
body = json.dumps(
|
||||
dict(payload),
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except Exception:
|
||||
return 500, INTERNAL_ERROR_BODY
|
||||
if len(body) > MAX_RESPONSE_BYTES:
|
||||
return 500, INTERNAL_ERROR_BODY
|
||||
return status, body
|
||||
|
||||
def _send(self, status: int, payload: Mapping[str, Any]) -> None:
|
||||
self._complete_network_read()
|
||||
status, body = self._encoded_response(status, payload)
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
if getattr(self, "command", None) != "HEAD":
|
||||
self.wfile.write(body)
|
||||
|
||||
def _method_not_allowed(self) -> None:
|
||||
self._send(405, {"status": "METHOD_NOT_ALLOWED"})
|
||||
|
||||
def _reject(self, status: int, payload_status: str) -> None:
|
||||
self.close_connection = True
|
||||
self._send(status, {"status": payload_status})
|
||||
|
||||
def _content_length(self) -> int:
|
||||
if self.headers.get_all("Transfer-Encoding", []):
|
||||
raise RequestRejected(400, "BAD_REQUEST")
|
||||
values = self.headers.get_all("Content-Length", [])
|
||||
if len(values) > 1:
|
||||
raise RequestRejected(400, "BAD_REQUEST")
|
||||
if not values:
|
||||
return 0
|
||||
raw_length = values[0]
|
||||
if (
|
||||
not raw_length
|
||||
or not raw_length.isascii()
|
||||
or not raw_length.isdigit()
|
||||
):
|
||||
raise RequestRejected(400, "BAD_REQUEST")
|
||||
try:
|
||||
length = int(raw_length)
|
||||
except ValueError as exc:
|
||||
raise RequestRejected(400, "BAD_REQUEST") from exc
|
||||
if length > MAX_REQUEST_BYTES:
|
||||
raise RequestRejected(413, "REQUEST_TOO_LARGE")
|
||||
return length
|
||||
|
||||
def _read_exact_body(self, length: int) -> bytes:
|
||||
try:
|
||||
body = self.rfile.read(length)
|
||||
except TimeoutError as exc:
|
||||
raise RequestRejected(408, "REQUEST_TIMEOUT") from exc
|
||||
self._ensure_network_read_active()
|
||||
if len(body) != length:
|
||||
raise RequestRejected(400, "BAD_REQUEST")
|
||||
return body
|
||||
|
||||
def do_GET(self) -> None:
|
||||
path = self._path()
|
||||
if path == "/health/live":
|
||||
self._send(200, {"status": "LIVE"})
|
||||
elif path == "/health/ready":
|
||||
if self.server.processor is None:
|
||||
self._send(503, {"status": "SERVICE_NOT_CONFIGURED"})
|
||||
else:
|
||||
self._send(200, {"status": "READY"})
|
||||
elif path in MUTATION_ROUTES:
|
||||
self._method_not_allowed()
|
||||
else:
|
||||
self._send(404, {"status": "NOT_FOUND"})
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = self._path()
|
||||
if path not in MUTATION_ROUTES:
|
||||
if path in {"/health/live", "/health/ready"}:
|
||||
self._method_not_allowed()
|
||||
else:
|
||||
self._send(404, {"status": "NOT_FOUND"})
|
||||
return
|
||||
try:
|
||||
length = self._content_length()
|
||||
except RequestRejected as rejection:
|
||||
self._reject(rejection.status, rejection.payload_status)
|
||||
return
|
||||
processor = self.server.processor
|
||||
if processor is None:
|
||||
self._send(503, {"status": "SERVICE_NOT_CONFIGURED"})
|
||||
return
|
||||
if self.server.is_closing:
|
||||
self._reject(503, "SERVICE_UNAVAILABLE")
|
||||
return
|
||||
try:
|
||||
body = self._read_exact_body(length)
|
||||
except RequestRejected as rejection:
|
||||
self._reject(rejection.status, rejection.payload_status)
|
||||
return
|
||||
self._complete_network_read()
|
||||
try:
|
||||
started, result = self.server.call_processor(path, body)
|
||||
if not started:
|
||||
self._reject(503, "SERVICE_UNAVAILABLE")
|
||||
return
|
||||
status, payload = result
|
||||
except Exception:
|
||||
self._send(500, {"status": "INTERNAL_ERROR"})
|
||||
return
|
||||
self._send(status, payload)
|
||||
|
||||
do_DELETE = _method_not_allowed
|
||||
do_HEAD = _method_not_allowed
|
||||
do_OPTIONS = _method_not_allowed
|
||||
do_PATCH = _method_not_allowed
|
||||
do_PUT = _method_not_allowed
|
||||
|
||||
|
||||
def build_server(
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 18792,
|
||||
*,
|
||||
processor: Processor | None = None,
|
||||
) -> IngressHTTPServer:
|
||||
"""Build a loopback-only HTTP server without starting it."""
|
||||
try:
|
||||
address = ipaddress.ip_address(host)
|
||||
except ValueError as exc:
|
||||
raise ValueError("host must be a loopback IP address") from exc
|
||||
if address.version != 4 or not address.is_loopback:
|
||||
raise ValueError("host must be an IPv4 loopback address")
|
||||
return IngressHTTPServer((host, port), IngressRequestHandler, processor)
|
||||
|
||||
|
||||
def serve(host: str = "127.0.0.1", port: int = 18792) -> None:
|
||||
"""Run the unconfigured loopback shell until interrupted."""
|
||||
server = build_server(host, port)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
return
|
||||
finally:
|
||||
server.server_close()
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Tenant-scoped memory namespace and access policy for AIOS V2R1."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Literal
|
||||
|
||||
from .tenant_context import ScopeError, ValidationError, require_ident
|
||||
|
||||
MemoryKind = Literal["session", "profile", "cell", "tenant", "shared_artifact"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MemoryScope:
|
||||
tenant_id: str
|
||||
cell_id: str
|
||||
profile_id: str
|
||||
session_id: str | None
|
||||
kind: MemoryKind
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_ident("tenant_id", self.tenant_id, tenant=True)
|
||||
for name in ("cell_id", "profile_id"):
|
||||
require_ident(name, getattr(self, name))
|
||||
if self.session_id is not None:
|
||||
require_ident("session_id", self.session_id)
|
||||
if self.kind not in {"session", "profile", "cell", "tenant", "shared_artifact"}:
|
||||
raise ValidationError("invalid memory kind")
|
||||
if self.kind == "session" and self.session_id is None:
|
||||
raise ValidationError("session memory requires session_id")
|
||||
|
||||
@property
|
||||
def namespace(self) -> str:
|
||||
parts = ["tenant", self.tenant_id, "cell", self.cell_id]
|
||||
if self.kind in {"profile", "session"}:
|
||||
parts.extend(["profile", self.profile_id])
|
||||
if self.kind == "session":
|
||||
parts.extend(["session", str(self.session_id)])
|
||||
parts.extend(["memory", self.kind])
|
||||
return str(PurePosixPath(*parts))
|
||||
|
||||
def assert_access(self, requested: "MemoryScope", *, write: bool = False) -> None:
|
||||
if self.tenant_id != requested.tenant_id:
|
||||
raise ScopeError("cross-tenant memory access denied")
|
||||
if self.cell_id != requested.cell_id:
|
||||
raise ScopeError("cross-cell memory access denied")
|
||||
if requested.kind == "shared_artifact":
|
||||
if write:
|
||||
raise ScopeError("shared artifacts are immutable")
|
||||
return
|
||||
if self.kind != requested.kind:
|
||||
raise ScopeError("cross-kind memory access denied")
|
||||
if self.profile_id != requested.profile_id and requested.kind in {"profile", "session"}:
|
||||
raise ScopeError("cross-profile memory access denied")
|
||||
if requested.kind == "session" and self.session_id != requested.session_id:
|
||||
raise ScopeError("cross-session memory access denied")
|
||||
|
||||
|
||||
def qualify_memory_key(scope: MemoryScope, key: str) -> str:
|
||||
normalized = require_ident("memory_key", key)
|
||||
if normalized != key:
|
||||
raise ValidationError("memory_key must be canonical")
|
||||
return f"{scope.namespace}/{normalized}"
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Tenant-scoped append-only message ledger and exact quote resolver."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import closing
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
from typing import TypeVar
|
||||
|
||||
from .tenant_context import (
|
||||
CanonicalMessage, ConflictError, ScopeError, ValidationError,
|
||||
isoformat, normalize_message_id, utc_now,
|
||||
)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LedgerRecord:
|
||||
tenant_id: str
|
||||
cell_id: str
|
||||
channel: str
|
||||
channel_account_id: str
|
||||
peer_id: str
|
||||
direction: str
|
||||
message_id: str
|
||||
event_id: str
|
||||
body: str
|
||||
occurred_at: str
|
||||
expires_at: str
|
||||
|
||||
|
||||
class MessageLedger:
|
||||
"""Exact-ID ledger with no fuzzy, prefix, or time-nearest fallback."""
|
||||
|
||||
def __init__(self, database: str | Path, *, ttl_days: int = 7) -> None:
|
||||
if not 1 <= ttl_days <= 365:
|
||||
raise ValidationError("ttl_days must be between 1 and 365")
|
||||
self.database = Path(database)
|
||||
self.ttl_days = ttl_days
|
||||
self.database.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._initialize()
|
||||
try:
|
||||
os.chmod(self.database, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
db = sqlite3.connect(str(self.database), timeout=30)
|
||||
db.row_factory = sqlite3.Row
|
||||
db.execute("PRAGMA journal_mode=WAL")
|
||||
db.execute("PRAGMA synchronous=FULL")
|
||||
db.execute("PRAGMA busy_timeout=30000")
|
||||
return db
|
||||
|
||||
def _storage(self, operation: Callable[[], T]) -> T:
|
||||
failure: ConflictError | None = None
|
||||
try:
|
||||
return operation()
|
||||
except sqlite3.Error:
|
||||
failure = ConflictError("ledger storage unavailable")
|
||||
# Raise after the driver exception handler, keeping SQLite details out of
|
||||
# both the public exception and its implicit context.
|
||||
raise failure
|
||||
|
||||
def _initialize(self) -> None:
|
||||
def initialize() -> None:
|
||||
with closing(self._connect()) as connection, connection as db:
|
||||
db.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS message_ledger (
|
||||
tenant_id TEXT NOT NULL,
|
||||
cell_id TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
channel_account_id TEXT NOT NULL,
|
||||
peer_id TEXT NOT NULL,
|
||||
direction TEXT NOT NULL CHECK(direction IN ('inbound','outbound','internal')),
|
||||
message_id TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
occurred_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(
|
||||
tenant_id, cell_id, channel, channel_account_id,
|
||||
peer_id, direction, message_id
|
||||
),
|
||||
UNIQUE(tenant_id, cell_id, event_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_reply_lookup ON message_ledger(
|
||||
tenant_id, cell_id, channel, channel_account_id,
|
||||
peer_id, message_id, expires_at
|
||||
);
|
||||
"""
|
||||
)
|
||||
self._storage(initialize)
|
||||
|
||||
def append(self, message: CanonicalMessage) -> LedgerRecord:
|
||||
message_id = normalize_message_id(message.message_id)
|
||||
now = utc_now()
|
||||
expires = isoformat(now + timedelta(days=self.ttl_days))
|
||||
key = (
|
||||
message.tenant_id, message.cell_id, message.channel,
|
||||
message.channel_account_id, message.peer_id, message.direction, message_id,
|
||||
)
|
||||
|
||||
def write() -> LedgerRecord:
|
||||
with closing(self._connect()) as connection, connection as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
existing = db.execute(
|
||||
"SELECT event_id, body, occurred_at, expires_at FROM message_ledger "
|
||||
"WHERE tenant_id=? AND cell_id=? AND channel=? AND channel_account_id=? "
|
||||
"AND peer_id=? AND direction=? AND message_id=?", key,
|
||||
).fetchone()
|
||||
if existing:
|
||||
if existing["body"] != message.body:
|
||||
db.rollback()
|
||||
raise ConflictError("message ID collision with different content")
|
||||
db.commit()
|
||||
return LedgerRecord(
|
||||
*key, existing["event_id"], existing["body"],
|
||||
existing["occurred_at"], existing["expires_at"],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO message_ledger VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(*key, message.event_id, message.body, message.occurred_at,
|
||||
expires, isoformat(now)),
|
||||
)
|
||||
db.commit()
|
||||
return LedgerRecord(
|
||||
*key, message.event_id, message.body, message.occurred_at, expires
|
||||
)
|
||||
|
||||
return self._storage(write)
|
||||
|
||||
def remember(self, message: CanonicalMessage) -> LedgerRecord:
|
||||
"""Backward-compatible API wrapper outside the runtime write path."""
|
||||
return self.append(message)
|
||||
|
||||
def matches(self, message: CanonicalMessage) -> bool:
|
||||
message_id = normalize_message_id(message.message_id)
|
||||
|
||||
def query() -> bool:
|
||||
with closing(self._connect()) as connection, connection as db:
|
||||
row = db.execute(
|
||||
"SELECT event_id, body FROM message_ledger WHERE tenant_id=? AND "
|
||||
"cell_id=? AND channel=? AND channel_account_id=? AND peer_id=? "
|
||||
"AND direction=? AND message_id=?",
|
||||
(
|
||||
message.tenant_id, message.cell_id, message.channel,
|
||||
message.channel_account_id, message.peer_id, message.direction,
|
||||
message_id,
|
||||
),
|
||||
).fetchone()
|
||||
return bool(
|
||||
row and row["event_id"] == message.event_id
|
||||
and row["body"] == message.body
|
||||
)
|
||||
|
||||
return self._storage(query)
|
||||
|
||||
def remove(self, record: LedgerRecord) -> None:
|
||||
def delete() -> None:
|
||||
with closing(self._connect()) as connection, connection as db:
|
||||
db.execute(
|
||||
"DELETE FROM message_ledger WHERE tenant_id=? AND cell_id=? AND "
|
||||
"channel=? AND channel_account_id=? AND peer_id=? AND direction=? "
|
||||
"AND message_id=? AND event_id=?",
|
||||
(
|
||||
record.tenant_id, record.cell_id, record.channel,
|
||||
record.channel_account_id, record.peer_id, record.direction,
|
||||
record.message_id, record.event_id,
|
||||
),
|
||||
)
|
||||
self._storage(delete)
|
||||
|
||||
def resolve_quote(self, message: CanonicalMessage) -> str:
|
||||
if message.quoted_body is not None:
|
||||
return message.quoted_body
|
||||
if message.reply_to_message_id is None:
|
||||
raise ScopeError("quoted_text_unavailable")
|
||||
reply_id = normalize_message_id(
|
||||
message.reply_to_message_id, field_name="reply_to_message_id"
|
||||
)
|
||||
|
||||
def query() -> list[sqlite3.Row]:
|
||||
with closing(self._connect()) as connection, connection as db:
|
||||
return db.execute(
|
||||
"SELECT direction, body, event_id FROM message_ledger "
|
||||
"WHERE tenant_id=? AND cell_id=? AND channel=? AND channel_account_id=? "
|
||||
"AND peer_id=? AND message_id=? AND expires_at>?",
|
||||
(
|
||||
message.tenant_id, message.cell_id, message.channel,
|
||||
message.channel_account_id, message.peer_id, reply_id, isoformat(),
|
||||
),
|
||||
).fetchall()
|
||||
|
||||
rows = self._storage(query)
|
||||
if len(rows) == 1:
|
||||
return str(rows[0]["body"])
|
||||
if len(rows) > 1:
|
||||
raise ConflictError("ambiguous reply ID across message directions")
|
||||
raise ScopeError("quoted_text_unavailable")
|
||||
|
||||
def purge_expired(self) -> int:
|
||||
def purge() -> int:
|
||||
with closing(self._connect()) as connection, connection as db:
|
||||
cursor = db.execute(
|
||||
"DELETE FROM message_ledger WHERE expires_at<=?", (isoformat(),)
|
||||
)
|
||||
return cursor.rowcount
|
||||
return self._storage(purge)
|
||||
|
||||
def integrity_check(self) -> str:
|
||||
def check() -> str:
|
||||
with closing(self._connect()) as connection, connection as db:
|
||||
return str(db.execute("PRAGMA integrity_check").fetchone()[0])
|
||||
return self._storage(check)
|
||||
|
||||
def count(self) -> int:
|
||||
def query() -> int:
|
||||
with closing(self._connect()) as connection, connection as db:
|
||||
return int(db.execute("SELECT count(*) FROM message_ledger").fetchone()[0])
|
||||
return self._storage(query)
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Authoritative tenant registry and fail-closed message router."""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from contextlib import closing
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterator
|
||||
|
||||
from .tenant_context import (
|
||||
CanonicalMessage,
|
||||
ConflictError,
|
||||
ScopeError,
|
||||
TenantContext,
|
||||
ValidationError,
|
||||
isoformat,
|
||||
parse_time,
|
||||
require_ident,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Route:
|
||||
tenant_id: str
|
||||
cell_id: str
|
||||
channel: str
|
||||
channel_account_id: str
|
||||
principal_id: str
|
||||
session_id: str | None
|
||||
|
||||
|
||||
class TenantRouter:
|
||||
"""SQLite-backed routing registry.
|
||||
|
||||
Channel accounts are authoritative tenant ownership boundaries. Principal and
|
||||
session bindings may narrow a route, but can never move traffic to another
|
||||
tenant or Cell.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, database: str | Path, *, clock: Callable[[], datetime] = utc_now
|
||||
) -> None:
|
||||
self.database = str(database)
|
||||
self._clock = clock
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.database, timeout=30, isolation_level=None)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA synchronous=FULL")
|
||||
connection.execute("PRAGMA busy_timeout=30000")
|
||||
return connection
|
||||
|
||||
@contextmanager
|
||||
def _transaction(self) -> Iterator[sqlite3.Connection]:
|
||||
connection = self._connect()
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
yield connection
|
||||
connection.execute("COMMIT")
|
||||
except Exception:
|
||||
connection.execute("ROLLBACK")
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with closing(self._connect()) as connection, connection as db:
|
||||
db.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
tenant_id TEXT PRIMARY KEY,
|
||||
cell_id TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL CHECK(status IN ('active','suspended','retired')),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS channel_routes (
|
||||
channel TEXT NOT NULL,
|
||||
channel_account_id TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
cell_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(channel, channel_account_id),
|
||||
FOREIGN KEY(tenant_id) REFERENCES tenants(tenant_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS principal_routes (
|
||||
channel TEXT NOT NULL,
|
||||
channel_account_id TEXT NOT NULL,
|
||||
principal_id TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
cell_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(channel, channel_account_id, principal_id),
|
||||
FOREIGN KEY(channel, channel_account_id)
|
||||
REFERENCES channel_routes(channel, channel_account_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS session_bindings (
|
||||
tenant_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
cell_id TEXT NOT NULL,
|
||||
principal_id TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(tenant_id, session_id),
|
||||
FOREIGN KEY(tenant_id) REFERENCES tenants(tenant_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_expiry
|
||||
ON session_bindings(expires_at);
|
||||
"""
|
||||
)
|
||||
|
||||
def register_tenant(self, tenant_id: str, cell_id: str) -> None:
|
||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
||||
cell_id = require_ident("cell_id", cell_id)
|
||||
now = isoformat(self._clock())
|
||||
with self._transaction() as db:
|
||||
row = db.execute(
|
||||
"SELECT cell_id, status FROM tenants WHERE tenant_id=?", (tenant_id,)
|
||||
).fetchone()
|
||||
if row:
|
||||
if row["cell_id"] != cell_id:
|
||||
raise ConflictError("tenant already bound to another cell")
|
||||
db.execute(
|
||||
"UPDATE tenants SET status='active', updated_at=? WHERE tenant_id=?",
|
||||
(now, tenant_id),
|
||||
)
|
||||
return
|
||||
conflict = db.execute(
|
||||
"SELECT tenant_id FROM tenants WHERE cell_id=?", (cell_id,)
|
||||
).fetchone()
|
||||
if conflict:
|
||||
raise ConflictError("cell already belongs to another tenant")
|
||||
db.execute(
|
||||
"INSERT INTO tenants VALUES(?,?,?,?,?)",
|
||||
(tenant_id, cell_id, "active", now, now),
|
||||
)
|
||||
|
||||
def set_tenant_status(self, tenant_id: str, status: str) -> None:
|
||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
||||
if status not in {"active", "suspended", "retired"}:
|
||||
raise ValidationError("invalid tenant status")
|
||||
with self._transaction() as db:
|
||||
cursor = db.execute(
|
||||
"UPDATE tenants SET status=?, updated_at=? WHERE tenant_id=?",
|
||||
(status, isoformat(self._clock()), tenant_id),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise ValidationError("unknown tenant")
|
||||
|
||||
def bind_channel(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
channel: str,
|
||||
channel_account_id: str,
|
||||
) -> None:
|
||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
||||
channel = require_ident("channel", channel)
|
||||
channel_account_id = require_ident("channel_account_id", channel_account_id)
|
||||
with self._transaction() as db:
|
||||
tenant = db.execute(
|
||||
"SELECT cell_id, status FROM tenants WHERE tenant_id=?", (tenant_id,)
|
||||
).fetchone()
|
||||
if not tenant or tenant["status"] != "active":
|
||||
raise ValidationError("tenant is not active")
|
||||
existing = db.execute(
|
||||
"SELECT tenant_id, cell_id FROM channel_routes "
|
||||
"WHERE channel=? AND channel_account_id=?",
|
||||
(channel, channel_account_id),
|
||||
).fetchone()
|
||||
if existing:
|
||||
if existing["tenant_id"] != tenant_id or existing["cell_id"] != tenant["cell_id"]:
|
||||
raise ConflictError("channel account already belongs to another tenant")
|
||||
return
|
||||
db.execute(
|
||||
"INSERT INTO channel_routes VALUES(?,?,?,?,?)",
|
||||
(channel, channel_account_id, tenant_id, tenant["cell_id"], isoformat(self._clock())),
|
||||
)
|
||||
|
||||
def bind_principal(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
channel: str,
|
||||
channel_account_id: str,
|
||||
principal_id: str,
|
||||
) -> None:
|
||||
principal_id = require_ident("principal_id", principal_id)
|
||||
route = self.resolve_channel(channel, channel_account_id)
|
||||
if route.tenant_id != tenant_id:
|
||||
raise ScopeError("principal cannot cross tenant boundary")
|
||||
with self._transaction() as db:
|
||||
existing = db.execute(
|
||||
"SELECT tenant_id, cell_id FROM principal_routes WHERE "
|
||||
"channel=? AND channel_account_id=? AND principal_id=?",
|
||||
(channel, channel_account_id, principal_id),
|
||||
).fetchone()
|
||||
if existing:
|
||||
if existing["tenant_id"] != tenant_id or existing["cell_id"] != route.cell_id:
|
||||
raise ConflictError("principal route conflict")
|
||||
return
|
||||
db.execute(
|
||||
"INSERT INTO principal_routes VALUES(?,?,?,?,?,?)",
|
||||
(
|
||||
channel,
|
||||
channel_account_id,
|
||||
principal_id,
|
||||
tenant_id,
|
||||
route.cell_id,
|
||||
isoformat(self._clock()),
|
||||
),
|
||||
)
|
||||
|
||||
def bind_session(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
session_id: str,
|
||||
cell_id: str,
|
||||
principal_id: str,
|
||||
ttl_seconds: int = 86400,
|
||||
) -> None:
|
||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
||||
session_id = require_ident("session_id", session_id)
|
||||
cell_id = require_ident("cell_id", cell_id)
|
||||
principal_id = require_ident("principal_id", principal_id)
|
||||
if not 60 <= ttl_seconds <= 31 * 86400:
|
||||
raise ValidationError("invalid session TTL")
|
||||
now = self._clock()
|
||||
expires = isoformat(now + timedelta(seconds=ttl_seconds))
|
||||
with self._transaction() as db:
|
||||
tenant = db.execute(
|
||||
"SELECT cell_id, status FROM tenants WHERE tenant_id=?", (tenant_id,)
|
||||
).fetchone()
|
||||
if not tenant or tenant["status"] != "active" or tenant["cell_id"] != cell_id:
|
||||
raise ScopeError("session cell does not match tenant")
|
||||
existing = db.execute(
|
||||
"SELECT cell_id, principal_id FROM session_bindings "
|
||||
"WHERE tenant_id=? AND session_id=?",
|
||||
(tenant_id, session_id),
|
||||
).fetchone()
|
||||
if existing and (
|
||||
existing["cell_id"] != cell_id or existing["principal_id"] != principal_id
|
||||
):
|
||||
raise ConflictError("session is already bound to another principal or cell")
|
||||
db.execute(
|
||||
"INSERT INTO session_bindings VALUES(?,?,?,?,?,?) "
|
||||
"ON CONFLICT(tenant_id,session_id) DO UPDATE SET expires_at=excluded.expires_at",
|
||||
(tenant_id, session_id, cell_id, principal_id, expires, isoformat(now)),
|
||||
)
|
||||
|
||||
def resolve_channel(self, channel: str, channel_account_id: str) -> Route:
|
||||
channel = require_ident("channel", channel)
|
||||
channel_account_id = require_ident("channel_account_id", channel_account_id)
|
||||
with closing(self._connect()) as connection, connection as db:
|
||||
row = db.execute(
|
||||
"SELECT r.tenant_id, r.cell_id, t.status FROM channel_routes r "
|
||||
"JOIN tenants t ON t.tenant_id=r.tenant_id "
|
||||
"WHERE r.channel=? AND r.channel_account_id=?",
|
||||
(channel, channel_account_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ScopeError("unknown channel account")
|
||||
if row["status"] != "active":
|
||||
raise ScopeError("tenant is not active")
|
||||
return Route(row["tenant_id"], row["cell_id"], channel, channel_account_id, "", None)
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
*,
|
||||
channel: str,
|
||||
channel_account_id: str,
|
||||
principal_id: str,
|
||||
session_id: str | None = None,
|
||||
) -> Route:
|
||||
principal_id = require_ident("principal_id", principal_id)
|
||||
base = self.resolve_channel(channel, channel_account_id)
|
||||
with closing(self._connect()) as connection, connection as db:
|
||||
principal = db.execute(
|
||||
"SELECT tenant_id, cell_id FROM principal_routes WHERE "
|
||||
"channel=? AND channel_account_id=? AND principal_id=?",
|
||||
(channel, channel_account_id, principal_id),
|
||||
).fetchone()
|
||||
if principal and (
|
||||
principal["tenant_id"] != base.tenant_id or principal["cell_id"] != base.cell_id
|
||||
):
|
||||
raise ScopeError("principal route violates channel ownership")
|
||||
if session_id is not None:
|
||||
session_id = require_ident("session_id", session_id)
|
||||
session = db.execute(
|
||||
"SELECT cell_id, principal_id, expires_at FROM session_bindings "
|
||||
"WHERE tenant_id=? AND session_id=?",
|
||||
(base.tenant_id, session_id),
|
||||
).fetchone()
|
||||
if not session:
|
||||
raise ScopeError("unknown session")
|
||||
if parse_time(session["expires_at"]) <= self._clock():
|
||||
raise ScopeError("session expired")
|
||||
if session["cell_id"] != base.cell_id or session["principal_id"] != principal_id:
|
||||
raise ScopeError("session scope mismatch")
|
||||
return Route(
|
||||
base.tenant_id,
|
||||
base.cell_id,
|
||||
channel,
|
||||
channel_account_id,
|
||||
principal_id,
|
||||
session_id,
|
||||
)
|
||||
|
||||
def assert_message_route(self, message: CanonicalMessage) -> Route:
|
||||
route = self.resolve(
|
||||
channel=message.channel,
|
||||
channel_account_id=message.channel_account_id,
|
||||
principal_id=message.principal_id,
|
||||
session_id=message.session_id,
|
||||
)
|
||||
if message.tenant_id != route.tenant_id or message.cell_id != route.cell_id:
|
||||
raise ScopeError("message claims do not match authoritative route")
|
||||
return route
|
||||
|
||||
def assert_context_route(self, context: TenantContext) -> Route:
|
||||
route = self.resolve(
|
||||
channel=context.channel,
|
||||
channel_account_id=context.channel_account_id,
|
||||
principal_id=context.principal_id,
|
||||
session_id=context.session_id,
|
||||
)
|
||||
if context.tenant_id != route.tenant_id or context.cell_id != route.cell_id:
|
||||
raise ScopeError("context claims do not match authoritative route")
|
||||
return route
|
||||
@@ -0,0 +1,136 @@
|
||||
"""V2R1 runtime composition root for authenticated channel ingress."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from .channel_adapter import ChannelAdapter, IngressResult
|
||||
from .identity import (
|
||||
AuthenticationError,
|
||||
IngressAuthenticator,
|
||||
RuntimeIdentity,
|
||||
RuntimeIdentityResolver,
|
||||
)
|
||||
from .message_ledger import MessageLedger
|
||||
from .router import TenantRouter
|
||||
from .session_pool import SessionPool
|
||||
from .tenant_context import ConflictError, ScopeError, TenantContext, ValidationError, utc_now
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeIngressResponse:
|
||||
"""Sanitised boundary response; no exception text crosses external ingress."""
|
||||
|
||||
status: str
|
||||
result: IngressResult | None = None
|
||||
|
||||
|
||||
class RuntimeWiring:
|
||||
"""Own the only external-to-runtime identity binding and message path.
|
||||
|
||||
The request accepted by :meth:`ingest` is an HMAC-authenticated envelope;
|
||||
tenant, cell, channel, account, session, principal, and message claims are
|
||||
signed together. The envelope is then resolved against ``TenantRouter``.
|
||||
Only that resolved identity can mint the short-lived internal context passed
|
||||
to ``ChannelAdapter``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
router: TenantRouter,
|
||||
ledger: MessageLedger,
|
||||
session_pool: SessionPool,
|
||||
channel_adapter: ChannelAdapter,
|
||||
identity_resolver: RuntimeIdentityResolver,
|
||||
ingress_authenticator: IngressAuthenticator,
|
||||
internal_signing_key: bytes,
|
||||
clock: Callable[[], datetime],
|
||||
) -> None:
|
||||
self.router = router
|
||||
self.ledger = ledger
|
||||
self.session_pool = session_pool
|
||||
self.channel_adapter = channel_adapter
|
||||
self.identity_resolver = identity_resolver
|
||||
self._ingress_authenticator = ingress_authenticator
|
||||
self._internal_signing_key = internal_signing_key
|
||||
self._clock = clock
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
root: str | Path,
|
||||
*,
|
||||
signing_key: bytes,
|
||||
ingress_hmac_key: bytes | None = None,
|
||||
clock: Callable[[], datetime] = utc_now,
|
||||
) -> "RuntimeWiring":
|
||||
if not isinstance(signing_key, bytes) or len(signing_key) < 32:
|
||||
raise ValidationError("internal signing key must be at least 32 bytes")
|
||||
# Kept optional for one controlled migration release; the external
|
||||
# request path still cannot supply a TenantContext token.
|
||||
hmac_key = ingress_hmac_key if ingress_hmac_key is not None else signing_key
|
||||
root_path = Path(root)
|
||||
root_path.mkdir(parents=True, exist_ok=True)
|
||||
router = TenantRouter(root_path / "router.sqlite3", clock=clock)
|
||||
ledger = MessageLedger(root_path / "message_ledger.sqlite3")
|
||||
session_pool = SessionPool(router)
|
||||
adapter = ChannelAdapter(
|
||||
router=router,
|
||||
ledger=ledger,
|
||||
session_pool=session_pool,
|
||||
signing_key=signing_key,
|
||||
clock=clock,
|
||||
)
|
||||
return cls(
|
||||
router=router,
|
||||
ledger=ledger,
|
||||
session_pool=session_pool,
|
||||
channel_adapter=adapter,
|
||||
identity_resolver=RuntimeIdentityResolver(router),
|
||||
ingress_authenticator=IngressAuthenticator(hmac_key),
|
||||
internal_signing_key=signing_key,
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
def _issue_internal_context(self, identity: RuntimeIdentity) -> TenantContext:
|
||||
"""Mint internal scope only after all claims matched the router route."""
|
||||
return TenantContext.issue(
|
||||
lifetime_seconds=300,
|
||||
now=self._clock(),
|
||||
tenant_id=identity.tenant_id,
|
||||
cell_id=identity.cell_id,
|
||||
principal_id=identity.principal_id,
|
||||
principal_type="authenticated_user",
|
||||
session_id=identity.session_id,
|
||||
task_id=None,
|
||||
channel=identity.channel,
|
||||
channel_account_id=identity.channel_account_id,
|
||||
)
|
||||
|
||||
def ingest(self, *, request: Mapping[str, Any]) -> RuntimeIngressResponse:
|
||||
"""Authenticate, resolve, issue internal context, then append and lease.
|
||||
|
||||
The only successful route is ``IngressAuthenticator`` ->
|
||||
``RuntimeIdentityResolver`` -> ``ChannelAdapter`` ->
|
||||
``MessageLedger.append`` + ``SessionPool``. The legacy caller-supplied
|
||||
TenantContext token interface is intentionally not accepted.
|
||||
"""
|
||||
try:
|
||||
ingress = self._ingress_authenticator.authenticate(request)
|
||||
identity = self.identity_resolver.resolve(ingress.principal, ingress.claims)
|
||||
context = self._issue_internal_context(identity)
|
||||
result = self.channel_adapter.ingest(
|
||||
token=context.sign(self._internal_signing_key), message=ingress.message
|
||||
)
|
||||
return RuntimeIngressResponse(status="ACCEPTED", result=result)
|
||||
except (AuthenticationError, ScopeError):
|
||||
return RuntimeIngressResponse(status="AUTH_FAILED")
|
||||
except ValidationError:
|
||||
return RuntimeIngressResponse(status="VALIDATION_FAILED")
|
||||
except ConflictError:
|
||||
return RuntimeIngressResponse(status="CONFLICT")
|
||||
except Exception:
|
||||
return RuntimeIngressResponse(status="INTERNAL_ERROR")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Tenant and Cell scoped, value-free secret reference model."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from .tenant_context import ScopeError, ValidationError, require_ident
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SecretRef:
|
||||
secret_id: str
|
||||
tenant_id: str
|
||||
cell_id: str
|
||||
kind: Literal["channel", "model", "tool", "runtime"]
|
||||
version: str
|
||||
broker_path: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("secret_id", "cell_id", "version"):
|
||||
value = getattr(self, name)
|
||||
if require_ident(name, value) != value:
|
||||
raise ValidationError(f"{name} must be canonical")
|
||||
if require_ident("tenant_id", self.tenant_id, tenant=True) != self.tenant_id:
|
||||
raise ValidationError("tenant_id must be canonical")
|
||||
if self.kind not in {"channel", "model", "tool", "runtime"}:
|
||||
raise ValidationError("invalid secret kind")
|
||||
self._validate_broker_path()
|
||||
|
||||
def _validate_broker_path(self) -> None:
|
||||
if not isinstance(self.broker_path, str) or not self.broker_path.startswith("secret://"):
|
||||
raise ValidationError("secret broker path required")
|
||||
suffix = self.broker_path[len("secret://"):]
|
||||
if (
|
||||
not suffix or "?" in suffix or "#" in suffix or chr(92) in suffix
|
||||
or any(part in {"", ".", ".."} for part in suffix.split("/"))
|
||||
):
|
||||
raise ValidationError("invalid secret broker path")
|
||||
for part in suffix.split("/"):
|
||||
if require_ident("secret broker path segment", part) != part:
|
||||
raise ValidationError("invalid secret broker path")
|
||||
|
||||
def can_be_used_by(self, *, tenant_id: str, cell_id: str) -> bool:
|
||||
return self.tenant_id == tenant_id and self.cell_id == cell_id
|
||||
|
||||
def resolve(self, broker: Any) -> str:
|
||||
"""Resolve through one explicit broker boundary without leaking provider errors."""
|
||||
failure: ScopeError | None = None
|
||||
value: object = None
|
||||
try:
|
||||
reader = getattr(broker, "read_secret")
|
||||
if not callable(reader):
|
||||
raise TypeError("invalid broker")
|
||||
value = reader(self)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError("invalid broker result")
|
||||
except Exception:
|
||||
failure = ScopeError("secret broker unavailable")
|
||||
if failure is not None:
|
||||
raise failure
|
||||
return value
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Process-local session lease pool backed by TenantRouter authority."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from threading import RLock
|
||||
|
||||
from .router import Route, TenantRouter
|
||||
from .tenant_context import ScopeError, TenantContext
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionLease:
|
||||
tenant_id: str
|
||||
cell_id: str
|
||||
principal_id: str
|
||||
session_id: str
|
||||
|
||||
|
||||
class SessionPool:
|
||||
"""Tracks active leases without creating a second session authority store."""
|
||||
|
||||
def __init__(self, router: TenantRouter) -> None:
|
||||
self._router = router
|
||||
self._leases: dict[tuple[str, str], SessionLease] = {}
|
||||
self._lock = RLock()
|
||||
|
||||
@staticmethod
|
||||
def _assert_route(context: TenantContext, route: Route) -> None:
|
||||
if (
|
||||
route.tenant_id != context.tenant_id
|
||||
or route.cell_id != context.cell_id
|
||||
or route.principal_id != context.principal_id
|
||||
or route.session_id != context.session_id
|
||||
):
|
||||
raise ScopeError("session lease does not match authoritative route")
|
||||
|
||||
def assert_available(self, context: TenantContext, route: Route) -> None:
|
||||
"""Re-resolve the context from the router before a ledger mutation."""
|
||||
self._assert_route(context, route)
|
||||
self._assert_route(context, self._router.assert_context_route(context))
|
||||
|
||||
def acquire(self, context: TenantContext, route: Route) -> SessionLease:
|
||||
self._assert_route(context, route)
|
||||
lease = SessionLease(
|
||||
tenant_id=context.tenant_id,
|
||||
cell_id=context.cell_id,
|
||||
principal_id=context.principal_id,
|
||||
session_id=context.session_id,
|
||||
)
|
||||
with self._lock:
|
||||
return self._leases.setdefault((lease.tenant_id, lease.session_id), lease)
|
||||
|
||||
def release(self, lease: SessionLease) -> None:
|
||||
with self._lock:
|
||||
self._leases.pop((lease.tenant_id, lease.session_id), None)
|
||||
|
||||
def active_count(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._leases)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "aios-v2r1-runtime"
|
||||
version = "2.1.0rc1"
|
||||
description = "AIOS V2R1 canonical runtime package"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
aios-cell = "aios_v2r1.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "app"}
|
||||
packages = ["aios_v2r1"]
|
||||
@@ -1 +0,0 @@
|
||||
/data/aios-gitops/workspace/active-runtime
|
||||
@@ -1 +0,0 @@
|
||||
{"ts": 1783642009.9384289, "name": "another_cmd.json"}
|
||||
@@ -1 +0,0 @@
|
||||
{"ts": 1783642009.9384289, "name": "stuck_cmd.json"}
|
||||
@@ -1,19 +0,0 @@
|
||||
# CMD-20260710-sr01-gitea-branch-sync
|
||||
|
||||
## Objective
|
||||
Fix Gitea DB branch index desync: Gitea DB 与 bare repo 分支列表不一致(Gitea API 返回 50 条 vs bare repo 55 条)。
|
||||
|
||||
## Analysis
|
||||
- Root cause: `h_branch_sync()` 之前依赖 Gitea API `POST /api/v1/repos/aios/aios/branches` 分页查询
|
||||
- Token scope 不足 `read:admin` 导致只能获取 50 条(默认 limit),但 total 是 55
|
||||
- 临时修复: 改为 `git ls-remote --heads` 直接枚举 bare repo,绕过了 Gitea API 分页问题
|
||||
- 但根本问题:Gitea DB 索引没有完全同步 bare repo,需要调查 Gitea 后台同步机制
|
||||
|
||||
## Plan
|
||||
1. 诊断 Gitea DB 与 bare repo 实际的差异详情
|
||||
2. 决定修复方案:full resync Gitea, or fix the sync mechanism
|
||||
3. 实现/验证
|
||||
4. 更新 baseline
|
||||
|
||||
## Status
|
||||
pending
|
||||
@@ -1 +0,0 @@
|
||||
{"ts": 1783642009.9169936, "name": "cmd1.json"}
|
||||
@@ -1 +0,0 @@
|
||||
{"ts": 1783642009.9169936, "name": "cmd2.json"}
|
||||
@@ -1 +0,0 @@
|
||||
{"ts": 1783643569.9295633, "name": "recent"}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783583895",
|
||||
"title": "⚠️ Issue Detected: stuck",
|
||||
"body": "1 commands stuck in running/ for >30m",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T15:58:15+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783583993",
|
||||
"title": "⚠️ Issue Detected: stuck",
|
||||
"body": "1 commands stuck in running/ for >30m",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T15:59:53+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783584042",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T16:00:42+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783597695",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T19:48:15+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783597737",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T19:48:57+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783598337",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T19:58:57+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783598938",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T20:08:58+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783599538",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T20:18:58+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783600138",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T20:28:58+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783600738",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T20:38:58+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783601338",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T20:48:58+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783601938",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T20:58:58+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783602538",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T21:08:58+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783603138",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T21:18:58+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783603738",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T21:28:58+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783604338",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T21:38:58+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783604938",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T21:48:58+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783605538",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T21:58:58+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783606138",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T22:08:58+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783606738",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T22:18:58+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783607338",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T22:28:58+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783607939",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T22:38:59+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783608539",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T22:48:59+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783609139",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T22:58:59+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783609739",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T23:08:59+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783610339",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T23:18:59+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783610939",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T23:28:59+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783611539",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T23:38:59+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783612139",
|
||||
"title": "⚠️ Issue Detected: branch_sync",
|
||||
"body": "5 branches missing from Gitea DB",
|
||||
"urgency": "warning",
|
||||
"ts": "2026-07-09T23:48:59+0800"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"nid": "n-1783643869",
|
||||
"title": "Stuck Commands Cleaned",
|
||||
"body": "Moved 2 stuck commands to failed/",
|
||||
"urgency": "info",
|
||||
"ts": "2026-07-10T08:37:49+0800"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"check": "stuck", "severity": "medium", "diagnosis": "stuck commands", "stuck": [{"n": "stuck_cmd.json", "ts": 1783642009.9384289}, {"n": "another_cmd.json", "ts": 1783642009.9384289}], "fix": "move_to_failed"}
|
||||
@@ -1 +0,0 @@
|
||||
{"check": "branch_sync", "severity": "high", "diagnosis": "missing branches", "branches": ["br1"], "fix": "sync_branches"}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783584042",
|
||||
"created": "2026-07-09T16:00:42+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/user-profile-isolation",
|
||||
"patch/gitops-change-archive-audit"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783597695",
|
||||
"created": "2026-07-09T19:48:15+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/user-profile-isolation",
|
||||
"patch/group-owner-agent-context-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783597737",
|
||||
"created": "2026-07-09T19:48:57+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783598337",
|
||||
"created": "2026-07-09T19:58:57+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783598938",
|
||||
"created": "2026-07-09T20:08:58+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783599538",
|
||||
"created": "2026-07-09T20:18:58+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783600138",
|
||||
"created": "2026-07-09T20:28:58+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783600738",
|
||||
"created": "2026-07-09T20:38:58+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783601338",
|
||||
"created": "2026-07-09T20:48:58+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783601938",
|
||||
"created": "2026-07-09T20:58:58+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783602538",
|
||||
"created": "2026-07-09T21:08:58+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783603138",
|
||||
"created": "2026-07-09T21:18:58+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783603738",
|
||||
"created": "2026-07-09T21:28:58+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783604338",
|
||||
"created": "2026-07-09T21:38:58+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783604938",
|
||||
"created": "2026-07-09T21:48:58+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783605538",
|
||||
"created": "2026-07-09T21:58:58+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783606138",
|
||||
"created": "2026-07-09T22:08:58+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783606738",
|
||||
"created": "2026-07-09T22:18:58+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783607338",
|
||||
"created": "2026-07-09T22:28:58+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783607939",
|
||||
"created": "2026-07-09T22:38:59+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783608539",
|
||||
"created": "2026-07-09T22:48:59+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783609139",
|
||||
"created": "2026-07-09T22:58:59+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783609739",
|
||||
"created": "2026-07-09T23:08:59+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783610339",
|
||||
"created": "2026-07-09T23:18:59+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783610939",
|
||||
"created": "2026-07-09T23:28:59+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783611539",
|
||||
"created": "2026-07-09T23:38:59+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783612139",
|
||||
"created": "2026-07-09T23:48:59+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "5 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"patch/agent-event-bus-unified",
|
||||
"patch/agent-gpt-message-plane",
|
||||
"patch/group-owner-agent-context-isolation",
|
||||
"patch/gitops-change-archive-audit",
|
||||
"patch/user-profile-isolation"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"patch_id": "patch-1783643869",
|
||||
"created": "2026-07-10T08:37:49+0800",
|
||||
"check": "branch_sync",
|
||||
"severity": "high",
|
||||
"diagnosis": "2 branches missing from Gitea DB",
|
||||
"branches": [
|
||||
"br1",
|
||||
"br2"
|
||||
],
|
||||
"stuck": [],
|
||||
"fix": "sync_branches"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
this is not json
|
||||
+18
-29
@@ -1,8 +1,7 @@
|
||||
<<<<<<< HEAD
|
||||
# AIOS Agent Command Plane Registry
|
||||
# Git is the single source of truth for all agent commands.
|
||||
version: 1.0.0
|
||||
created_at: 2026-07-08T15:20:00+08:00
|
||||
created_at: "2026-07-08T15:20:00+08:00"
|
||||
baseline: V1R3C1
|
||||
|
||||
commands:
|
||||
@@ -12,6 +11,22 @@ commands:
|
||||
completed_path: "agents/commands/completed/"
|
||||
failed_path: "agents/commands/failed/"
|
||||
|
||||
directories:
|
||||
commands/active: "New commands pending execution — Git is the only entry point"
|
||||
commands/running: "Commands currently being executed by an agent"
|
||||
commands/completed: "Commands that finished successfully — moved here on success"
|
||||
commands/failed: "Commands that finished with errors — moved here on failure"
|
||||
|
||||
lifecycle:
|
||||
new: active/
|
||||
running: running/
|
||||
success: completed/
|
||||
failure: failed/
|
||||
|
||||
# Command lifecycle state machine:
|
||||
# active/ → locked & moved to running/ → execute → success → completed/
|
||||
# → failure → failed/
|
||||
|
||||
policies:
|
||||
path: "agents/policies/"
|
||||
source: "git"
|
||||
@@ -38,30 +53,4 @@ audit:
|
||||
agent_runs: "audit/agent-runs/"
|
||||
version_history: "audit/version-history/"
|
||||
command_history: "audit/command-history/"
|
||||
=======
|
||||
# AIOS Agent Command Registry
|
||||
# Git is the single source of truth for all agent commands.
|
||||
|
||||
registry_version: 1.0.0
|
||||
created_at: "2026-07-08T15:44:00+08:00"
|
||||
baseline: V1R3C1
|
||||
|
||||
directories:
|
||||
commands/active: "New commands pending execution — Git is the only entry point"
|
||||
commands/running: "Commands currently being executed by an agent"
|
||||
commands/completed: "Commands that finished successfully — moved here on success"
|
||||
commands/failed: "Commands that finished with errors — moved here on failure"
|
||||
|
||||
state: agents/state/
|
||||
policies: agents/policies/
|
||||
|
||||
lifecycle:
|
||||
new: active/
|
||||
running: running/
|
||||
success: completed/
|
||||
failure: failed/
|
||||
|
||||
# Command lifecycle state machine:
|
||||
# active/ → locked & moved to running/ → execute → success → completed/
|
||||
# → failure → failed/
|
||||
>>>>>>> 4d18ee0 (feat(cmd): enable Agent Command Plane, audit plane, and Web UI dashboard)
|
||||
interaction_history: "audit/interaction-history/"
|
||||
|
||||
@@ -76,25 +76,20 @@ def h_api():
|
||||
except Exception as e: return {"ok": False, "err": str(e)}
|
||||
|
||||
def h_branch_sync():
|
||||
bare = Path("/data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios.git")
|
||||
bare = Path("/data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios-v23.git")
|
||||
try:
|
||||
# Use git command directly instead of Gitea API to avoid auth/permission issues
|
||||
r = subprocess.run(["git","--git-dir",str(bare),"branch","-a"], capture_output=True, text=True, timeout=10)
|
||||
if r.returncode != 0: return {"ok": False, "err": r.stderr[:200]}
|
||||
bb = set(l.strip().replace("remotes/origin/","").replace("* ","") for l in r.stdout.strip().split("\n") if l.strip())
|
||||
# Use git ls-remote to get remote branches instead of Gitea API
|
||||
r2 = subprocess.run(["git","ls-remote","--heads",GITEA_URL.split("//")[0]+"//"+GITEA_TOKEN+"@"+GITEA_URL.split("//")[1]+"/aios/aios.git"],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
if r2.returncode != 0: return {"ok": False, "bare": len(bb), "gitea": 0, "err": "ls-remote failed: "+r2.stderr[:200]}
|
||||
gb = []
|
||||
for line in r2.stdout.strip().split("\n"):
|
||||
if line.strip():
|
||||
parts = line.strip().split(None, 1)
|
||||
if len(parts) == 2:
|
||||
ref = parts[1].replace("refs/heads/","")
|
||||
gb.append(ref)
|
||||
gs = set(gb)
|
||||
miss = bb - gs
|
||||
gb = []; pg = 1
|
||||
while True:
|
||||
r2 = requests.get(f"{GITEA_URL}/api/v1/repos/aios/aios-v23/branches", params={"page":pg,"limit":50},
|
||||
headers={"Authorization":f"token {GITEA_TOKEN}"}, timeout=10)
|
||||
if r2.status_code != 200: break
|
||||
d = r2.json()
|
||||
if not d: break
|
||||
gb.extend(b["name"] for b in d); pg += 1
|
||||
gs = set(gb); miss = bb - gs
|
||||
return {"ok": len(miss)==0, "bare": len(bb), "gitea": len(gs), "missing": list(miss)[:10]}
|
||||
except Exception as e: return {"ok": False, "err": str(e)}
|
||||
|
||||
|
||||
+10
-11
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"ts": "2026-07-11T02:23:29+0800",
|
||||
"ok": false,
|
||||
"checks": {
|
||||
"gitea": false,
|
||||
"api": true,
|
||||
"branch_sync": true,
|
||||
"stuck": true
|
||||
},
|
||||
"plans": 0,
|
||||
"applied": 0
|
||||
}
|
||||
"ts": "2026-07-09T00:00:00+09:00",
|
||||
"ok": true,
|
||||
"checks": {
|
||||
"git_object_permission": true,
|
||||
"api": true,
|
||||
"branch_sync": true,
|
||||
"stale_runtime_state_cleaned": true
|
||||
},
|
||||
"note": "Updated during PR #2 cleanup before merge"
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
"discovered_at": "2026-07-08T19:01:45+0800"
|
||||
}
|
||||
},
|
||||
"last_scan": "2026-07-11T02:28:28+0800"
|
||||
"last_scan": "2026-07-09T10:42:52+0800"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"run_id": "c-1783583895",
|
||||
"check": "stuck",
|
||||
"status": "degraded",
|
||||
"ts": "2026-07-09T15:58:15+0800",
|
||||
"details": {
|
||||
"ok": false,
|
||||
"stuck": [
|
||||
{
|
||||
"n": "CMD-20260709-enable-autonomous-controller.md",
|
||||
"age": 292.9
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"run_id": "c-1783583993",
|
||||
"check": "stuck",
|
||||
"status": "degraded",
|
||||
"ts": "2026-07-09T15:59:53+0800",
|
||||
"details": {
|
||||
"ok": false,
|
||||
"stuck": [
|
||||
{
|
||||
"n": "CMD-20260709-enable-autonomous-controller.md",
|
||||
"age": 294.6
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"run_id": "c-1783584042",
|
||||
"check": "stuck",
|
||||
"status": "degraded",
|
||||
"ts": "2026-07-09T16:00:42+0800",
|
||||
"details": {
|
||||
"ok": false,
|
||||
"stuck": [
|
||||
{
|
||||
"n": "CMD-20260709-enable-autonomous-controller.md",
|
||||
"age": 295.4
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"run_id": "c-1783597602",
|
||||
"check": "stuck",
|
||||
"status": "ok",
|
||||
"ts": "2026-07-09T19:46:42+0800",
|
||||
"details": {
|
||||
"ok": true,
|
||||
"stuck": []
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user