Compare commits
73
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78c22cbfb9 | ||
|
|
502ef30642 | ||
|
|
b9e1f9db66 | ||
|
|
7b82ae11f6 | ||
|
|
d6ba7e8fa8 | ||
|
|
a6878d30b8 | ||
|
|
f11513e9d7 | ||
|
|
6b645c9cdd | ||
|
|
9d58609e33 | ||
|
|
b355c3b5b9 | ||
|
|
6bbfbff7f4 | ||
|
|
6077331add | ||
|
|
75f61ac0e6 | ||
|
|
0b41a3952a | ||
|
|
8f205c0d87 | ||
|
|
d2a4a765ac | ||
|
|
b1110cad62 | ||
|
|
b483dfadbf | ||
|
|
1e5e144318 | ||
|
|
d552876a80 | ||
|
|
6569065198 | ||
|
|
2b80c50415 | ||
|
|
954982647e | ||
|
|
8074eb31b1 | ||
|
|
90138221ee | ||
|
|
729c7bb23b | ||
|
|
d2de7f23d1 | ||
|
|
73c0a233ce | ||
|
|
00816a13c1 | ||
|
|
4920ac6192 | ||
|
|
b12ff086bf | ||
|
|
57a668d87e | ||
|
|
bab319575b | ||
|
|
c172953a3c | ||
|
|
c7617fa548 | ||
|
|
2eba23d274 | ||
|
|
f2e0406a20 | ||
|
|
ccb02640ac | ||
|
|
5c52f6c82c | ||
|
|
429c6875bc | ||
|
|
f10d0d5b17 | ||
|
|
c9974f3e47 | ||
|
|
c72450aeb0 | ||
|
|
f95be48a42 | ||
|
|
ba989d7340 | ||
|
|
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 |
@@ -0,0 +1,38 @@
|
|||||||
|
status: PASS
|
||||||
|
integration_path: tests/integration/test_runtime_ingress_flow.py
|
||||||
|
tests: pytest tests/v2r1 = 29 passed in 0.98s; pytest tests/integration = 3 passed in 0.18s
|
||||||
|
runtime_flow: PASS - signed public ingress -> IngressAuthenticator -> RuntimeIdentityResolver -> RuntimeWiring.ingest -> ChannelAdapter -> MessageLedger.append -> SessionPool
|
||||||
|
commit: a6878d30b8690e55f1bf9819da5aac189dabf89e
|
||||||
|
gate1: BLOCKED
|
||||||
|
risk: high
|
||||||
|
|
||||||
|
# Result
|
||||||
|
|
||||||
|
## Changed Files
|
||||||
|
|
||||||
|
- `tests/integration/test_runtime_ingress_flow.py`
|
||||||
|
- `.agent/results/codex/RESULT-V2R1-202607132000-CODEX.md`
|
||||||
|
|
||||||
|
## RED Evidence
|
||||||
|
|
||||||
|
The baseline integration entrypoint from `f11513e9d7326caa827e59f4f10f07681633dfc0` was inadequate: its successful flow replaced `MessageLedger.remember()` and its alleged duplicate conflict changed the message ID. The quality checks failed before this update because neither test proved the required runtime contract.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- The success path sends a real signed public ingress request and uses a `sys.setprofile` observer against the real `MessageLedger.remember` code object. It preserves the production object and all behavior, then asserts zero legacy calls.
|
||||||
|
- The duplicate path sends two authenticated requests under the same principal, tenant, cell, channel, account, peer, direction, session, and `message_id`; only `event_id` and body differ. The second response must be `CONFLICT`, with exactly one ledger record and one session lease remaining.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```text
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=active-runtime/app /usr/bin/python3 -m pytest tests/v2r1 -q
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=active-runtime/app /usr/bin/python3 -m pytest tests/integration -q
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gate 1
|
||||||
|
|
||||||
|
`BLOCKED`. This task makes the runtime integration entrypoint meaningful, but does not close the independent Gate 1 matrix, authenticated production OpenClaw registration, secret scanning, second clean-room review, or staging rollback rehearsal.
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
Keep this isolated evidence commit for independent review. Do not merge, tag, or deploy until all Gate 1 requirements have independent evidence.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
status: PASS
|
||||||
|
commit: 7b82ae11f685b77628308ba46b9cfdf5bcdf78d3
|
||||||
|
tests:
|
||||||
|
v2r1: "29 passed, exit code 0"
|
||||||
|
integration: "3 passed, exit code 0"
|
||||||
|
cleanup_scan:
|
||||||
|
status: PASS
|
||||||
|
findings: "No remaining conflict markers, unexplained worktree changes, unapproved temporary/cache/build artifacts, or scanner-detected secrets. Runtime SQLite was removed and its sidecars are ignored with exact root-anchored rules; tracked dist/ZIP artifacts are documented exceptions."
|
||||||
|
risk: MEDIUM
|
||||||
|
recommendation: "Push the controlled work branch, then request independent OpenClaw Gate Review; do not merge or release from this result alone."
|
||||||
|
|
||||||
|
# V2R1 Gate 1 Codex Cleanup Result
|
||||||
|
|
||||||
|
## Scope and Git Baseline
|
||||||
|
|
||||||
|
- Task: `V2R1-G1-CODEX-CLEANUP-20260713`
|
||||||
|
- Branch: `work/v2r1-g1-cleanup-remediation-20260713`
|
||||||
|
- Cleanup implementation commit: `7b82ae11f685b77628308ba46b9cfdf5bcdf78d3`
|
||||||
|
- Integration commit: `a6878d30b8690e55f1bf9819da5aac189dabf89e`
|
||||||
|
- Locked integration result baseline: `d6ba7e8fa859407ec55acf49b5468bba5eac96da`
|
||||||
|
- `git merge-base --is-ancestor a6878d30b8690e55f1bf9819da5aac189dabf89e HEAD`: exit code `0`
|
||||||
|
- `git merge-base --is-ancestor d6ba7e8fa859407ec55acf49b5468bba5eac96da HEAD`: exit code `0`
|
||||||
|
- Tree before this result-only commit: `94146e05f700921b12ef023eb4d8be771bae4047`
|
||||||
|
|
||||||
|
The locked integration artifacts were not modified by cleanup:
|
||||||
|
|
||||||
|
- `tests/integration/test_runtime_ingress_flow.py`: blob `6b6732a4c73bbb7fb5273a05b897adce9da39a9a`
|
||||||
|
- `.agent/results/codex/RESULT-V2R1-202607132000-CODEX.md`: blob `e2d6ce3b037339385831f51cb0db42c86b2ae827`
|
||||||
|
|
||||||
|
## Cleanup Changes Verified
|
||||||
|
|
||||||
|
Commit `7b82ae11f685b77628308ba46b9cfdf5bcdf78d3`:
|
||||||
|
|
||||||
|
- removed the tracked runtime database `data/webops.sqlite3`;
|
||||||
|
- added exact root-anchored ignore rules for `/data/webops.sqlite3`, `/data/webops.sqlite3-wal`, `/data/webops.sqlite3-shm`, and `/data/webops.sqlite3-journal`;
|
||||||
|
- resolved tracked merge-conflict residue in `agents/registry.yaml` and `docs/architecture/AIOS_AGENT_AUDIT_PLANE.md`;
|
||||||
|
- left the locked integration test and prior Codex result blobs unchanged.
|
||||||
|
|
||||||
|
`git grep -n -E '^(<<<<<<<|=======|>>>>>>>)' -- . ':!source-baseline/*.zip'` returned no matches (exit code `1`, the expected no-match result).
|
||||||
|
|
||||||
|
The repository scan found no unapproved `__pycache__`, `*.pyc`, `*.pyo`, `.pytest_cache`, `.mypy_cache`, `.ruff_cache`, `*.tmp`, `*.swp`, backup-suffix, or `.DS_Store` artifacts after verification cleanup. The following tracked artifacts are expected baseline exceptions, not unexplained generated files:
|
||||||
|
|
||||||
|
- `openclaw/plugins/aios-tenant-boundary/dist/` (2 tracked plugin distribution files);
|
||||||
|
- `source-baseline/current_actual_deployment_source.zip`;
|
||||||
|
- `source-baseline/previous_current_actual_deployment_source_from_webops_delta.zip`.
|
||||||
|
|
||||||
|
Before creating this result file, `git status --porcelain=v1 --untracked-files=all` was empty with exit code `0`.
|
||||||
|
|
||||||
|
## Secret Scan
|
||||||
|
|
||||||
|
Scanner: Gitleaks `8.30.1`, official Linux x64 release asset.
|
||||||
|
|
||||||
|
- Asset: `/tmp/gitleaks_8.30.1_linux_x64.tar.gz.codex`
|
||||||
|
- SHA-256: `551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb`
|
||||||
|
- `/tmp/gitleaks-codex-8.30.1/gitleaks git --no-banner --no-color --redact --report-format json --report-path /tmp/v2r1-cleanup-audit-git.json --log-opts=7b82ae11f685b77628308ba46b9cfdf5bcdf78d3 /data/aios-gitops/worktrees/v2r1-g1-cleanup-remediation-20260713`: exit code `0`; Gitleaks reported `312 commits scanned`, approximately `1,384,427 bytes (1.38 MB)` scanned, and `no leaks found` (`0` findings in the JSON report).
|
||||||
|
- `git rev-list --count 7b82ae11f685b77628308ba46b9cfdf5bcdf78d3`: exit code `0`, `327` reachable commits. This Git reachability count is recorded separately and is not the Gitleaks scanned-commit count.
|
||||||
|
- `/tmp/gitleaks-codex-8.30.1/gitleaks dir --no-banner --no-color --redact --report-format json --report-path /tmp/v2r1-cleanup-audit-tree.json /data/aios-gitops/worktrees/v2r1-g1-cleanup-remediation-20260713`: exit code `0`; Gitleaks reported approximately `1,214,737 bytes (1.21 MB)` scanned and `no leaks found` (`0` findings in the JSON report).
|
||||||
|
|
||||||
|
No secret content is reproduced in this report.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=active-runtime/app /usr/bin/python3 -m pytest tests/v2r1
|
||||||
|
```
|
||||||
|
|
||||||
|
Output summary:
|
||||||
|
|
||||||
|
```text
|
||||||
|
collected 29 items
|
||||||
|
29 passed in 0.97s
|
||||||
|
exit code: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=active-runtime/app /usr/bin/python3 -m pytest tests/integration
|
||||||
|
```
|
||||||
|
|
||||||
|
Output summary:
|
||||||
|
|
||||||
|
```text
|
||||||
|
collected 3 items
|
||||||
|
3 passed in 0.18s
|
||||||
|
exit code: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Environment: Linux, Python `3.12.3`, pytest `9.1.1`, pluggy `1.6.0`.
|
||||||
|
|
||||||
|
## Disposition
|
||||||
|
|
||||||
|
The cleanup and requested test verification pass at the stated implementation commit. Risk remains `MEDIUM` until the controlled branch is pushed and independently fetched and reviewed by OpenClaw. This result does not authorize merge, tag creation, baseline update, staging, or production deployment.
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
---
|
||||||
|
status: PASS
|
||||||
|
commit: 502ef30642597fb0b70b90ae2e8a0b6af217d80e
|
||||||
|
task_id: TASK-CODEX-V2R1-G1-FINAL-VERIFY-RETRY-20260715T010807UTC8
|
||||||
|
executor: CODEX_CANDIDATE_BUILDER
|
||||||
|
source_commit: 502ef30642597fb0b70b90ae2e8a0b6af217d80e
|
||||||
|
source_branch: work/v2r1-integration-quality-2000
|
||||||
|
candidate_ref: work/codex/v2r1-g1-recovery-candidate-20260715T010807UTC8
|
||||||
|
tests:
|
||||||
|
status: PASS
|
||||||
|
v2r1:
|
||||||
|
command: >-
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 PYTEST_ADDOPTS='-p no:cacheprovider'
|
||||||
|
PYTHONPATH=active-runtime/app /usr/bin/python3 -m pytest tests/v2r1 -q
|
||||||
|
exit_code: 0
|
||||||
|
passed: 29
|
||||||
|
integration:
|
||||||
|
command: >-
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 PYTEST_ADDOPTS='-p no:cacheprovider'
|
||||||
|
PYTHONPATH=active-runtime/app /usr/bin/python3 -m pytest tests/integration -q
|
||||||
|
exit_code: 0
|
||||||
|
passed: 3
|
||||||
|
commands:
|
||||||
|
- id: v2r1_tests
|
||||||
|
exit_code: 0
|
||||||
|
- id: integration_tests
|
||||||
|
exit_code: 0
|
||||||
|
- id: conflict_object_scan
|
||||||
|
command: >-
|
||||||
|
git grep -n -E '^(<<<<<<<|=======|>>>>>>>)'
|
||||||
|
502ef30642597fb0b70b90ae2e8a0b6af217d80e --
|
||||||
|
. ':(exclude)source-baseline/*.zip'
|
||||||
|
exit_code: 1
|
||||||
|
accepted_exit_code: 1
|
||||||
|
- id: tracked_sqlite_object_scan
|
||||||
|
command: >-
|
||||||
|
git ls-tree -r --name-only
|
||||||
|
502ef30642597fb0b70b90ae2e8a0b6af217d80e --
|
||||||
|
data/webops.sqlite3 data/webops.sqlite3-wal
|
||||||
|
data/webops.sqlite3-shm data/webops.sqlite3-journal
|
||||||
|
exit_code: 0
|
||||||
|
- id: webops_ignore_order
|
||||||
|
command: >-
|
||||||
|
grep -E '^/data/webops\.sqlite3(-wal|-shm|-journal)?$' .gitignore
|
||||||
|
exit_code: 0
|
||||||
|
- id: gitleaks_version
|
||||||
|
command: /tmp/gitleaks-codex-8.30.1/gitleaks version
|
||||||
|
exit_code: 0
|
||||||
|
- id: gitleaks_git
|
||||||
|
command: >-
|
||||||
|
/tmp/gitleaks-codex-8.30.1/gitleaks git --no-banner --no-color
|
||||||
|
--redact --report-format json --report-path "$TMP/git.json"
|
||||||
|
--log-opts=502ef30642597fb0b70b90ae2e8a0b6af217d80e
|
||||||
|
/data/aios-gitops/worktrees/chatgpt/TASK-CODEX-V2R1-G1-FINAL-VERIFY-RETRY-20260715T010807UTC8
|
||||||
|
exit_code: 0
|
||||||
|
- id: gitleaks_dir
|
||||||
|
command: >-
|
||||||
|
/tmp/gitleaks-codex-8.30.1/gitleaks dir --no-banner --no-color
|
||||||
|
--redact --report-format json --report-path "$TMP/dir.json"
|
||||||
|
/data/aios-gitops/worktrees/chatgpt/TASK-CODEX-V2R1-G1-FINAL-VERIFY-RETRY-20260715T010807UTC8
|
||||||
|
exit_code: 0
|
||||||
|
conflict_scan:
|
||||||
|
status: PASS
|
||||||
|
source_object: 502ef30642597fb0b70b90ae2e8a0b6af217d80e
|
||||||
|
exit_code: 1
|
||||||
|
stdout_lines: 0
|
||||||
|
conflict_count: 0
|
||||||
|
tracked_sqlite:
|
||||||
|
status: PASS
|
||||||
|
exit_code: 0
|
||||||
|
count: 0
|
||||||
|
checked_paths:
|
||||||
|
- data/webops.sqlite3
|
||||||
|
- data/webops.sqlite3-wal
|
||||||
|
- data/webops.sqlite3-shm
|
||||||
|
- data/webops.sqlite3-journal
|
||||||
|
webops_ignore_rules:
|
||||||
|
status: PASS
|
||||||
|
count: 4
|
||||||
|
ordered:
|
||||||
|
- /data/webops.sqlite3
|
||||||
|
- /data/webops.sqlite3-wal
|
||||||
|
- /data/webops.sqlite3-shm
|
||||||
|
- /data/webops.sqlite3-journal
|
||||||
|
gitleaks:
|
||||||
|
status: PASS
|
||||||
|
version: "8.30.1"
|
||||||
|
git:
|
||||||
|
exit_code: 0
|
||||||
|
findings: 0
|
||||||
|
report_json: []
|
||||||
|
dir:
|
||||||
|
exit_code: 0
|
||||||
|
findings: 0
|
||||||
|
report_json: []
|
||||||
|
hmac:
|
||||||
|
status: PASS
|
||||||
|
method: AST assignment inspection
|
||||||
|
target: tests/v2r1/test_runtime_wiring.py
|
||||||
|
command: /usr/bin/python3 - tests/v2r1/test_runtime_wiring.py
|
||||||
|
exit_code: 0
|
||||||
|
internal_key_length: 39
|
||||||
|
ingress_key_length: 39
|
||||||
|
placeholder_count: 0
|
||||||
|
mandatory_fields:
|
||||||
|
status: PASS
|
||||||
|
present:
|
||||||
|
- status
|
||||||
|
- commit
|
||||||
|
- tests
|
||||||
|
- cleanup_scan
|
||||||
|
- risk
|
||||||
|
- recommendation
|
||||||
|
cleanup_scan:
|
||||||
|
status: PASS
|
||||||
|
details:
|
||||||
|
temporary_reports_outside_worktree: true
|
||||||
|
temporary_reports_removed: true
|
||||||
|
unexpected_workspace_artifacts: 0
|
||||||
|
secret_scan: PASS
|
||||||
|
rollback:
|
||||||
|
status: PASS
|
||||||
|
required: false
|
||||||
|
deployment: false
|
||||||
|
merge: false
|
||||||
|
tag: false
|
||||||
|
baseline: false
|
||||||
|
workspace_clean_at_test_completion: true
|
||||||
|
risk: LOW
|
||||||
|
recommendation: Advance this exact candidate commit to independent verification only; do not deploy, merge, tag, or alter baselines.
|
||||||
|
---
|
||||||
@@ -11,3 +11,7 @@ runtime/tmp/
|
|||||||
runtime/cache/
|
runtime/cache/
|
||||||
logs/
|
logs/
|
||||||
config/test_write_perms
|
config/test_write_perms
|
||||||
|
/data/webops.sqlite3
|
||||||
|
/data/webops.sqlite3-wal
|
||||||
|
/data/webops.sqlite3-shm
|
||||||
|
/data/webops.sqlite3-journal
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Append-only audit chain for AIOS V2R1."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .tenant_context import canonical_json, isoformat, require_ident
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog:
|
||||||
|
def __init__(self, database: str | Path) -> None:
|
||||||
|
self.database = str(database)
|
||||||
|
with sqlite3.connect(self.database) as db:
|
||||||
|
db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_events (
|
||||||
|
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
tenant_id TEXT NOT NULL,
|
||||||
|
trace_id TEXT NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
previous_hash TEXT NOT NULL,
|
||||||
|
event_hash TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def append(self, *, tenant_id: str, trace_id: str, event_type: str, payload: dict[str, Any]) -> str:
|
||||||
|
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
||||||
|
trace_id = require_ident("trace_id", trace_id)
|
||||||
|
event_type = require_ident("event_type", event_type)
|
||||||
|
with sqlite3.connect(self.database) as db:
|
||||||
|
previous = db.execute(
|
||||||
|
"SELECT event_hash FROM audit_events ORDER BY seq DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
previous_hash = previous[0] if previous else "GENESIS"
|
||||||
|
body = {
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"event_type": event_type,
|
||||||
|
"payload": payload,
|
||||||
|
"previous_hash": previous_hash,
|
||||||
|
"created_at": isoformat(),
|
||||||
|
}
|
||||||
|
digest = hashlib.sha256(
|
||||||
|
canonical_json(body)
|
||||||
|
).hexdigest()
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO audit_events VALUES(NULL,?,?,?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
tenant_id,
|
||||||
|
trace_id,
|
||||||
|
event_type,
|
||||||
|
json.dumps(payload, ensure_ascii=False),
|
||||||
|
previous_hash,
|
||||||
|
digest,
|
||||||
|
body["created_at"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return digest
|
||||||
|
|
||||||
|
def verify(self) -> bool:
|
||||||
|
previous = "GENESIS"
|
||||||
|
with sqlite3.connect(self.database) as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT tenant_id,trace_id,event_type,payload,previous_hash,event_hash,created_at "
|
||||||
|
"FROM audit_events ORDER BY seq"
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
body = {
|
||||||
|
"tenant_id": row[0],
|
||||||
|
"trace_id": row[1],
|
||||||
|
"event_type": row[2],
|
||||||
|
"payload": json.loads(row[3]),
|
||||||
|
"previous_hash": row[4],
|
||||||
|
"created_at": row[6],
|
||||||
|
}
|
||||||
|
if row[4] != previous:
|
||||||
|
return False
|
||||||
|
digest = hashlib.sha256(canonical_json(body)).hexdigest()
|
||||||
|
if digest != row[5]:
|
||||||
|
return False
|
||||||
|
previous = digest
|
||||||
|
return True
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""AIOS V2R1 Tenant Cell lifecycle CLI primitives."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .tenant_context import ValidationError, require_ident
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CellRecord:
|
||||||
|
tenant_id: str
|
||||||
|
cell_id: str
|
||||||
|
version: str
|
||||||
|
state: str
|
||||||
|
config_digest: str
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class CellManager:
|
||||||
|
def __init__(self, registry: str | Path) -> None:
|
||||||
|
self.registry = Path(registry)
|
||||||
|
self.registry.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not self.registry.exists():
|
||||||
|
self.registry.write_text("{\"cells\":{}}\n", encoding="utf-8")
|
||||||
|
|
||||||
|
def _load(self) -> dict:
|
||||||
|
return json.loads(self.registry.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
def _save(self, value: dict) -> None:
|
||||||
|
self.registry.write_text(
|
||||||
|
json.dumps(value, ensure_ascii=False, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def create(self, tenant_id: str, cell_id: str, version: str, digest: str) -> CellRecord:
|
||||||
|
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
||||||
|
cell_id = require_ident("cell_id", cell_id)
|
||||||
|
data = self._load()
|
||||||
|
if tenant_id in data["cells"]:
|
||||||
|
raise ValidationError("tenant already has a cell")
|
||||||
|
record = CellRecord(
|
||||||
|
tenant_id,
|
||||||
|
cell_id,
|
||||||
|
version,
|
||||||
|
"created",
|
||||||
|
digest,
|
||||||
|
datetime.now(timezone.utc).isoformat(),
|
||||||
|
)
|
||||||
|
data["cells"][tenant_id] = record.__dict__
|
||||||
|
self._save(data)
|
||||||
|
return record
|
||||||
|
|
||||||
|
def status(self, tenant_id: str) -> CellRecord:
|
||||||
|
data = self._load()
|
||||||
|
item = data["cells"].get(tenant_id)
|
||||||
|
if not item:
|
||||||
|
raise ValidationError("unknown tenant")
|
||||||
|
return CellRecord(**item)
|
||||||
|
|
||||||
|
def transition(self, tenant_id: str, state: str) -> CellRecord:
|
||||||
|
allowed = {"created", "starting", "healthy", "upgrading", "rollback", "failed"}
|
||||||
|
if state not in allowed:
|
||||||
|
raise ValidationError("invalid cell state")
|
||||||
|
data = self._load()
|
||||||
|
item = data["cells"].get(tenant_id)
|
||||||
|
if not item:
|
||||||
|
raise ValidationError("unknown tenant")
|
||||||
|
item["state"] = state
|
||||||
|
self._save(data)
|
||||||
|
return CellRecord(**item)
|
||||||
@@ -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,48 @@
|
|||||||
|
"""aios-cell command implementation for V2R1 Cell lifecycle."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from .cell_manager import CellManager
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(prog="aios-cell")
|
||||||
|
parser.add_argument("registry")
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
create = sub.add_parser("create")
|
||||||
|
create.add_argument("tenant_id")
|
||||||
|
create.add_argument("cell_id")
|
||||||
|
create.add_argument("version")
|
||||||
|
create.add_argument("digest")
|
||||||
|
|
||||||
|
status = sub.add_parser("status")
|
||||||
|
status.add_argument("tenant_id")
|
||||||
|
|
||||||
|
transition = sub.add_parser("transition")
|
||||||
|
transition.add_argument("tenant_id")
|
||||||
|
transition.add_argument("state")
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
manager = CellManager(args.registry)
|
||||||
|
if args.command == "create":
|
||||||
|
result = manager.create(args.tenant_id, args.cell_id, args.version, args.digest)
|
||||||
|
elif args.command == "status":
|
||||||
|
result = manager.status(args.tenant_id)
|
||||||
|
elif args.command == "transition":
|
||||||
|
result = manager.transition(args.tenant_id, args.state)
|
||||||
|
else:
|
||||||
|
return 2
|
||||||
|
print(json.dumps(result.__dict__, ensure_ascii=False, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -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,143 @@
|
|||||||
|
"""Tenant-partitioned Hermes shared agent fabric primitives."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from contextlib import closing
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .tenant_context import (
|
||||||
|
CanonicalMessage,
|
||||||
|
ConflictError,
|
||||||
|
ScopeError,
|
||||||
|
ValidationError,
|
||||||
|
isoformat,
|
||||||
|
require_ident,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FabricJob:
|
||||||
|
job_id: str
|
||||||
|
tenant_id: str
|
||||||
|
cell_id: str
|
||||||
|
profile_id: str
|
||||||
|
session_id: str
|
||||||
|
event_id: str
|
||||||
|
status: str = "queued"
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in (
|
||||||
|
"job_id", "cell_id", "profile_id", "session_id", "event_id"
|
||||||
|
):
|
||||||
|
require_ident(name, getattr(self, name))
|
||||||
|
require_ident("tenant_id", self.tenant_id, tenant=True)
|
||||||
|
if self.status not in {"queued", "running", "completed", "failed", "cancelled"}:
|
||||||
|
raise ValidationError("invalid job status")
|
||||||
|
|
||||||
|
|
||||||
|
class HermesFabric:
|
||||||
|
"""Shared scheduler storage with mandatory tenant partition keys."""
|
||||||
|
|
||||||
|
def __init__(self, database: str | Path) -> None:
|
||||||
|
self.database = str(database)
|
||||||
|
with closing(sqlite3.connect(self.database)) as connection, connection as db:
|
||||||
|
db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
db.execute("PRAGMA synchronous=FULL")
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS fabric_jobs (
|
||||||
|
job_id TEXT PRIMARY KEY,
|
||||||
|
tenant_id TEXT NOT NULL,
|
||||||
|
cell_id TEXT NOT NULL,
|
||||||
|
profile_id TEXT NOT NULL,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
event_id TEXT NOT NULL UNIQUE,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_fabric_tenant_queue "
|
||||||
|
"ON fabric_jobs(tenant_id, cell_id, status)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def enqueue(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
message: CanonicalMessage,
|
||||||
|
profile_id: str,
|
||||||
|
) -> FabricJob:
|
||||||
|
profile_id = require_ident("profile_id", profile_id)
|
||||||
|
if not message.command_capable:
|
||||||
|
raise ScopeError("non-command events cannot create execution jobs")
|
||||||
|
if not message.is_authenticated_human_command:
|
||||||
|
raise ScopeError("only authenticated human commands can create jobs")
|
||||||
|
job = FabricJob(
|
||||||
|
job_id=f"job-{message.event_id}",
|
||||||
|
tenant_id=message.tenant_id,
|
||||||
|
cell_id=message.cell_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
session_id=message.session_id,
|
||||||
|
event_id=message.event_id,
|
||||||
|
)
|
||||||
|
now = isoformat()
|
||||||
|
conflict: ConflictError | None = None
|
||||||
|
try:
|
||||||
|
with closing(sqlite3.connect(self.database)) as connection, connection as db:
|
||||||
|
db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO fabric_jobs VALUES(?,?,?,?,?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
job.job_id,
|
||||||
|
job.tenant_id,
|
||||||
|
job.cell_id,
|
||||||
|
job.profile_id,
|
||||||
|
job.session_id,
|
||||||
|
job.event_id,
|
||||||
|
job.status,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
conflict = ConflictError(
|
||||||
|
"fabric event already exists or violates queue uniqueness"
|
||||||
|
)
|
||||||
|
if conflict is not None:
|
||||||
|
raise conflict
|
||||||
|
return job
|
||||||
|
|
||||||
|
def claim(self, tenant_id: str, cell_id: str, profile_id: str) -> FabricJob | None:
|
||||||
|
require_ident("tenant_id", tenant_id, tenant=True)
|
||||||
|
require_ident("cell_id", cell_id)
|
||||||
|
require_ident("profile_id", profile_id)
|
||||||
|
with closing(sqlite3.connect(self.database)) as connection, connection as db:
|
||||||
|
db.execute("BEGIN IMMEDIATE")
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT * FROM fabric_jobs WHERE tenant_id=? AND cell_id=? "
|
||||||
|
"AND profile_id=? AND status='queued' ORDER BY created_at LIMIT 1",
|
||||||
|
(tenant_id, cell_id, profile_id),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
|
db.execute(
|
||||||
|
"UPDATE fabric_jobs SET status='running', updated_at=? WHERE job_id=?",
|
||||||
|
(isoformat(), row[0]),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return FabricJob(*row[:7])
|
||||||
|
|
||||||
|
def health(self) -> dict[str, Any]:
|
||||||
|
with closing(sqlite3.connect(self.database)) as connection, connection as db:
|
||||||
|
count = db.execute("SELECT count(*) FROM fabric_jobs").fetchone()[0]
|
||||||
|
return {
|
||||||
|
"component": "hermes_fabric",
|
||||||
|
"jobs": count,
|
||||||
|
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Hermes shared fabric profile isolation primitives."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
|
||||||
|
from .tenant_context import ValidationError, require_ident
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HermesProfile:
|
||||||
|
tenant_id: str
|
||||||
|
cell_id: str
|
||||||
|
profile_id: str
|
||||||
|
hermes_home: Path
|
||||||
|
workspace: Path
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
require_ident("tenant_id", self.tenant_id, tenant=True)
|
||||||
|
for name in ("cell_id", "profile_id"):
|
||||||
|
require_ident(name, getattr(self, name))
|
||||||
|
if not self.hermes_home.is_absolute() or not self.workspace.is_absolute():
|
||||||
|
raise ValidationError("profile paths must be absolute")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def environment(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"HERMES_HOME": str(self.hermes_home),
|
||||||
|
"HOME": str(self.hermes_home),
|
||||||
|
"AIOS_TENANT_ID": self.tenant_id,
|
||||||
|
"AIOS_CELL_ID": self.cell_id,
|
||||||
|
"AIOS_PROFILE_ID": self.profile_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
def prepare(self) -> None:
|
||||||
|
self.hermes_home.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.workspace.mkdir(parents=True, exist_ok=True)
|
||||||
|
os.chmod(self.hermes_home, 0o700)
|
||||||
|
os.chmod(self.workspace, 0o700)
|
||||||
|
|
||||||
|
def assert_path_allowed(self, path: str | Path) -> None:
|
||||||
|
target = Path(path).resolve()
|
||||||
|
for root in (self.hermes_home.resolve(), self.workspace.resolve()):
|
||||||
|
if target == root or root in target.parents:
|
||||||
|
return
|
||||||
|
raise ValidationError("path escapes profile isolation boundary")
|
||||||
@@ -0,0 +1,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,60 @@
|
|||||||
|
"""Tenant-scoped memory namespace and access policy for AIOS V2R1."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from .tenant_context import ScopeError, ValidationError, require_ident
|
||||||
|
|
||||||
|
|
||||||
|
MemoryKind = Literal["session", "profile", "cell", "tenant", "shared_artifact"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MemoryScope:
|
||||||
|
tenant_id: str
|
||||||
|
cell_id: str
|
||||||
|
profile_id: str
|
||||||
|
session_id: str | None
|
||||||
|
kind: MemoryKind
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
require_ident("tenant_id", self.tenant_id, tenant=True)
|
||||||
|
for name in ("cell_id", "profile_id"):
|
||||||
|
require_ident(name, getattr(self, name))
|
||||||
|
if self.session_id is not None:
|
||||||
|
require_ident("session_id", self.session_id)
|
||||||
|
if self.kind not in {"session", "profile", "cell", "tenant", "shared_artifact"}:
|
||||||
|
raise ValidationError("invalid memory kind")
|
||||||
|
if self.kind == "session" and self.session_id is None:
|
||||||
|
raise ValidationError("session memory requires session_id")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def namespace(self) -> str:
|
||||||
|
parts = ["tenant", self.tenant_id, "cell", self.cell_id]
|
||||||
|
if self.kind in {"profile", "session"}:
|
||||||
|
parts.extend(["profile", self.profile_id])
|
||||||
|
if self.kind == "session":
|
||||||
|
parts.extend(["session", str(self.session_id)])
|
||||||
|
parts.extend(["memory", self.kind])
|
||||||
|
return str(PurePosixPath(*parts))
|
||||||
|
|
||||||
|
def assert_access(self, requested: "MemoryScope", *, write: bool = False) -> None:
|
||||||
|
if self.tenant_id != requested.tenant_id:
|
||||||
|
raise ScopeError("cross-tenant memory access denied")
|
||||||
|
if self.cell_id != requested.cell_id:
|
||||||
|
raise ScopeError("cross-cell memory access denied")
|
||||||
|
if requested.kind == "shared_artifact":
|
||||||
|
if write:
|
||||||
|
raise ScopeError("shared artifacts are immutable")
|
||||||
|
return
|
||||||
|
if self.profile_id != requested.profile_id and requested.kind in {"profile", "session"}:
|
||||||
|
raise ScopeError("cross-profile memory access denied")
|
||||||
|
if requested.kind == "session" and self.session_id != requested.session_id:
|
||||||
|
raise ScopeError("cross-session memory access denied")
|
||||||
|
|
||||||
|
|
||||||
|
def qualify_memory_key(scope: MemoryScope, key: str) -> str:
|
||||||
|
key = require_ident("memory_key", key)
|
||||||
|
return f"{scope.namespace}/{key}"
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"""Tenant-scoped append-only message ledger and exact quote resolver."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import timedelta
|
||||||
|
from contextlib import closing
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from .tenant_context import (
|
||||||
|
CanonicalMessage,
|
||||||
|
ConflictError,
|
||||||
|
ScopeError,
|
||||||
|
ValidationError,
|
||||||
|
isoformat,
|
||||||
|
normalize_message_id,
|
||||||
|
parse_time,
|
||||||
|
utc_now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LedgerRecord:
|
||||||
|
tenant_id: str
|
||||||
|
cell_id: str
|
||||||
|
channel: str
|
||||||
|
channel_account_id: str
|
||||||
|
peer_id: str
|
||||||
|
direction: str
|
||||||
|
message_id: str
|
||||||
|
event_id: str
|
||||||
|
body: str
|
||||||
|
occurred_at: str
|
||||||
|
expires_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class MessageLedger:
|
||||||
|
"""Exact-ID ledger.
|
||||||
|
|
||||||
|
The resolver never uses time windows, prefixes, similarity, or nearest-message
|
||||||
|
fallbacks. Ambiguity and absence fail closed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, database: str | Path, *, ttl_days: int = 7) -> None:
|
||||||
|
if not 1 <= ttl_days <= 365:
|
||||||
|
raise ValidationError("ttl_days must be between 1 and 365")
|
||||||
|
self.database = Path(database)
|
||||||
|
self.ttl_days = ttl_days
|
||||||
|
self.database.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._initialize()
|
||||||
|
try:
|
||||||
|
os.chmod(self.database, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
db = sqlite3.connect(str(self.database), timeout=30)
|
||||||
|
db.row_factory = sqlite3.Row
|
||||||
|
db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
db.execute("PRAGMA synchronous=FULL")
|
||||||
|
db.execute("PRAGMA busy_timeout=30000")
|
||||||
|
return db
|
||||||
|
|
||||||
|
def _initialize(self) -> None:
|
||||||
|
with closing(self._connect()) as connection, connection as db:
|
||||||
|
db.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS message_ledger (
|
||||||
|
tenant_id TEXT NOT NULL,
|
||||||
|
cell_id TEXT NOT NULL,
|
||||||
|
channel TEXT NOT NULL,
|
||||||
|
channel_account_id TEXT NOT NULL,
|
||||||
|
peer_id TEXT NOT NULL,
|
||||||
|
direction TEXT NOT NULL CHECK(direction IN ('inbound','outbound','internal')),
|
||||||
|
message_id TEXT NOT NULL,
|
||||||
|
event_id TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
occurred_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(
|
||||||
|
tenant_id, cell_id, channel, channel_account_id,
|
||||||
|
peer_id, direction, message_id
|
||||||
|
),
|
||||||
|
UNIQUE(tenant_id, cell_id, event_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_message_reply_lookup ON message_ledger(
|
||||||
|
tenant_id, cell_id, channel, channel_account_id,
|
||||||
|
peer_id, message_id, expires_at
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def append(self, message: CanonicalMessage) -> LedgerRecord:
|
||||||
|
"""Append one exact message through the runtime write path.
|
||||||
|
|
||||||
|
This is intentionally the owner of validation, idempotency, and the
|
||||||
|
immediate SQLite transaction. Runtime callers must not depend on the
|
||||||
|
legacy ``remember`` compatibility name.
|
||||||
|
"""
|
||||||
|
message_id = normalize_message_id(message.message_id)
|
||||||
|
now = utc_now()
|
||||||
|
expires = isoformat(now + timedelta(days=self.ttl_days))
|
||||||
|
key = (
|
||||||
|
message.tenant_id,
|
||||||
|
message.cell_id,
|
||||||
|
message.channel,
|
||||||
|
message.channel_account_id,
|
||||||
|
message.peer_id,
|
||||||
|
message.direction,
|
||||||
|
message_id,
|
||||||
|
)
|
||||||
|
storage_error: ConflictError | None = None
|
||||||
|
try:
|
||||||
|
with closing(self._connect()) as connection, connection as db:
|
||||||
|
db.execute("BEGIN IMMEDIATE")
|
||||||
|
existing = db.execute(
|
||||||
|
"SELECT event_id, body, occurred_at, expires_at FROM message_ledger "
|
||||||
|
"WHERE tenant_id=? AND cell_id=? AND channel=? AND channel_account_id=? "
|
||||||
|
"AND peer_id=? AND direction=? AND message_id=?",
|
||||||
|
key,
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
if existing["body"] != message.body:
|
||||||
|
db.rollback()
|
||||||
|
raise ConflictError("message ID collision with different content")
|
||||||
|
db.commit()
|
||||||
|
return LedgerRecord(
|
||||||
|
*key, existing["event_id"], existing["body"],
|
||||||
|
existing["occurred_at"], existing["expires_at"],
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO message_ledger VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
(*key, message.event_id, message.body, message.occurred_at, expires, isoformat(now)),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except sqlite3.Error:
|
||||||
|
storage_error = ConflictError("ledger storage unavailable")
|
||||||
|
if storage_error is not None:
|
||||||
|
# Raise outside the sqlite exception handler so callers cannot inspect
|
||||||
|
# a driver exception through __cause__ or __context__.
|
||||||
|
raise storage_error
|
||||||
|
return LedgerRecord(*key, message.event_id, message.body, message.occurred_at, expires)
|
||||||
|
|
||||||
|
def remember(self, message: CanonicalMessage) -> LedgerRecord:
|
||||||
|
"""Backward-compatible API wrapper for callers outside the runtime path."""
|
||||||
|
return self.append(message)
|
||||||
|
|
||||||
|
def matches(self, message: CanonicalMessage) -> bool:
|
||||||
|
"""Whether this exact message is already durably represented."""
|
||||||
|
message_id = normalize_message_id(message.message_id)
|
||||||
|
with closing(self._connect()) as connection, connection as db:
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT event_id, body FROM message_ledger WHERE tenant_id=? AND "
|
||||||
|
"cell_id=? AND channel=? AND channel_account_id=? AND peer_id=? "
|
||||||
|
"AND direction=? AND message_id=?",
|
||||||
|
(
|
||||||
|
message.tenant_id, message.cell_id, message.channel,
|
||||||
|
message.channel_account_id, message.peer_id, message.direction,
|
||||||
|
message_id,
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
return bool(row and row["event_id"] == message.event_id and row["body"] == message.body)
|
||||||
|
|
||||||
|
def remove(self, record: LedgerRecord) -> None:
|
||||||
|
"""Compensate a newly appended record if a later ingress step fails."""
|
||||||
|
with closing(self._connect()) as connection, connection as db:
|
||||||
|
db.execute(
|
||||||
|
"DELETE FROM message_ledger WHERE tenant_id=? AND cell_id=? AND "
|
||||||
|
"channel=? AND channel_account_id=? AND peer_id=? AND direction=? "
|
||||||
|
"AND message_id=? AND event_id=?",
|
||||||
|
(
|
||||||
|
record.tenant_id, record.cell_id, record.channel,
|
||||||
|
record.channel_account_id, record.peer_id, record.direction,
|
||||||
|
record.message_id, record.event_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_quote(self, message: CanonicalMessage) -> str:
|
||||||
|
if message.quoted_body is not None:
|
||||||
|
return message.quoted_body
|
||||||
|
if message.reply_to_message_id is None:
|
||||||
|
raise ScopeError("quoted_text_unavailable")
|
||||||
|
reply_id = normalize_message_id(
|
||||||
|
message.reply_to_message_id,
|
||||||
|
field_name="reply_to_message_id",
|
||||||
|
)
|
||||||
|
with closing(self._connect()) as connection, connection as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT direction, body, event_id FROM message_ledger "
|
||||||
|
"WHERE tenant_id=? AND cell_id=? AND channel=? AND channel_account_id=? "
|
||||||
|
"AND peer_id=? AND message_id=? AND expires_at>?",
|
||||||
|
(
|
||||||
|
message.tenant_id,
|
||||||
|
message.cell_id,
|
||||||
|
message.channel,
|
||||||
|
message.channel_account_id,
|
||||||
|
message.peer_id,
|
||||||
|
reply_id,
|
||||||
|
isoformat(),
|
||||||
|
),
|
||||||
|
).fetchall()
|
||||||
|
if len(rows) == 1:
|
||||||
|
return str(rows[0]["body"])
|
||||||
|
if len(rows) > 1:
|
||||||
|
raise ConflictError("ambiguous reply ID across message directions")
|
||||||
|
raise ScopeError("quoted_text_unavailable")
|
||||||
|
|
||||||
|
def purge_expired(self) -> int:
|
||||||
|
with closing(self._connect()) as connection, connection as db:
|
||||||
|
cursor = db.execute(
|
||||||
|
"DELETE FROM message_ledger WHERE expires_at<=?", (isoformat(),)
|
||||||
|
)
|
||||||
|
return cursor.rowcount
|
||||||
|
|
||||||
|
def integrity_check(self) -> str:
|
||||||
|
with closing(self._connect()) as connection, connection as db:
|
||||||
|
return str(db.execute("PRAGMA integrity_check").fetchone()[0])
|
||||||
|
|
||||||
|
def count(self) -> int:
|
||||||
|
with closing(self._connect()) as connection, connection as db:
|
||||||
|
return int(db.execute("SELECT count(*) FROM message_ledger").fetchone()[0])
|
||||||
@@ -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,33 @@
|
|||||||
|
"""Tenant and Cell scoped secret reference model.
|
||||||
|
|
||||||
|
This module never stores secret values. It stores ownership and retrieval scope
|
||||||
|
metadata for integration with a real secret broker.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from .tenant_context import ValidationError, require_ident
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SecretRef:
|
||||||
|
secret_id: str
|
||||||
|
tenant_id: str
|
||||||
|
cell_id: str
|
||||||
|
kind: Literal["channel", "model", "tool", "runtime"]
|
||||||
|
version: str
|
||||||
|
broker_path: str
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in ("secret_id", "cell_id", "version"):
|
||||||
|
require_ident(name, getattr(self, name))
|
||||||
|
require_ident("tenant_id", self.tenant_id, tenant=True)
|
||||||
|
if self.kind not in {"channel", "model", "tool", "runtime"}:
|
||||||
|
raise ValidationError("invalid secret kind")
|
||||||
|
if not self.broker_path.startswith("secret://"):
|
||||||
|
raise ValidationError("secret broker path required")
|
||||||
|
|
||||||
|
def can_be_used_by(self, *, tenant_id: str, cell_id: str) -> bool:
|
||||||
|
return self.tenant_id == tenant_id and self.cell_id == cell_id
|
||||||
@@ -0,0 +1,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",
|
||||||
|
)
|
||||||
@@ -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
|
# AIOS Agent Command Plane Registry
|
||||||
# Git is the single source of truth for all agent commands.
|
# Git is the single source of truth for all agent commands.
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
created_at: 2026-07-08T15:20:00+08:00
|
created_at: "2026-07-08T15:20:00+08:00"
|
||||||
baseline: V1R3C1
|
baseline: V1R3C1
|
||||||
|
|
||||||
commands:
|
commands:
|
||||||
@@ -12,6 +11,22 @@ commands:
|
|||||||
completed_path: "agents/commands/completed/"
|
completed_path: "agents/commands/completed/"
|
||||||
failed_path: "agents/commands/failed/"
|
failed_path: "agents/commands/failed/"
|
||||||
|
|
||||||
|
directories:
|
||||||
|
commands/active: "New commands pending execution — Git is the only entry point"
|
||||||
|
commands/running: "Commands currently being executed by an agent"
|
||||||
|
commands/completed: "Commands that finished successfully — moved here on success"
|
||||||
|
commands/failed: "Commands that finished with errors — moved here on failure"
|
||||||
|
|
||||||
|
lifecycle:
|
||||||
|
new: active/
|
||||||
|
running: running/
|
||||||
|
success: completed/
|
||||||
|
failure: failed/
|
||||||
|
|
||||||
|
# Command lifecycle state machine:
|
||||||
|
# active/ → locked & moved to running/ → execute → success → completed/
|
||||||
|
# → failure → failed/
|
||||||
|
|
||||||
policies:
|
policies:
|
||||||
path: "agents/policies/"
|
path: "agents/policies/"
|
||||||
source: "git"
|
source: "git"
|
||||||
@@ -38,30 +53,4 @@ audit:
|
|||||||
agent_runs: "audit/agent-runs/"
|
agent_runs: "audit/agent-runs/"
|
||||||
version_history: "audit/version-history/"
|
version_history: "audit/version-history/"
|
||||||
command_history: "audit/command-history/"
|
command_history: "audit/command-history/"
|
||||||
=======
|
interaction_history: "audit/interaction-history/"
|
||||||
# AIOS Agent Command Registry
|
|
||||||
# Git is the single source of truth for all agent commands.
|
|
||||||
|
|
||||||
registry_version: 1.0.0
|
|
||||||
created_at: "2026-07-08T15:44:00+08:00"
|
|
||||||
baseline: V1R3C1
|
|
||||||
|
|
||||||
directories:
|
|
||||||
commands/active: "New commands pending execution — Git is the only entry point"
|
|
||||||
commands/running: "Commands currently being executed by an agent"
|
|
||||||
commands/completed: "Commands that finished successfully — moved here on success"
|
|
||||||
commands/failed: "Commands that finished with errors — moved here on failure"
|
|
||||||
|
|
||||||
state: agents/state/
|
|
||||||
policies: agents/policies/
|
|
||||||
|
|
||||||
lifecycle:
|
|
||||||
new: active/
|
|
||||||
running: running/
|
|
||||||
success: completed/
|
|
||||||
failure: failed/
|
|
||||||
|
|
||||||
# Command lifecycle state machine:
|
|
||||||
# active/ → locked & moved to running/ → execute → success → completed/
|
|
||||||
# → failure → failed/
|
|
||||||
>>>>>>> 4d18ee0 (feat(cmd): enable Agent Command Plane, audit plane, and Web UI dashboard)
|
|
||||||
|
|||||||
@@ -76,25 +76,20 @@ def h_api():
|
|||||||
except Exception as e: return {"ok": False, "err": str(e)}
|
except Exception as e: return {"ok": False, "err": str(e)}
|
||||||
|
|
||||||
def h_branch_sync():
|
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:
|
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)
|
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]}
|
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())
|
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
|
gb = []; pg = 1
|
||||||
r2 = subprocess.run(["git","ls-remote","--heads",GITEA_URL.split("//")[0]+"//"+GITEA_TOKEN+"@"+GITEA_URL.split("//")[1]+"/aios/aios.git"],
|
while True:
|
||||||
capture_output=True, text=True, timeout=15)
|
r2 = requests.get(f"{GITEA_URL}/api/v1/repos/aios/aios-v23/branches", params={"page":pg,"limit":50},
|
||||||
if r2.returncode != 0: return {"ok": False, "bare": len(bb), "gitea": 0, "err": "ls-remote failed: "+r2.stderr[:200]}
|
headers={"Authorization":f"token {GITEA_TOKEN}"}, timeout=10)
|
||||||
gb = []
|
if r2.status_code != 200: break
|
||||||
for line in r2.stdout.strip().split("\n"):
|
d = r2.json()
|
||||||
if line.strip():
|
if not d: break
|
||||||
parts = line.strip().split(None, 1)
|
gb.extend(b["name"] for b in d); pg += 1
|
||||||
if len(parts) == 2:
|
gs = set(gb); miss = bb - gs
|
||||||
ref = parts[1].replace("refs/heads/","")
|
|
||||||
gb.append(ref)
|
|
||||||
gs = set(gb)
|
|
||||||
miss = bb - gs
|
|
||||||
return {"ok": len(miss)==0, "bare": len(bb), "gitea": len(gs), "missing": list(miss)[:10]}
|
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)}
|
except Exception as e: return {"ok": False, "err": str(e)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
{
|
{
|
||||||
"ts": "2026-07-11T02:23:29+0800",
|
"ts": "2026-07-09T00:00:00+09:00",
|
||||||
"ok": false,
|
"ok": true,
|
||||||
"checks": {
|
"checks": {
|
||||||
"gitea": false,
|
"git_object_permission": true,
|
||||||
"api": true,
|
"api": true,
|
||||||
"branch_sync": true,
|
"branch_sync": true,
|
||||||
"stuck": true
|
"stale_runtime_state_cleaned": true
|
||||||
},
|
},
|
||||||
"plans": 0,
|
"note": "Updated during PR #2 cleanup before merge"
|
||||||
"applied": 0
|
|
||||||
}
|
}
|
||||||
@@ -11,5 +11,5 @@
|
|||||||
"discovered_at": "2026-07-08T19:01:45+0800"
|
"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": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"run_id": "c-1783597695",
|
|
||||||
"check": "stuck",
|
|
||||||
"status": "ok",
|
|
||||||
"ts": "2026-07-09T19:48:15+0800",
|
|
||||||
"details": {
|
|
||||||
"ok": true,
|
|
||||||
"stuck": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"run_id": "c-1783597737",
|
|
||||||
"check": "stuck",
|
|
||||||
"status": "ok",
|
|
||||||
"ts": "2026-07-09T19:48:57+0800",
|
|
||||||
"details": {
|
|
||||||
"ok": true,
|
|
||||||
"stuck": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"run_id": "c-1783598337",
|
|
||||||
"check": "stuck",
|
|
||||||
"status": "ok",
|
|
||||||
"ts": "2026-07-09T19:58:57+0800",
|
|
||||||
"details": {
|
|
||||||
"ok": true,
|
|
||||||
"stuck": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"run_id": "c-1783598937",
|
|
||||||
"check": "api",
|
|
||||||
"status": "ok",
|
|
||||||
"ts": "2026-07-09T20:08:57+0800",
|
|
||||||
"details": {
|
|
||||||
"ok": true,
|
|
||||||
"baseline": "V1R4C3",
|
|
||||||
"versions": 13
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user