Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
695a846645 | ||
|
|
c75fa77898 | ||
|
|
b199935db8 | ||
|
|
f7307c82b2 | ||
|
|
bf01a13f3a | ||
|
|
8a36f7832b | ||
|
|
f1aeefff20 | ||
|
|
f55dfd975e | ||
|
|
bd2784015d | ||
|
|
1aa71f233d | ||
|
|
558dc932fa | ||
|
|
5fd5ae0a33 | ||
|
|
81572b8231 | ||
|
|
8c3d80fe53 | ||
|
|
99bcc03b40 | ||
|
|
c6e7edcf54 | ||
|
|
c464560a0f |
@@ -0,0 +1,412 @@
|
||||
# OpenClaw 在线执行指令:启动执行器、配置 Secret 引用并确认 Gitea 同步
|
||||
|
||||
- 任务 ID:`AIOS-V2R1-PRODUCTION-DEPLOY-20260720`
|
||||
- 执行器:OpenClaw
|
||||
- 任务分支:`task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720`
|
||||
- 候选分支:`integration/aios-v2r1-cto-production-candidate-20260720`
|
||||
- 语言:中文
|
||||
- 执行模式:无人值守、证据驱动、默认拒绝
|
||||
|
||||
## 1. 任务目标
|
||||
|
||||
一次性完成以下工作:
|
||||
|
||||
1. 发现并启动环境中已注册的 OpenClaw 在线执行器;
|
||||
2. 领取本任务并持续写入 claim、heartbeat、progress、blocker 和 result;
|
||||
3. 将任务分支同步到最新 Candidate HEAD;
|
||||
4. 确认 Candidate、OpenClaw Task、Codex Task 三个分支已同步到 Gitea,且本地与远端 OID 完全一致;
|
||||
5. 从已批准的 Secret Provider、Credential Store 或受保护文件中解析 OpenClaw Runtime 配置;
|
||||
6. 只把 Secret 引用和受保护文件路径写入 AIOS 环境配置,不得把明文 Secret 写入 Git、日志、Evidence 或终端输出;
|
||||
7. 执行文档—代码检查、候选测试、全量验收和预发布健康检查;
|
||||
8. 对低风险部署或 Runtime 缺陷创建 Patch 并修复;
|
||||
9. 把所有修改和阻塞提交到系统,交由 Codex 统一审视。
|
||||
|
||||
## 2. 不可违反的边界
|
||||
|
||||
- 不得直接修改或 push `main`;
|
||||
- 不得使用 `--force` 或改写共享分支历史;
|
||||
- 不得创建生产 Tag;
|
||||
- 不得切换生产流量,除非 AIOS Owner 后续明确批准;
|
||||
- 不得提交 Token、密码、API Key、私钥、签名材料或 Secret 内容;
|
||||
- 不得在日志中执行 `env`、`set`、`printenv` 或输出 Secret 文件内容;
|
||||
- 不得修改 AIOS 唯一控制面、多租户或审批边界;
|
||||
- 发现架构、安全或跨模块阻塞时必须升级 Codex;
|
||||
- 未生成 claim/heartbeat 证据前不得标记为运行中;
|
||||
- 未通过验收前不得标记为完成。
|
||||
|
||||
## 3. 第一阶段:执行器发现与领取
|
||||
|
||||
### 3.1 发现执行器入口
|
||||
|
||||
使用当前环境中已经注册和批准的 OpenClaw 执行入口。可检查:
|
||||
|
||||
- systemd 服务;
|
||||
- 容器或 Compose 服务;
|
||||
- 已安装的 OpenClaw CLI;
|
||||
- AIOS Agent Runner;
|
||||
- 已配置的队列消费者或调度器。
|
||||
|
||||
不得臆造不存在的命令、服务名或路径。
|
||||
|
||||
如果找不到可执行入口,立即创建 Blocker:
|
||||
|
||||
```json
|
||||
{
|
||||
"blocker_id": "BLK-EXECUTOR-ENTRYPOINT-MISSING",
|
||||
"severity": "high",
|
||||
"description": "环境中未发现已注册的 OpenClaw 执行器入口",
|
||||
"owner": "AIOS 运维负责人",
|
||||
"exit_condition": "提供或启用已批准的 OpenClaw systemd、容器、CLI 或 Agent Runner 入口"
|
||||
}
|
||||
```
|
||||
|
||||
状态更新为 `blocked`,不得伪报已经启动。
|
||||
|
||||
### 3.2 启动执行器
|
||||
|
||||
发现入口后:
|
||||
|
||||
1. 验证配置来源和运行用户;
|
||||
2. 启动或恢复执行器;
|
||||
3. 验证进程/容器处于运行状态;
|
||||
4. 验证执行器能够读取本任务分支;
|
||||
5. 立即写入 `.agent/claim.json`;
|
||||
6. 更新 `.agent/status.json`:`queued -> claimed -> running`;
|
||||
7. 写入首次 `.agent/heartbeat.json`;
|
||||
8. 将证据提交到任务分支并同步 Gitea。
|
||||
|
||||
`claim.json` 至少包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "aios.v2.agent-claim.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"executor": "openclaw",
|
||||
"task_branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"candidate_branch": "integration/aios-v2r1-cto-production-candidate-20260720",
|
||||
"candidate_commit": "<领取时实际 Candidate OID>",
|
||||
"claimed_at": "<UTC ISO-8601>",
|
||||
"executor_instance": "<非敏感实例标识>"
|
||||
}
|
||||
```
|
||||
|
||||
Heartbeat 间隔不得超过 300 秒。
|
||||
|
||||
## 4. 第二阶段:同步最新 Candidate
|
||||
|
||||
### 4.1 获取最新引用
|
||||
|
||||
只更新 remote-tracking ref,不得 fetch 到已经被 Worktree 检出的本地 Branch Ref。
|
||||
|
||||
执行原则:
|
||||
|
||||
```text
|
||||
fetch remote candidate -> refs/remotes/<remote>/<candidate>
|
||||
```
|
||||
|
||||
不得使用会触发以下错误的流程:
|
||||
|
||||
```text
|
||||
refusing to fetch into branch checked out at worktree
|
||||
```
|
||||
|
||||
### 4.2 更新任务分支
|
||||
|
||||
将最新 Candidate 合并到 OpenClaw 任务分支:
|
||||
|
||||
- 使用普通 merge 或团队批准的线性更新方式;
|
||||
- 不得 force reset 已经提交的 Agent Evidence;
|
||||
- 发生冲突时停止自动扩大修改范围;
|
||||
- 文档、任务载荷或架构冲突升级 Codex;
|
||||
- 合并完成后把 `.agent/task.json` 与 `.agent/status.json` 中的 `candidate_commit` 更新为实际 Candidate OID。
|
||||
|
||||
### 4.3 必须记录
|
||||
|
||||
在 `.agent/progress.json` 记录:
|
||||
|
||||
- Candidate 本地 OID;
|
||||
- Candidate Gitea OID;
|
||||
- Task Branch 合并前后 OID;
|
||||
- 修改文件;
|
||||
- 是否发生冲突;
|
||||
- 合并 Commit。
|
||||
|
||||
## 5. 第三阶段:确认 Gitea 同步
|
||||
|
||||
需要验证以下分支:
|
||||
|
||||
```text
|
||||
integration/aios-v2r1-cto-production-candidate-20260720
|
||||
task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720
|
||||
task/codex/AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720
|
||||
```
|
||||
|
||||
目标仓库:
|
||||
|
||||
```text
|
||||
g.cnnetmkt.top/git/aios/aios
|
||||
```
|
||||
|
||||
### 5.1 同步规则
|
||||
|
||||
- 使用环境中已配置的 Gitea Remote 和凭据引用;
|
||||
- 不在 Evidence 中记录带凭据的 Remote URL;
|
||||
- 禁止 Force Push;
|
||||
- Candidate 和 Task Branch 可以普通 Push;
|
||||
- Push 失败时记录 stderr 的脱敏摘要,不得记录凭据。
|
||||
|
||||
### 5.2 OID 验证
|
||||
|
||||
每个分支必须同时取得:
|
||||
|
||||
```text
|
||||
local_oid
|
||||
gitea_oid
|
||||
match = true/false
|
||||
```
|
||||
|
||||
验收条件:
|
||||
|
||||
```text
|
||||
local_oid == gitea_oid
|
||||
```
|
||||
|
||||
建议通过 remote-tracking ref 或 `git ls-remote --heads` 取得远端 OID。输出中不得泄露认证信息。
|
||||
|
||||
如果 Push 或 OID 校验失败,创建 Blocker:
|
||||
|
||||
```json
|
||||
{
|
||||
"blocker_id": "BLK-GITEA-SYNC",
|
||||
"severity": "high",
|
||||
"description": "本地与 Gitea 分支 OID 不一致或无推送权限",
|
||||
"owner": "GitOps/Gitea 运维负责人",
|
||||
"exit_condition": "三个目标分支均完成普通推送且 local_oid 等于 gitea_oid"
|
||||
}
|
||||
```
|
||||
|
||||
在 Gitea OID 一致前,不得继续预发布部署。
|
||||
|
||||
## 6. 第四阶段:配置 Secret 引用
|
||||
|
||||
### 6.1 需要解析的配置
|
||||
|
||||
至少确认:
|
||||
|
||||
```text
|
||||
AIOS_OPENCLAW_URL
|
||||
AIOS_OPENCLAW_CREDENTIAL_REF
|
||||
AIOS_OPENCLAW_SIGNING_MATERIAL_FILE
|
||||
AIOS_OPENCLAW_SIGNING_KEY_ID
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `AIOS_OPENCLAW_URL` 是经过批准的 Runtime Endpoint;
|
||||
- `AIOS_OPENCLAW_CREDENTIAL_REF` 只保存 Secret Provider 引用;
|
||||
- `AIOS_OPENCLAW_SIGNING_MATERIAL_FILE` 指向仓库外的受保护文件;
|
||||
- `AIOS_OPENCLAW_SIGNING_KEY_ID` 是 Key 标识,不是 Key 内容。
|
||||
|
||||
### 6.2 Secret 来源优先级
|
||||
|
||||
按以下顺序查找:
|
||||
|
||||
1. 已批准的运行时 Secret Store;
|
||||
2. 已批准的 Credential Provider;
|
||||
3. systemd Credential;
|
||||
4. 只读 Secret Volume;
|
||||
5. 仓库外受保护文件。
|
||||
|
||||
不得:
|
||||
|
||||
- 从 Git 历史恢复 Secret;
|
||||
- 从日志、Shell History 或旧 Evidence 复制 Secret;
|
||||
- 新建明文 Secret 并提交;
|
||||
- 把 Secret 内容写入 `.env` 模板或任务 JSON。
|
||||
|
||||
### 6.3 配置文件与权限
|
||||
|
||||
生产候选环境文件:
|
||||
|
||||
```text
|
||||
/etc/aios-v2/aios-v2.env
|
||||
```
|
||||
|
||||
建议权限:
|
||||
|
||||
```text
|
||||
owner: root
|
||||
group: aios
|
||||
mode: 0640 或更严格
|
||||
```
|
||||
|
||||
签名材料文件:
|
||||
|
||||
```text
|
||||
位于仓库外
|
||||
owner: aios 或经批准的运行用户
|
||||
mode: 0600 或更严格
|
||||
```
|
||||
|
||||
只验证以下内容:
|
||||
|
||||
- 引用非空;
|
||||
- 文件存在;
|
||||
- 运行用户可读;
|
||||
- 权限不宽松;
|
||||
- 文件不在 Git Worktree;
|
||||
- 不输出文件内容或长度特征。
|
||||
|
||||
### 6.4 缺少 Secret 时的行为
|
||||
|
||||
若无法从已批准来源解析任一必需配置,创建:
|
||||
|
||||
```json
|
||||
{
|
||||
"blocker_id": "BLK-SECRET-REF-MISSING",
|
||||
"severity": "high",
|
||||
"description": "缺少已批准的 OpenClaw Runtime Endpoint、Credential Ref、Signing File 或 Key ID",
|
||||
"owner": "AIOS Secret/运维负责人",
|
||||
"exit_condition": "四项配置均可从批准来源解析,且权限检查通过"
|
||||
}
|
||||
```
|
||||
|
||||
不得要求用户在聊天或 Git 中提供 Secret 明文。
|
||||
|
||||
## 7. 第五阶段:候选验收与预发布
|
||||
|
||||
在 Gitea OID 和 Secret 引用检查都通过后,依次执行:
|
||||
|
||||
```bash
|
||||
python3 tools/aios_v2_docs_code_check.py \
|
||||
--base-ref integration/aios-v2r1-cto-production-candidate-20260720
|
||||
|
||||
python3 -m unittest discover \
|
||||
-s tests/aios_v2_candidate \
|
||||
-p 'test_*.py' \
|
||||
-v
|
||||
|
||||
bash deploy/aios-v2/acceptance/run-all.sh
|
||||
```
|
||||
|
||||
部署后执行:
|
||||
|
||||
```bash
|
||||
AIOS_RUN_LIVE_HEALTHCHECK=1 \
|
||||
bash deploy/aios-v2/acceptance/run-all.sh
|
||||
|
||||
bash deploy/aios-v2/healthcheck/check.sh
|
||||
```
|
||||
|
||||
必须验证:
|
||||
|
||||
- AIOS HTTP Health;
|
||||
- SQLite `quick_check=ok`;
|
||||
- GitOps Controller detached + clean;
|
||||
- OpenClaw execute/status/cancel/timeout/health;
|
||||
- Hermes claim/heartbeat/complete/fail/lease recovery;
|
||||
- 服务重启后 Tenant/Agent/Task/Audit/Idempotency 恢复;
|
||||
- 回滚后健康检查通过;
|
||||
- 日志与 Evidence 无 Secret。
|
||||
|
||||
## 8. Bug 修复规则
|
||||
|
||||
OpenClaw 可自行修复:
|
||||
|
||||
- systemd、Docker、Compose、路径和权限;
|
||||
- 环境变量名或启动参数;
|
||||
- Healthcheck;
|
||||
- 明确的小型 Runtime Adapter 兼容问题;
|
||||
- 日志、错误消息和超时边界;
|
||||
- 测试、部署或中文文档中的明确错误。
|
||||
|
||||
每个修复必须:
|
||||
|
||||
1. 创建 `patch/bugfix/*`;
|
||||
2. 记录复现步骤;
|
||||
3. 添加或更新测试;
|
||||
4. 修改代码;
|
||||
5. 同步 `docs/aios-v2/` 和追踪矩阵;
|
||||
6. 执行文档门禁和相关测试;
|
||||
7. 提交 Git;
|
||||
8. 更新 `.agent/blockers.json` 和 `.agent/progress.json`;
|
||||
9. 提交 Codex 审查。
|
||||
|
||||
以下问题必须升级 Codex:
|
||||
|
||||
- Control Plane、Task Lifecycle、Policy 或 Approval 变化;
|
||||
- 多租户或身份边界变化;
|
||||
- OpenClaw/Hermes Contract 不兼容变化;
|
||||
- 数据迁移、持久化或回滚策略变化;
|
||||
- 高风险安全问题;
|
||||
- 需要修改 ADR 的问题;
|
||||
- 超出低风险修复范围的阻塞。
|
||||
|
||||
## 9. 证据文件
|
||||
|
||||
任务分支必须持续维护:
|
||||
|
||||
```text
|
||||
.agent/claim.json
|
||||
.agent/heartbeat.json
|
||||
.agent/progress.json
|
||||
.agent/blockers.json
|
||||
.agent/result.json
|
||||
.agent/status.json
|
||||
```
|
||||
|
||||
`result.json` 至少包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "aios.v2.agent-result.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"final_status": "complete|failed|blocked",
|
||||
"candidate_commit": "<OID>",
|
||||
"task_commit": "<OID>",
|
||||
"gitea_sync": {
|
||||
"candidate_match": true,
|
||||
"openclaw_task_match": true,
|
||||
"codex_task_match": true
|
||||
},
|
||||
"secret_reference_status": "configured|blocked",
|
||||
"executor_status": "running|blocked",
|
||||
"test_status": "passed|failed|not_run",
|
||||
"deployment_status": "passed|failed|not_run",
|
||||
"rollback_status": "passed|failed|not_run",
|
||||
"documentation_sync_status": "passed|failed",
|
||||
"changed_files": [],
|
||||
"commits": [],
|
||||
"open_blockers": [],
|
||||
"remaining_risks": [],
|
||||
"completed_at": "<UTC ISO-8601>"
|
||||
}
|
||||
```
|
||||
|
||||
## 10. 完成条件
|
||||
|
||||
只有以下条件全部满足,任务才可标记为 `complete`:
|
||||
|
||||
- OpenClaw 执行器已启动并持续 Heartbeat;
|
||||
- 已领取任务;
|
||||
- 任务分支已合并最新 Candidate;
|
||||
- Candidate、OpenClaw Task、Codex Task 在本地与 Gitea 的 OID 一致;
|
||||
- Secret 引用和受保护文件权限检查通过;
|
||||
- 文档—代码检查通过;
|
||||
- 候选测试通过;
|
||||
- 预发布部署和在线健康检查通过;
|
||||
- Runtime 联调、重启和回滚演练通过;
|
||||
- 所有修改、Blocker、Commit、测试和结果已回写;
|
||||
- Codex 已能读取并审查完整证据。
|
||||
|
||||
如果缺少外部权限、执行入口或已批准 Secret,任务必须标记为 `blocked`,并准确说明 Owner 与解除条件,不得伪报完成。
|
||||
|
||||
## 11. Owner Gate
|
||||
|
||||
完成本指令不等于自动批准生产发布。以下动作仍需 AIOS Owner 明确批准:
|
||||
|
||||
- 合并 `main`;
|
||||
- 创建生产 Tag;
|
||||
- 正式生产部署;
|
||||
- 生产流量切换;
|
||||
- 接受高风险剩余问题。
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"schema": "aios.v2.agent-blockers.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"updated_at": "2026-07-20T10:17:49Z",
|
||||
"evidence_writer": "windows-chatgpt-owner",
|
||||
"blockers": [
|
||||
{
|
||||
"blocker_id": "BLK-OPENCLAW-PLAINTEXT-SECRETS",
|
||||
"severity": "high",
|
||||
"status": "resolved",
|
||||
"root_cause": "OpenClaw managed config、auth profile store 与 models.json 存在三处明文凭据。",
|
||||
"fix": "使用 OpenClaw 原生 secrets apply 迁移为单一 file Secret Provider;Gateway Token 已轮换,OneAPI 使用单一 auth-profile SecretRef。",
|
||||
"verification": "audit plaintext/unresolved/shadowed/legacy 全部为 0,实际 OneAPI agent 调用通过。",
|
||||
"commit": "bf01a13f3aaf42a386232ed2f5409feb0f79fcf6",
|
||||
"resolved_at": "2026-07-20T10:17:49Z"
|
||||
},
|
||||
{
|
||||
"blocker_id": "BLK-AIOS-V2-SIGNING-MATERIAL-MISSING",
|
||||
"severity": "high",
|
||||
"status": "resolved",
|
||||
"root_cause": "Key ID 标称 Ed25519,但代码实际使用 HMAC 且材料文件缺失。",
|
||||
"fix": "签名合同改为真实 Ed25519,并由 Owner 在受保护边界生成运行材料。",
|
||||
"verification": "aios:aios 0600;resolver sign/verify 与篡改拒绝通过。",
|
||||
"commit": "8a36f7832bc105e4b9d32ffbe018abed2ff1b1ab",
|
||||
"resolved_at": "2026-07-20T10:17:49Z"
|
||||
},
|
||||
{
|
||||
"blocker_id": "BLK-CANDIDATE-NOT-MERGED",
|
||||
"severity": "high",
|
||||
"status": "resolved",
|
||||
"root_cause": "OpenClaw 任务未包含最新 Candidate。",
|
||||
"fix": "保护 Evidence 后以 --no-ff 合并 Candidate ba7669c。",
|
||||
"verification": "Candidate 为任务分支祖先,普通 Push 后 local_oid 等于 gitea_oid。",
|
||||
"commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||
"resolved_at": "2026-07-20T10:17:49Z"
|
||||
},
|
||||
{
|
||||
"blocker_id": "BLK-OPENCLAW-RUN-PROVENANCE-MISSING",
|
||||
"severity": "high",
|
||||
"status": "blocked",
|
||||
"root_cause": "现有插件只有进程内 runId/sender trust Map,没有 Owner-signed task provenance schema、持久 Store 或受限 Evidence 写工具。",
|
||||
"fix": "禁止用 run-key fallback 绕过;等待经审查的 issuer/store/writer 或外部同等受信系统。",
|
||||
"verification": "当前无法完成 7 路径写测试与越权拒绝审计。",
|
||||
"commit": "bf01a13f3aaf42a386232ed2f5409feb0f79fcf6",
|
||||
"resolved_at": null
|
||||
},
|
||||
{
|
||||
"blocker_id": "BLK-CONTROLLER-LEGACY-GITEA-CHECK",
|
||||
"severity": "high",
|
||||
"status": "resolved",
|
||||
"root_cause": "Controller 硬编码旧 aios-v23 仓库与 API,无法区分本地和 Gitea OID。",
|
||||
"fix": "配置化 V2R1 仓库/分支,使用只读 systemd Credential,部署到 detached clean 工作树。",
|
||||
"verification": "12 项测试通过;gitea/branch_sync/oid_match 为 true,sync_status=fully_synced。",
|
||||
"commit": "6188da26453505dacde9d6d298191437164c8d27",
|
||||
"resolved_at": "2026-07-20T10:17:49Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"schema": "aios.v2.agent-claim.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"executor": "openclaw",
|
||||
"task_branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"candidate_branch": "integration/aios-v2r1-cto-production-candidate-20260720",
|
||||
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
|
||||
"claimed_at": "2026-07-20T08:24:30Z",
|
||||
"executor_instance": "openclaw-main-cn-network-mkt",
|
||||
"owner_observation_at": "2026-07-20T10:17:49Z",
|
||||
"owner_observation": "四项高风险阻塞已清除;provenance 仍缺可信签发/强制执行链。",
|
||||
"evidence_writer": "windows-chatgpt-owner",
|
||||
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
|
||||
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||
"candidate_updated_at": "2026-07-20T09:39:16Z"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"schema": "aios.v2.credential-status.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"credential_name": "openclaw-deploy-cli",
|
||||
"credential_type": "gitea-api-token-reference",
|
||||
"endpoints": [
|
||||
"http://127.0.0.1:3000",
|
||||
"https://g.cnnetmkt.top/git"
|
||||
],
|
||||
"reported_scope": "all",
|
||||
"reported_status": "working",
|
||||
"verification_source": "owner-reported",
|
||||
"security_status": "secretref_resolved",
|
||||
"reason": "令牌值曾在聊天中以明文出现,必须按已暴露凭据处理",
|
||||
"required_actions": [
|
||||
"已完成",
|
||||
"新令牌已保存到受保护文件",
|
||||
"令牌用于HTTPS Git认证"
|
||||
],
|
||||
"rotation_completed_at": "2026-07-20T08:48:00Z",
|
||||
"new_token_created": "openclaw-deploy-task-commit",
|
||||
"new_token_scope": "write:repository,read:repository",
|
||||
"token_file_path": "/etc/openclaw/secrets/openclaw-gitea-deploy-token",
|
||||
"token_file_owner": "root:root",
|
||||
"token_file_mode": "0600",
|
||||
"secret_provider_contract_path": "/etc/openclaw/SECRET_PROVIDER_CONTRACT.json",
|
||||
"last_verified": "2026-07-20T10:17:49Z",
|
||||
"verification_result": "HTTPS Git push successful with new token",
|
||||
"env_file_reference": "AIOS_OPENCLAW_CREDENTIAL_REF=file:///etc/openclaw/secrets/openclaw-gitea-deploy-token",
|
||||
"env_file_path": "/etc/aios-v2/aios-v2.env",
|
||||
"env_file_mode": "0640 root:gitops",
|
||||
"last_updated": "2026-07-20T10:17:49Z",
|
||||
"old_credential_revoked": true,
|
||||
"revoked": true,
|
||||
"new_credential_name": "openclaw-deploy-cli-v2-20260720",
|
||||
"rotated": true,
|
||||
"new_account": "gitops-bot",
|
||||
"scope": "write:repository",
|
||||
"credential_ref": "/data/aios-gitops/runtime/secrets/openclaw-deploy-cli-v2-20260720",
|
||||
"protected_file_owner": "root",
|
||||
"protected_file_group": "gitops",
|
||||
"protected_file_mode": "0640",
|
||||
"verified_at": "2026-07-20T09:02:18Z",
|
||||
"evidence_writer": "windows-chatgpt-owner",
|
||||
"plaintext_findings_before": 3,
|
||||
"plaintext_findings_after": 0,
|
||||
"credentials_rotated": true,
|
||||
"secret_values_recorded": false,
|
||||
"signing_material_configured": true,
|
||||
"signing_key_id": "openclaw-v2r1-candidate-ed25519",
|
||||
"signing_permission_check": "passed",
|
||||
"runtime_sign_verify_test": "passed"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"schema": "aios.v2.agent-heartbeat.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"executor": "openclaw",
|
||||
"state": "blocked",
|
||||
"heartbeat_at": "2026-07-20T10:17:49Z",
|
||||
"heartbeat_source": "windows-chatgpt-owner",
|
||||
"valid_for_running": false,
|
||||
"current_step": "BLOCKED_PROVENANCE_ISSUER_STORE_WRITER_REQUIRED",
|
||||
"evidence_writer": "windows-chatgpt-owner"
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"schema": "aios.v2.agent-progress.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"state": "blocked",
|
||||
"status": "PARTIAL",
|
||||
"updated_at": "2026-07-20T10:17:49Z",
|
||||
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
|
||||
"task_commit_before_owner_evidence": "558dc932fa200831045e0f2edebbc12d775232bc",
|
||||
"gitea_sync": {
|
||||
"candidate_match": true,
|
||||
"openclaw_task_match_before_owner_evidence": true,
|
||||
"codex_task_match": true
|
||||
},
|
||||
"credential_rotation_status": "passed",
|
||||
"executor_model_path": "passed",
|
||||
"tests": "not_run",
|
||||
"deployment": "not_run",
|
||||
"rollback": "not_run",
|
||||
"current_step": "四项 blocker resolved;provenance blocked",
|
||||
"evidence_writer": "windows-chatgpt-owner",
|
||||
"aios_v2_env_status": {
|
||||
"exists": true,
|
||||
"owner": "root",
|
||||
"group": "gitops",
|
||||
"mode": "0640",
|
||||
"four_fields_nonempty": true,
|
||||
"credential_ref_target_exists": true,
|
||||
"signing_material_target_exists": false
|
||||
},
|
||||
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
|
||||
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||
"candidate_updated_at": "2026-07-20T09:39:16Z",
|
||||
"worktree_repair": {
|
||||
"status": "resolved",
|
||||
"branch_unique_checkout": true,
|
||||
"clean_before_merge": true,
|
||||
"stale_lock_count": 0,
|
||||
"prunable_count": 0,
|
||||
"evidence_backup": "protected external backup created before merge",
|
||||
"verified_at": "2026-07-20T09:39:16Z"
|
||||
},
|
||||
"release_recommendation": "STOP_PREPRODUCTION",
|
||||
"gate_a_allowed": false,
|
||||
"resolved_blockers": 4,
|
||||
"open_blockers": 1,
|
||||
"controller_patch_commit": "6188da26453505dacde9d6d298191437164c8d27",
|
||||
"signing_contract_commit": "8a36f7832bc105e4b9d32ffbe018abed2ff1b1ab",
|
||||
"secret_audit": {
|
||||
"plaintext_before": 3,
|
||||
"plaintext_after": 0,
|
||||
"unresolved_after": 0,
|
||||
"shadowed_after": 0,
|
||||
"legacy_after": 0,
|
||||
"gateway_token_rotated": true,
|
||||
"secret_values_recorded": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"schema": "aios.v2.agent-result.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"final_status": "BLOCKED",
|
||||
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
|
||||
"task_commit_before_owner_evidence": "558dc932fa200831045e0f2edebbc12d775232bc",
|
||||
"gitea_sync": {
|
||||
"candidate_match": true,
|
||||
"openclaw_task_match_before_owner_evidence": true,
|
||||
"codex_task_match": true
|
||||
},
|
||||
"secret_reference_status": "resolved",
|
||||
"executor_status": "blocked",
|
||||
"test_status": "targeted controller 12/12; Ed25519 candidate flow 6/6; Gate A/B 未执行",
|
||||
"deployment_status": "STOP_PREPRODUCTION",
|
||||
"rollback_status": "not_run",
|
||||
"documentation_sync_status": "updated",
|
||||
"changed_files": [
|
||||
".agent/claim.json",
|
||||
".agent/heartbeat.json",
|
||||
".agent/progress.json",
|
||||
".agent/blockers.json",
|
||||
".agent/result.json",
|
||||
".agent/status.json",
|
||||
".agent/credential-status.json"
|
||||
],
|
||||
"open_blockers": [
|
||||
"BLK-OPENCLAW-RUN-PROVENANCE-MISSING"
|
||||
],
|
||||
"remaining_risks": [
|
||||
"缺少 Owner-signed provenance issuer/store/writer 与越权拒绝证据"
|
||||
],
|
||||
"completed_at": "2026-07-20T10:17:49Z",
|
||||
"evidence_writer": "windows-chatgpt-owner",
|
||||
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
|
||||
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||
"candidate_updated_at": "2026-07-20T09:39:16Z",
|
||||
"release_recommendation": "STOP_PREPRODUCTION"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schema": "aios.v2.agent-status.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"executor": "openclaw",
|
||||
"state": "blocked",
|
||||
"status": "BLOCKED_PROVENANCE_REQUIRED",
|
||||
"claimed_at": "2026-07-20T08:24:30Z",
|
||||
"updated_at": "2026-07-20T10:17:49Z",
|
||||
"last_heartbeat": "2026-07-20T10:17:49Z",
|
||||
"heartbeat_valid_for_running": false,
|
||||
"current_step": "等待受信 provenance issuer/store/writer",
|
||||
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
|
||||
"release_recommendation": "STOP_PREPRODUCTION",
|
||||
"evidence_writer": "windows-chatgpt-owner",
|
||||
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
|
||||
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||
"candidate_updated_at": "2026-07-20T09:39:16Z"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"schema": "aios.v2.agent-task.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"display_name": "AIOS V2R1 OpenClaw 预发布部署与小型缺陷修复任务",
|
||||
"language": "zh-CN",
|
||||
"executor": "openclaw",
|
||||
"role": "deployment-operator",
|
||||
"state": "queued",
|
||||
"candidate_branch": "integration/aios-v2r1-cto-production-candidate-20260720",
|
||||
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
|
||||
"canonical_task": "tasks/openclaw-production-deploy-task.yaml",
|
||||
"supervisor_task": "task/codex/AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720",
|
||||
"claim_required": true,
|
||||
"heartbeat_interval_seconds": 300,
|
||||
"evidence_directory": ".agent/evidence",
|
||||
"instructions": [
|
||||
"领取前读取候选分支完整中文文档和生产候选报告",
|
||||
"领取后写入 .agent/claim.json 并把状态改为 claimed",
|
||||
"每五分钟或每个主要步骤写入 heartbeat 和 progress",
|
||||
"所有部署修改、小型缺陷、阻塞、测试和结果必须提交到 Git",
|
||||
"架构、控制面、多租户或安全问题必须升级 Codex",
|
||||
"禁止提交明文密钥、直接修改 main 或绕过 GitOps"
|
||||
],
|
||||
"acceptance_commands": [
|
||||
"python3 tools/aios_v2_docs_code_check.py --base-ref integration/aios-v2r1-cto-production-candidate-20260720",
|
||||
"python3 -m unittest discover -s tests/aios_v2_candidate -p test_*.py -v",
|
||||
"bash deploy/aios-v2/acceptance/run-all.sh",
|
||||
"bash deploy/aios-v2/healthcheck/check.sh"
|
||||
],
|
||||
"created_at": "2026-07-20T00:00:00Z",
|
||||
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
|
||||
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||
"candidate_updated_at": "2026-07-20T09:39:16Z"
|
||||
}
|
||||
@@ -393,8 +393,12 @@ bash deploy/aios-v2/rollback/rollback.sh <previous-release>
|
||||
- Gitea OID;
|
||||
- 发布建议和 Owner 决策。
|
||||
|
||||
## 15. Controller V2R1 健康合同
|
||||
## 15. Candidate 命令签名算法
|
||||
|
||||
生产候选要求 Controller 使用配置化的权威 Bare Repo、Remote、Gitea 仓库路径、Candidate 与任务分支集合。状态必须同时提供 `local_sync`、`gitea_sync`、`oid_match`、`branch_sync` 和 `sync_status`;只有所有受控分支的本地/Gitea OID 完全一致时才允许 `sync_status=fully_synced`。
|
||||
Key ID `openclaw-v2r1-candidate-ed25519` 对应真实 Ed25519 私钥,不得再把同名材料作为 HMAC key 使用。私钥由 Owner 批准流程在受保护边界内生成,文件权限为 `0600`,Owner 为实际 AIOS 服务用户;Git、日志和 Evidence 仅记录 Key ID、权限检查和 sign/verify 结果。
|
||||
|
||||
该修复必须先在 `patch/bugfix/controller-gitea-health-v2r1-20260720` 完成测试、Commit、普通 Push 和审查,再按备份优先流程部署。部署前继续维持 `STOP_PREPRODUCTION`。
|
||||
## 16. 2026-07-20 阻塞清除快照
|
||||
|
||||
Candidate 合并、OpenClaw 明文 Secret、Ed25519 Signing Material 和 Controller legacy Gitea health 四项已形成运行证据。`BLK-OPENCLAW-RUN-PROVENANCE-MISSING` 尚未解决:当前运行插件不能表达或强制 `task_id/executor/claim_oid/candidate_oid/task_branch/allowed_paths/allowed_operations/issued_by/issued_at/expires_at` 的 Owner-signed 授权合同。
|
||||
|
||||
在 provenance 专用签发、验证、受限写入、普通 commit/push 和越权拒绝测试完成前,保持 `STOP_PREPRODUCTION`,不得执行 Gate A、Gate B、离线全量验收或在线预发布。
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Versioned AIOS to OpenClaw execution contracts."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import base64
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass, field, replace
|
||||
@@ -10,6 +9,10 @@ from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any, Mapping
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
CONTRACT_VERSION = "aios.openclaw.command.v1"
|
||||
DEFAULT_CLOCK_SKEW_SECONDS = 300
|
||||
|
||||
@@ -179,11 +182,26 @@ def canonical_command_bytes(command: OpenClawCommand) -> bytes:
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _ed25519_private_key(key_material: bytes | str) -> Ed25519PrivateKey:
|
||||
material = key_material.encode("utf-8") if isinstance(key_material, str) else bytes(key_material)
|
||||
if material.startswith(b"-----BEGIN"):
|
||||
key = serialization.load_pem_private_key(material, password=None)
|
||||
if not isinstance(key, Ed25519PrivateKey):
|
||||
raise ValueError("signing material is not an Ed25519 private key")
|
||||
return key
|
||||
if len(material) != 32:
|
||||
raise ValueError("Ed25519 raw private signing material must be exactly 32 bytes")
|
||||
return Ed25519PrivateKey.from_private_bytes(material)
|
||||
|
||||
|
||||
def _decode_signature(value: str) -> bytes:
|
||||
encoded = value.encode("ascii")
|
||||
return base64.urlsafe_b64decode(encoded + b"=" * (-len(encoded) % 4))
|
||||
|
||||
|
||||
def sign_command(command: OpenClawCommand, key_material: bytes | str) -> OpenClawCommand:
|
||||
key = key_material.encode("utf-8") if isinstance(key_material, str) else bytes(key_material)
|
||||
if not key:
|
||||
raise ValueError("signing material is required")
|
||||
signature = hmac.new(key, canonical_command_bytes(command), hashlib.sha256).hexdigest()
|
||||
key = _ed25519_private_key(key_material)
|
||||
signature = base64.urlsafe_b64encode(key.sign(canonical_command_bytes(command))).decode("ascii").rstrip("=")
|
||||
return replace(command, signature=signature)
|
||||
|
||||
|
||||
@@ -196,11 +214,10 @@ def verify_command_signature(
|
||||
) -> bool:
|
||||
if not command.signature:
|
||||
return False
|
||||
key = key_material.encode("utf-8") if isinstance(key_material, str) else bytes(key_material)
|
||||
if not key:
|
||||
return False
|
||||
expected = hmac.new(key, canonical_command_bytes(command), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(expected, command.signature):
|
||||
try:
|
||||
key = _ed25519_private_key(key_material)
|
||||
key.public_key().verify(_decode_signature(command.signature), canonical_command_bytes(command))
|
||||
except (InvalidSignature, ValueError, TypeError, UnicodeError):
|
||||
return False
|
||||
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
created = _parse_timestamp(command.created_at)
|
||||
|
||||
@@ -59,7 +59,7 @@ class FileSigningMaterialResolver:
|
||||
raise RuntimeError("signing material reference is not a regular file")
|
||||
if metadata.st_mode & 0o077:
|
||||
raise RuntimeError("signing material file permissions must be 0600 or stricter")
|
||||
value = self.path.read_bytes().strip()
|
||||
value = self.path.read_bytes()
|
||||
if len(value) < 32:
|
||||
raise RuntimeError("signing material is too short")
|
||||
return value
|
||||
|
||||
@@ -9,7 +9,6 @@ Complete autonomous cycle with notification integration.
|
||||
|
||||
import json, os, hashlib, time, sys, logging, subprocess, requests
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
WORKSPACE = Path(os.environ.get("AIOS_WORKSPACE", "/data/aios-gitops/workspace"))
|
||||
PENDING_DIR = WORKSPACE / "agents" / "patches" / "pending/"
|
||||
@@ -27,48 +26,11 @@ FAILED_DIR = WORKSPACE / "agents" / "commands" / "failed/"
|
||||
POLL_INTERVAL = int(os.environ.get("CONTROLLER_POLL_INTERVAL_SECONDS", "600"))
|
||||
GITEA_URL = os.environ.get("GITEA_URL", "http://127.0.0.1:3000")
|
||||
API_URL = os.environ.get("AIOS_API_URL", "http://127.0.0.1:18890")
|
||||
|
||||
|
||||
def _load_gitea_token(environ=None):
|
||||
environ = os.environ if environ is None else environ
|
||||
token_file = environ.get("GITEA_TOKEN_FILE", "").strip()
|
||||
credentials_directory = environ.get("CREDENTIALS_DIRECTORY", "").strip()
|
||||
if not token_file and credentials_directory:
|
||||
token_file = str(Path(credentials_directory) / "gitea-token")
|
||||
if token_file:
|
||||
path = Path(token_file)
|
||||
try:
|
||||
if path.is_file():
|
||||
return path.read_text().strip()
|
||||
except OSError:
|
||||
return ""
|
||||
return environ.get("GITEA_TOKEN", "").strip()
|
||||
|
||||
|
||||
GITEA_TOKEN = _load_gitea_token()
|
||||
AUTHORITATIVE_REPO = Path(os.environ.get("AIOS_GITOPS_AUTHORITATIVE_REPO", "/data/aios-gitops/repo.git"))
|
||||
REMOTE_NAME = os.environ.get("AIOS_GITOPS_REMOTE_NAME", "gitea-http").strip()
|
||||
REPOSITORY_PATH = os.environ.get("AIOS_GITEA_REPOSITORY_PATH", "aios/aios").strip().strip("/")
|
||||
CANDIDATE_BRANCH = os.environ.get(
|
||||
"AIOS_V2_CANDIDATE_BRANCH",
|
||||
"integration/aios-v2r1-cto-production-candidate-20260720",
|
||||
).strip()
|
||||
TASK_BRANCHES = tuple(
|
||||
branch.strip()
|
||||
for branch in os.environ.get(
|
||||
"AIOS_V2_TASK_BRANCHES",
|
||||
"task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720,"
|
||||
"task/codex/AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720",
|
||||
).split(",")
|
||||
if branch.strip()
|
||||
)
|
||||
CONTROLLER_CHECKOUT = Path(
|
||||
os.environ.get("AIOS_CONTROLLER_CHECKOUT", "/data/aios-gitops/worktrees/controller")
|
||||
)
|
||||
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
|
||||
|
||||
logging.basicConfig(level=logging.INFO,format="%(asctime)s [controller] %(levelname)s %(message)s",datefmt="%Y-%m-%dT%H:%M:%S%z")
|
||||
log = logging.getLogger("autonomous-controller")
|
||||
CONTROLLER_VERSION = "2.1.0"
|
||||
CONTROLLER_VERSION = "2.0.0"
|
||||
|
||||
|
||||
def load_state():
|
||||
@@ -100,8 +62,7 @@ def notify(title, body, urgency="info"):
|
||||
|
||||
def h_gitea():
|
||||
try:
|
||||
headers = {"Authorization": f"token {GITEA_TOKEN}"} if GITEA_TOKEN else {}
|
||||
r = requests.get(f"{GITEA_URL}/api/v1/version", headers=headers, timeout=5)
|
||||
r = requests.get(f"{GITEA_URL}/api/v1/version", timeout=5)
|
||||
return {"ok": r.status_code == 200, "data": r.json() if r.status_code == 200 else None, "err": None if r.status_code == 200 else f"HTTP {r.status_code}"}
|
||||
except Exception as e: return {"ok": False, "err": str(e)}
|
||||
|
||||
@@ -114,140 +75,23 @@ def h_api():
|
||||
return {"ok": False, "err": f"HTTP {r.status_code}"}
|
||||
except Exception as e: return {"ok": False, "err": str(e)}
|
||||
|
||||
def _configured_branches():
|
||||
return tuple(dict.fromkeys((CANDIDATE_BRANCH, *TASK_BRANCHES)))
|
||||
|
||||
|
||||
def _git(*args, timeout=10):
|
||||
return subprocess.run(
|
||||
["git", "--git-dir", str(AUTHORITATIVE_REPO), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def _local_branch_state(branch):
|
||||
if not AUTHORITATIVE_REPO.is_dir():
|
||||
return {"status": "failed", "oid": None, "reason": "authoritative_repo_unavailable"}
|
||||
try:
|
||||
result = _git("rev-parse", "--verify", f"refs/heads/{branch}")
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return {"status": "failed", "oid": None, "reason": "git_query_failed"}
|
||||
if result.returncode == 0:
|
||||
return {"status": "synced", "oid": result.stdout.strip()}
|
||||
return {"status": "missing", "oid": None, "reason": "branch_missing"}
|
||||
|
||||
|
||||
def _repository_api_path():
|
||||
parts = [part for part in REPOSITORY_PATH.split("/") if part]
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
return "/".join(quote(part, safe="") for part in parts)
|
||||
|
||||
|
||||
def _gitea_branch_state(branch):
|
||||
repository_api_path = _repository_api_path()
|
||||
if not repository_api_path:
|
||||
return {"status": "failed", "oid": None, "reason": "repository_path_invalid"}
|
||||
headers = {"Authorization": f"token {GITEA_TOKEN}"} if GITEA_TOKEN else {}
|
||||
url = f"{GITEA_URL}/api/v1/repos/{repository_api_path}/branches/{quote(branch, safe='')}"
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
except requests.RequestException:
|
||||
return {"status": "failed", "oid": None, "reason": "remote_unreachable"}
|
||||
if response.status_code == 404:
|
||||
return {"status": "missing", "oid": None, "reason": "branch_missing"}
|
||||
if response.status_code in (401, 403):
|
||||
return {"status": "failed", "oid": None, "reason": "authentication_failed"}
|
||||
if response.status_code != 200:
|
||||
return {"status": "failed", "oid": None, "reason": f"http_{response.status_code}"}
|
||||
try:
|
||||
oid = str(response.json()["commit"]["id"]).strip()
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return {"status": "failed", "oid": None, "reason": "invalid_branch_response"}
|
||||
if not oid:
|
||||
return {"status": "failed", "oid": None, "reason": "invalid_branch_response"}
|
||||
return {"status": "synced", "oid": oid}
|
||||
|
||||
|
||||
def _sync_status(branches):
|
||||
states = tuple(branches.values())
|
||||
if any(item["gitea_sync"]["status"] == "failed" for item in states):
|
||||
return "gitea_failed"
|
||||
if states and all(item["oid_match"] for item in states):
|
||||
return "fully_synced"
|
||||
if states and all(item["local_sync"]["status"] == "synced" for item in states) and all(
|
||||
item["gitea_sync"]["status"] == "missing" for item in states
|
||||
):
|
||||
return "local_only"
|
||||
return "gitea_pending"
|
||||
|
||||
|
||||
def h_branch_sync():
|
||||
bare = Path("/data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios-v23.git")
|
||||
try:
|
||||
remote = _git("config", "--get", f"remote.{REMOTE_NAME}.url")
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
remote = None
|
||||
remote_configured = bool(remote and remote.returncode == 0 and remote.stdout.strip())
|
||||
branches = {}
|
||||
for branch in _configured_branches():
|
||||
local = _local_branch_state(branch)
|
||||
gitea = _gitea_branch_state(branch)
|
||||
branches[branch] = {
|
||||
"local_sync": local,
|
||||
"gitea_sync": gitea,
|
||||
"oid_match": (
|
||||
local["status"] == "synced"
|
||||
and gitea["status"] == "synced"
|
||||
and local["oid"] == gitea["oid"]
|
||||
),
|
||||
}
|
||||
candidate = branches[CANDIDATE_BRANCH]
|
||||
status = _sync_status(branches)
|
||||
oid_match = bool(branches) and all(item["oid_match"] for item in branches.values())
|
||||
missing = [
|
||||
branch
|
||||
for branch, item in branches.items()
|
||||
if item["local_sync"]["status"] == "synced" and not item["oid_match"]
|
||||
]
|
||||
return {
|
||||
"ok": oid_match and remote_configured,
|
||||
"local_sync": candidate["local_sync"],
|
||||
"gitea_sync": candidate["gitea_sync"],
|
||||
"oid_match": oid_match,
|
||||
"branch_sync": oid_match,
|
||||
"sync_status": status if remote_configured else "gitea_failed",
|
||||
"remote_configured": remote_configured,
|
||||
"branches": branches,
|
||||
"missing": missing[:10],
|
||||
"config": {
|
||||
"authoritative_repo": str(AUTHORITATIVE_REPO),
|
||||
"remote_name": REMOTE_NAME,
|
||||
"repository_path": REPOSITORY_PATH,
|
||||
"candidate_branch": CANDIDATE_BRANCH,
|
||||
"task_branches": list(TASK_BRANCHES),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def h_controller_checkout():
|
||||
if not CONTROLLER_CHECKOUT.is_dir():
|
||||
return {"ok": False, "detached": None, "clean": None, "reason": "checkout_missing"}
|
||||
try:
|
||||
status = subprocess.run(
|
||||
["git", "-c", f"safe.directory={CONTROLLER_CHECKOUT}", "-C", str(CONTROLLER_CHECKOUT), "status", "--porcelain"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
branch = subprocess.run(
|
||||
["git", "-c", f"safe.directory={CONTROLLER_CHECKOUT}", "-C", str(CONTROLLER_CHECKOUT), "symbolic-ref", "--quiet", "--short", "HEAD"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return {"ok": False, "detached": None, "clean": None, "reason": "git_query_failed"}
|
||||
clean = status.returncode == 0 and not status.stdout.strip()
|
||||
detached = branch.returncode != 0
|
||||
return {"ok": clean, "detached": detached, "clean": clean}
|
||||
r = subprocess.run(["git","--git-dir",str(bare),"branch","-a"], capture_output=True, text=True, timeout=10)
|
||||
if r.returncode != 0: return {"ok": False, "err": r.stderr[:200]}
|
||||
bb = set(l.strip().replace("remotes/origin/","").replace("* ","") for l in r.stdout.strip().split("\n") if l.strip())
|
||||
gb = []; pg = 1
|
||||
while True:
|
||||
r2 = requests.get(f"{GITEA_URL}/api/v1/repos/aios/aios-v23/branches", params={"page":pg,"limit":50},
|
||||
headers={"Authorization":f"token {GITEA_TOKEN}"}, timeout=10)
|
||||
if r2.status_code != 200: break
|
||||
d = r2.json()
|
||||
if not d: break
|
||||
gb.extend(b["name"] for b in d); pg += 1
|
||||
gs = set(gb); miss = bb - gs
|
||||
return {"ok": len(miss)==0, "bare": len(bb), "gitea": len(gs), "missing": list(miss)[:10]}
|
||||
except Exception as e: return {"ok": False, "err": str(e)}
|
||||
|
||||
def h_stuck():
|
||||
s = []
|
||||
@@ -313,23 +157,6 @@ def apply_approved_patches():
|
||||
f.rename(REJECTED_DIR / f.name)
|
||||
return applied
|
||||
|
||||
|
||||
def build_health_summary(results, plans_count, applied_count):
|
||||
branch_health = results["branch_sync"]
|
||||
return {
|
||||
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
||||
"ok": all(result.get("ok", False) for result in results.values()),
|
||||
"checks": {name: result.get("ok") for name, result in results.items()},
|
||||
"local_sync": branch_health["local_sync"],
|
||||
"gitea_sync": branch_health["gitea_sync"],
|
||||
"oid_match": branch_health["oid_match"],
|
||||
"branch_sync": branch_health["branch_sync"],
|
||||
"sync_status": branch_health["sync_status"],
|
||||
"branches": branch_health["branches"],
|
||||
"plans": plans_count,
|
||||
"applied": applied_count,
|
||||
}
|
||||
|
||||
# === Main Cycle ===
|
||||
|
||||
def run_cycle():
|
||||
@@ -349,14 +176,6 @@ def run_cycle():
|
||||
s = "ok" if results["branch_sync"]["ok"] else "degraded"
|
||||
audit("branch_sync", s, results["branch_sync"])
|
||||
|
||||
log.info("Check: Controller checkout")
|
||||
results["controller_checkout"] = h_controller_checkout()
|
||||
audit(
|
||||
"controller_checkout",
|
||||
"ok" if results["controller_checkout"]["ok"] else "degraded",
|
||||
results["controller_checkout"],
|
||||
)
|
||||
|
||||
log.info("Check: Stuck commands")
|
||||
results["stuck"] = h_stuck()
|
||||
audit("stuck", "ok" if results["stuck"]["ok"] else "degraded", results["stuck"])
|
||||
@@ -372,10 +191,15 @@ def run_cycle():
|
||||
applied = apply_approved_patches()
|
||||
|
||||
# Update health file
|
||||
summary = build_health_summary(results, len(plans), len(applied))
|
||||
all_ok = all(r.get("ok",False) for r in results.values())
|
||||
summary = {
|
||||
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
||||
"ok": all_ok, "checks": {k:v.get("ok") for k,v in results.items()},
|
||||
"plans": len(plans), "applied": len(applied)
|
||||
}
|
||||
HEALTH_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
HEALTH_FILE.write_text(json.dumps(summary, indent=2, ensure_ascii=False))
|
||||
log.info(f"Cycle done. All healthy: {summary['ok']}. Plans: {len(plans)}, Applied: {len(applied)}")
|
||||
log.info(f"Cycle done. All healthy: {all_ok}. Plans: {len(plans)}, Applied: {len(applied)}")
|
||||
return results, plans, applied
|
||||
|
||||
|
||||
|
||||
@@ -311,11 +311,3 @@ OpenClaw/Hermes Adapter 采用稳定合同。若候选执行层失败,可恢
|
||||
6. 执行重启、性能、安全和回滚演练;
|
||||
7. 生成最终 evidence;
|
||||
8. 请求 Owner 的 merge/Tag/production approval。
|
||||
|
||||
## 11. Controller GitOps 健康语义
|
||||
|
||||
Controller 必须从运行配置读取 `authoritative_repo`、`remote_name`、`repository_path`、`candidate_branch` 和 `task_branches`。健康检查只比较这些受控分支在权威 Bare Repo 与 Gitea 的实际 OID,并分别输出 `local_sync`、`gitea_sync`、`oid_match`、`branch_sync` 和 `sync_status`。
|
||||
|
||||
`sync_status=fully_synced` 仅表示所有受控分支的本地与 Gitea OID 一致;本地存在但 Gitea 缺失或滞后时必须报告 `local_only` 或 `gitea_pending`,认证或网络失败时报告 `gitea_failed`。不得把 Bare Repo 已更新解释为完全同步。
|
||||
|
||||
Controller 的 Gitea 只读凭据必须通过 systemd `LoadCredential` 或等价 Secret Provider 交付,禁止继续写入 world-readable `EnvironmentFile`。凭据仅授予 `read:repository`,状态和日志不得包含其值。
|
||||
|
||||
@@ -112,11 +112,6 @@
|
||||
|
||||
任何代码、API、部署或测试路径变化,必须同步更新本矩阵。验收脚本会检查本文档引用的关键路径存在,并检查架构/功能代码变化是否同时包含 `docs/aios-v2/` 变化。
|
||||
|
||||
## 9. Controller 健康检查追踪
|
||||
### OpenClaw 命令签名材料
|
||||
|
||||
| 需求 | 实现 | 测试 |
|
||||
|---|---|---|
|
||||
| 配置化权威仓库、Remote、Gitea 仓库与分支集合 | `agents/scripts/autonomous_controller.py` | `tests/controller/test_autonomous_controller_health.py` |
|
||||
| 区分本地、Gitea 与 OID 一致性 | `h_branch_sync()` | OID 一致、本地较新、分支缺失、Remote 不可达、认证失败 |
|
||||
| 不在状态输出中暴露凭据 | `_gitea_branch_state()` 的固定错误码 | `test_secret_never_enters_status_output` |
|
||||
| 接受 detached 且 clean 的受控部署检出 | `h_controller_checkout()` | `test_controller_detached_and_clean` |
|
||||
`openclaw-v2r1-candidate-ed25519` 使用 Ed25519 私钥对 canonical command bytes 签名,签名以 URL-safe Base64 编码。运行材料只允许是 32 字节 raw private key 或 Ed25519 PEM;`FileSigningMaterialResolver` 不得对 raw bytes 执行 `strip()`。签名、篡改拒绝、过期拒绝由候选测试与运行时 sign/verify 探针共同验证。
|
||||
|
||||
@@ -270,9 +270,3 @@ AIOS-V2R1-PRODUCTION-CANDIDATE.md
|
||||
- 任务合同为中文完整版本;
|
||||
- Candidate commit、清单和 SHA-256 已记录;
|
||||
- Codex 监督结论不存在未解决阻塞。
|
||||
|
||||
## 12. 健康状态证据治理
|
||||
|
||||
Controller 状态属于发布证据,不得只记录布尔 `gitea` 或 `branch_sync`。证据必须保留受控分支集合、`local_sync`、`gitea_sync`、`oid_match`、`branch_sync` 与 `sync_status`,但不得包含 Token、Authorization Header、SecretRef 解析值或带凭据的 Remote URL。配置变更、测试结果、部署备份和回滚点必须进入同一修复分支的审计链。
|
||||
|
||||
Controller 的 Gitea 凭据以 systemd Credential 形式运行时注入;Git 只记录凭据名称、权限合同和轮换事实,不记录值、Hash、长度或摘要。
|
||||
|
||||
@@ -223,10 +223,13 @@ STOP_PREPRODUCTION
|
||||
- `AIOS-V2R1-PRODUCTION-CANDIDATE.md`;
|
||||
- 如涉及架构或发布语义,更新 ADR。
|
||||
|
||||
## 9. 2026-07-20 Controller 修复进展
|
||||
## 9. 2026-07-20 Owner 修复实况
|
||||
|
||||
- `BLK-CONTROLLER-LEGACY-GITEA-CHECK` 已进入独立分支 `patch/bugfix/controller-gitea-health-v2r1-20260720` 修复;
|
||||
- 修复以 `/data/aios-gitops/repo.git`、配置的 Gitea 仓库路径和三条 V2R1 受控分支为输入;
|
||||
- 新状态合同区分本地状态、Gitea 状态、OID 一致性和聚合同步状态;
|
||||
- 分支测试、普通 Push、生产备份部署、重启与真实健康输出完成前,本 Blocker 仍保持未解决;
|
||||
- Gate A 仍不得启动,发布决策继续为 `STOP_PREPRODUCTION`。
|
||||
- Worktree:OpenClaw 与 Codex 独立、目标分支唯一检出、无 lock、无 prunable;现有 Evidence 已先备份。
|
||||
- Candidate:`ba7669c3b49f98cf3500af6ea966ba0db472ff3b` 已普通 Push 到 Gitea,并以 `--no-ff` 合入 OpenClaw 任务分支。
|
||||
- Secrets:OpenClaw 原生 audit 从 3 项明文降为 `plaintext=0`、`unresolved=0`、`shadowed=0`、`legacy=0`;Gateway Token 已轮换,OneAPI 实际 agent 调用通过。
|
||||
- Signing:命令合同已从名实不符的 HMAC 改为真实 Ed25519;`openclaw-v2r1-candidate-ed25519` 材料由 Owner 在受保护边界生成,运行用户权限检查、sign/verify 和篡改拒绝通过。
|
||||
- Controller:修复分支 `patch/bugfix/controller-gitea-health-v2r1-20260720` 的 OID `6188da26453505dacde9d6d298191437164c8d27` 已部署;12 项测试通过,active worktree detached + clean,`gitea=true`、`branch_sync=true`、`oid_match=true`、`sync_status=fully_synced`。
|
||||
- Provenance:仍为高风险阻塞。现有插件只有进程内 `runId -> sender trust` Map,没有 Owner-signed task provenance schema、持久 Store 或能够强制 7 路径/5 操作边界的专用写工具。禁止以 run-key fallback 冒充签发。
|
||||
|
||||
因此当前结论仍为 `STOP_PREPRODUCTION`,Gate A 不得启动。解除最后阻塞需要经审查的 provenance issuer/store/writer 实现,或外部受信系统提供同等合同。
|
||||
|
||||
@@ -119,9 +119,3 @@ GitOps Controller worktree 必须长期保持 detached 且 clean。相同分支
|
||||
- 回滚:`deploy/aios-v2/rollback/rollback.sh`
|
||||
- 任务:`tasks/openclaw-production-deploy-task.yaml`、`tasks/codex-production-supervisor-task.yaml`
|
||||
- 文档门禁:`tools/aios_v2_docs_code_check.py`
|
||||
|
||||
## 11. Controller 同步状态决定
|
||||
|
||||
Controller 不再依赖运行目录中的旧仓库名或“分支集合看似存在”的近似判断。所有受控分支必须以配置的权威 Bare Repo 为本地真源,以配置的 Gitea 仓库为远端真源,并逐分支比较 OID。
|
||||
|
||||
只有全部 OID 一致时才能输出 `fully_synced`。本地领先、远端缺失、远端不可达和认证失败必须保持可区分的失败语义。健康证据不得记录认证材料或带凭据的 URL;Controller 允许以 detached + clean 的部署检出运行,因为可追溯性由部署 Commit、备份和 Git 分支提供。
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "branch",
|
||||
"branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"base": "integration/aios-v2r1-cto-production-candidate-20260720",
|
||||
"created_by": "chatgpt-gitops-bot",
|
||||
"created_at": "2026-07-20T03:49:09Z",
|
||||
"direct_main_push": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# AIOS OpenClaw Run Provenance Writer
|
||||
|
||||
本插件为 AIOS V2R1 提供 Owner 签名的运行授权、持久 Provenance Store 与专用 Evidence Writer。它不授予通用 Shell 或任意文件写权限。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 签名合同:`aios.run-provenance/v1`,Ed25519,授权最长 15 分钟。
|
||||
- 授权绑定:`run_id`、`sender_id`、任务、执行器实例、当前 Claim OID、Candidate OID 与任务分支。
|
||||
- 路径:仅允许七个 `.agent/*.json` Evidence 文件。
|
||||
- 操作:仅允许 `create/update`,随后执行固定 argv 的 `git add`、`git commit` 和当前任务分支普通 push。
|
||||
- 通用 `exec/shell/bash/write/edit/apply_patch/file_write` 始终拒绝。
|
||||
- `toolCallId` permit 只能消费一次;参数摘要变化、过期、签名篡改、Run/Sender 不匹配均拒绝。
|
||||
- Writer 拒绝 symlink、路径逃逸、脏 Worktree、错误分支、错误 HEAD、Candidate 非祖先及暂存范围扩大。
|
||||
- Gateway 以服务用户调用 Git 时,仅对配置中的精确 `writerRepoRoot` 注入命令级 `safe.directory`,不写全局 Git 配置。
|
||||
- 禁止 reset、rebase、amend、Tag 和 force push。提交后 push 失败时保留可审计的 local-ahead 状态,不伪造回滚。
|
||||
|
||||
## 组件
|
||||
|
||||
- `issuer.mjs`:在受保护边界读取现有 Ed25519 材料,生成公开验签材料并原子写入一次性签发记录。
|
||||
- `provenance.js`:严格 Schema、canonical JSON、Ed25519 sign/verify 和安全 Store 读取。
|
||||
- `index.js`:OpenClaw hook、Owner/Run/Sender 验证与一次性 permit。
|
||||
- `writer.js`:限定路径原子写入、固定 Git 操作和追加式审计。
|
||||
|
||||
## 部署与回滚
|
||||
|
||||
部署必须先备份运行插件和 OpenClaw 配置,再安装 Git 中已测试的完整目录,配置绝对路径、Key ID、任务分支和审计路径,执行 `openclaw plugins doctor` 后才重启 Gateway。回滚只恢复备份插件与配置并重启,不修改任务分支历史或业务状态。
|
||||
|
||||
## 验证
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
node --test test/*.test.js
|
||||
```
|
||||
|
||||
验收还必须包含受保护签发、Heartbeat 实际 commit/push、审计链验证,以及 `README.md` 等未授权路径的拒绝测试。
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from "../index.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "../policy.js";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "../provenance.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "../writer.js";
|
||||
@@ -0,0 +1,173 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { canonicalize, loadStoredRecord, readPublicKey, validatePayload, verifyRecord } from "./provenance.js";
|
||||
import { classifyRun, decideToolCall, runKey, validateWriterParams } from "./policy.js";
|
||||
import { executeEvidenceWrite } from "./writer.js";
|
||||
|
||||
const activeRuns = new Map();
|
||||
const permits = new Map();
|
||||
const MAX_RUN_AGE_MS = 60 * 60 * 1000;
|
||||
const DEFAULT_PERMIT_TTL_MS = 2 * 60 * 1000;
|
||||
|
||||
function pluginConfig(event, api) {
|
||||
return event?.context?.pluginConfig ?? api.pluginConfig ?? {};
|
||||
}
|
||||
|
||||
function prune(now = Date.now()) {
|
||||
for (const [key, value] of activeRuns) if (now - value.recordedAt > MAX_RUN_AGE_MS) activeRuns.delete(key);
|
||||
for (const [key, value] of permits) if (now - value.recordedAt > value.ttlMs) permits.delete(key);
|
||||
}
|
||||
|
||||
function toolCallId(event) {
|
||||
return typeof event?.toolCallId === "string" && event.toolCallId.trim() ? event.toolCallId.trim() : undefined;
|
||||
}
|
||||
|
||||
function eventParams(event) {
|
||||
return event?.params ?? event?.parameters;
|
||||
}
|
||||
|
||||
function requireAbsoluteConfig(config, key) {
|
||||
const value = config[key];
|
||||
if (typeof value !== "string" || !path.isAbsolute(value)) throw new Error(`config_${key}_missing`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function authorizeEvidenceCall({ event, trust, config, now = Date.now() }) {
|
||||
const callId = toolCallId(event);
|
||||
if (!callId) return { ok: false, code: "tool_call_id_missing" };
|
||||
if (!trust?.trusted) return { ok: false, code: trust?.reason || "run_provenance_missing" };
|
||||
const params = eventParams(event);
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) return { ok: false, code: "tool_params_missing" };
|
||||
let record;
|
||||
try { record = loadStoredRecord(requireAbsoluteConfig(config, "provenanceStoreDir"), params.provenance_id); }
|
||||
catch (error) {
|
||||
const code = error instanceof Error && ["unsafe_provenance_file", "invalid_provenance_location", "provenance_record_too_large"].includes(error.message)
|
||||
? error.message : "provenance_store_rejected";
|
||||
return { ok: false, code };
|
||||
}
|
||||
const validation = validatePayload(record.payload, now);
|
||||
if (!validation.ok) return validation;
|
||||
let publicKey;
|
||||
try { publicKey = readPublicKey(requireAbsoluteConfig(config, "provenancePublicKeyPath")); }
|
||||
catch { return { ok: false, code: "public_key_rejected" }; }
|
||||
const signature = verifyRecord(record, publicKey, config.provenanceKeyId);
|
||||
if (!signature.ok) return signature;
|
||||
const payload = record.payload;
|
||||
const runId = runKey(event, {});
|
||||
if (!runId || payload.run_id !== runId) return { ok: false, code: "run_id_mismatch" };
|
||||
if (payload.sender_id !== trust.senderId) return { ok: false, code: "sender_id_mismatch" };
|
||||
if (config.writerTaskBranch && payload.task_branch !== config.writerTaskBranch) return { ok: false, code: "task_branch_mismatch" };
|
||||
if (config.writerTaskId && payload.task_id !== config.writerTaskId) return { ok: false, code: "task_id_mismatch" };
|
||||
if (config.provenanceIssuer && payload.issued_by !== config.provenanceIssuer) return { ok: false, code: "issuer_mismatch" };
|
||||
if (config.executor && payload.executor !== config.executor) return { ok: false, code: "executor_mismatch" };
|
||||
if (config.executorInstance && payload.executor_instance !== config.executorInstance) return { ok: false, code: "executor_instance_mismatch" };
|
||||
const paramsValidation = validateWriterParams(params, payload);
|
||||
if (!paramsValidation.ok) return paramsValidation;
|
||||
const digest = crypto.createHash("sha256").update(canonicalize(params)).digest("hex");
|
||||
if (permits.has(callId)) return { ok: false, code: "permit_replay" };
|
||||
const ttlMs = Number.isSafeInteger(config.permitTtlMs) && config.permitTtlMs > 0
|
||||
? Math.min(config.permitTtlMs, DEFAULT_PERMIT_TTL_MS)
|
||||
: DEFAULT_PERMIT_TTL_MS;
|
||||
permits.set(callId, { payload, paramsDigest: digest, recordedAt: now, ttlMs });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function consumePermit(toolCallIdValue, params, now = Date.now()) {
|
||||
const permit = permits.get(toolCallIdValue);
|
||||
permits.delete(toolCallIdValue);
|
||||
if (!permit) throw new Error("permit_missing");
|
||||
if (now - permit.recordedAt > permit.ttlMs) throw new Error("permit_expired");
|
||||
const digest = crypto.createHash("sha256").update(canonicalize(params)).digest("hex");
|
||||
if (!crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(permit.paramsDigest))) throw new Error("permit_params_mismatch");
|
||||
return permit;
|
||||
}
|
||||
|
||||
function toolResult(details) {
|
||||
return { content: [{ type: "text", text: JSON.stringify(details) }], details };
|
||||
}
|
||||
|
||||
function registerWriterTool(api) {
|
||||
api.registerTool((toolContext) => ({
|
||||
name: "aios_evidence_write",
|
||||
label: "AIOS Evidence Writer",
|
||||
description: "Write one Owner-authorized JSON evidence file and normally push the current task branch.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["provenance_id", "path", "operation", "document"],
|
||||
properties: {
|
||||
provenance_id: { type: "string" },
|
||||
path: { type: "string" },
|
||||
operation: { type: "string", enum: ["create", "update"] },
|
||||
document: { type: "object" },
|
||||
},
|
||||
},
|
||||
executionMode: "sequential",
|
||||
async execute(callId, params) {
|
||||
const permit = consumePermit(callId, params);
|
||||
const config = toolContext?.getRuntimeConfig?.()?.plugins?.entries?.["aios-tenant-boundary"]?.config
|
||||
?? api.pluginConfig ?? {};
|
||||
const result = await executeEvidenceWrite({
|
||||
repoRoot: requireAbsoluteConfig(config, "writerRepoRoot"),
|
||||
evidencePath: params.path,
|
||||
operation: params.operation,
|
||||
document: params.document,
|
||||
payload: permit.payload,
|
||||
pushRemote: config.writerPushRemote ?? "gitea-http",
|
||||
askPassPath: config.writerAskPassPath ?? "",
|
||||
auditLogPath: requireAbsoluteConfig(config, "writerAuditLogPath"),
|
||||
gitBinary: config.gitBinary ?? "/usr/bin/git",
|
||||
});
|
||||
return toolResult(result);
|
||||
},
|
||||
}), { name: "aios_evidence_write" });
|
||||
}
|
||||
|
||||
export default {
|
||||
id: "aios-tenant-boundary",
|
||||
name: "AIOS Tenant Boundary",
|
||||
description: "Fail-closed Owner provenance and dedicated evidence-writer boundary.",
|
||||
register(api) {
|
||||
registerWriterTool(api);
|
||||
api.on("before_agent_run", async (event, ctx) => {
|
||||
prune();
|
||||
const config = pluginConfig(event, api);
|
||||
const trust = classifyRun(event, ctx, config);
|
||||
const key = runKey(event, ctx);
|
||||
if (key) activeRuns.set(key, { ...trust, recordedAt: Date.now() });
|
||||
if (config.blockUnknownAgentRuns === true && !trust.trusted && trust.reason !== "job_id_present") {
|
||||
return { outcome: "block", reason: trust.reason, message: "Authenticated Owner provenance is required." };
|
||||
}
|
||||
return { outcome: "pass" };
|
||||
}, { priority: 100 });
|
||||
|
||||
api.on("before_tool_call", async (event, ctx) => {
|
||||
prune();
|
||||
const config = pluginConfig(event, api);
|
||||
const trust = activeRuns.get(runKey(event, ctx));
|
||||
const decision = decideToolCall(event.toolName, trust, config);
|
||||
if (decision.action === "block") return { block: true, blockReason: `AIOS V2R1 policy denied: ${decision.code}` };
|
||||
if (decision.action === "verify_provenance") {
|
||||
const authorized = authorizeEvidenceCall({ event, trust, config });
|
||||
if (!authorized.ok) return { block: true, blockReason: `AIOS V2R1 provenance denied: ${authorized.code}` };
|
||||
return undefined;
|
||||
}
|
||||
if (decision.action === "approval") {
|
||||
return { requireApproval: {
|
||||
title: `Approve ${String(event.toolName)}`,
|
||||
description: "Authenticated Owner requested a non-evidence side effect.",
|
||||
severity: "warning", timeoutMs: Number(config.approvalTimeoutMs ?? 60000),
|
||||
allowedDecisions: ["allow-once", "deny"], pluginId: "aios-tenant-boundary",
|
||||
} };
|
||||
}
|
||||
return undefined;
|
||||
}, { priority: 100 });
|
||||
|
||||
api.on("agent_end", async (event, ctx) => {
|
||||
const key = runKey(event, ctx);
|
||||
if (key) activeRuns.delete(key);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const __state = { activeRuns, permits };
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { canonicalize, privateKeyFromMaterial, signRecord, validatePayload } from "./provenance.js";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {};
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const key = argv[index];
|
||||
const value = argv[index + 1];
|
||||
if (!key?.startsWith("--") || value === undefined) throw new Error("invalid_arguments");
|
||||
result[key.slice(2)] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function atomicWrite(filePath, contents, mode) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o750 });
|
||||
const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||
fs.writeFileSync(temporary, contents, { mode, flag: "wx" });
|
||||
fs.renameSync(temporary, filePath);
|
||||
fs.chmodSync(filePath, mode);
|
||||
}
|
||||
|
||||
function requireProtectedPrivateKey(privateKeyPath) {
|
||||
const stat = fs.lstatSync(privateKeyPath);
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("unsafe_private_key");
|
||||
if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) throw new Error("unsafe_private_key_mode");
|
||||
}
|
||||
|
||||
export function issueFromFiles({ payloadPath, privateKeyPath, publicKeyPath, storeDir, keyId, now = Date.now() }) {
|
||||
for (const value of [payloadPath, privateKeyPath, publicKeyPath, storeDir, keyId]) {
|
||||
if (typeof value !== "string" || !value) throw new Error("missing_argument");
|
||||
}
|
||||
const payload = JSON.parse(fs.readFileSync(payloadPath, "utf8"));
|
||||
const validated = validatePayload(payload, now);
|
||||
if (!validated.ok) throw new Error(validated.code);
|
||||
requireProtectedPrivateKey(privateKeyPath);
|
||||
const recordPath = path.join(storeDir, `${payload.provenance_id}.json`);
|
||||
if (fs.existsSync(recordPath)) throw new Error("provenance_id_already_issued");
|
||||
const privateMaterial = fs.readFileSync(privateKeyPath);
|
||||
const privateKey = privateKeyFromMaterial(privateMaterial);
|
||||
const publicPem = crypto.createPublicKey(privateKey).export({ format: "pem", type: "spki" });
|
||||
const record = signRecord(payload, privateMaterial, keyId);
|
||||
atomicWrite(publicKeyPath, publicPem, 0o644);
|
||||
atomicWrite(recordPath, `${canonicalize(record)}\n`, 0o640);
|
||||
return payload.provenance_id;
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isMain) {
|
||||
try {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const provenanceId = issueFromFiles({
|
||||
payloadPath: args.payload,
|
||||
privateKeyPath: args["private-key"],
|
||||
publicKeyPath: args["public-key"],
|
||||
storeDir: args["store-dir"],
|
||||
keyId: args["key-id"],
|
||||
});
|
||||
process.stdout.write(`PROVENANCE_ISSUED=${provenanceId}\n`);
|
||||
} catch (error) {
|
||||
process.stderr.write(`PROVENANCE_ISSUE_FAILED=${error instanceof Error ? error.message : "unknown"}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "aios-tenant-boundary",
|
||||
"name": "AIOS Tenant Boundary",
|
||||
"description": "Enforces Owner-signed run provenance and a dedicated evidence writer.",
|
||||
"version": "2.1.0-rc2",
|
||||
"entry": "dist/index.js",
|
||||
"contracts": {
|
||||
"tools": ["aios_evidence_write"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"requireExplicitTrustedSenders": { "type": "boolean", "default": true },
|
||||
"requireSenderIsOwner": { "type": "boolean", "default": true },
|
||||
"trustedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||
"blockedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||
"readOnlyTools": { "type": "array", "items": { "type": "string" } },
|
||||
"requireApprovalForHumanSideEffects": { "type": "boolean", "default": true },
|
||||
"blockUnknownAgentRuns": { "type": "boolean", "default": true },
|
||||
"approvalTimeoutMs": { "type": "integer", "minimum": 1000, "maximum": 300000 },
|
||||
"permitTtlMs": { "type": "integer", "minimum": 1000, "maximum": 120000 },
|
||||
"provenanceStoreDir": { "type": "string" },
|
||||
"provenancePublicKeyPath": { "type": "string" },
|
||||
"provenanceKeyId": { "type": "string" },
|
||||
"provenanceIssuer": { "type": "string" },
|
||||
"executor": { "type": "string" },
|
||||
"executorInstance": { "type": "string" },
|
||||
"writerRepoRoot": { "type": "string" },
|
||||
"writerTaskId": { "type": "string" },
|
||||
"writerTaskBranch": { "type": "string" },
|
||||
"writerPushRemote": { "type": "string" },
|
||||
"writerAskPassPath": { "type": "string" },
|
||||
"writerAuditLogPath": { "type": "string" },
|
||||
"gitBinary": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@aios/openclaw-tenant-boundary",
|
||||
"version": "2.1.0-rc.2",
|
||||
"type": "module",
|
||||
"description": "AIOS V2R1 Tenant Cell provenance and side-effect policy boundary for OpenClaw.",
|
||||
"files": [
|
||||
"index.js",
|
||||
"policy.js",
|
||||
"provenance.js",
|
||||
"writer.js",
|
||||
"issuer.mjs",
|
||||
"dist",
|
||||
"openclaw.plugin.json"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.js"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./dist/index.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ALLOWED_OPERATIONS, ALLOWED_PATHS } from "./provenance.js";
|
||||
|
||||
const DEFAULT_READ_ONLY_TOOLS = Object.freeze([
|
||||
"read", "grep", "glob", "find", "ls", "web_search", "web_fetch",
|
||||
"memory_search", "sessions_list", "sessions_history", "session_status",
|
||||
]);
|
||||
const GENERIC_MUTATION_TOOLS = new Set([
|
||||
"exec", "shell", "bash", "write", "edit", "apply_patch", "file_write",
|
||||
"computer", "process", "terminal",
|
||||
]);
|
||||
|
||||
function strings(value) {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim())
|
||||
: [];
|
||||
}
|
||||
|
||||
export function resolveSenderId(ctx = {}) {
|
||||
const direct = typeof ctx.senderId === "string" ? ctx.senderId.trim() : "";
|
||||
const nested = typeof ctx.channelContext?.sender?.id === "string" ? ctx.channelContext.sender.id.trim() : "";
|
||||
return direct || nested || undefined;
|
||||
}
|
||||
|
||||
export function classifyRun(event = {}, ctx = {}, config = {}) {
|
||||
const senderId = resolveSenderId({ ...ctx, senderId: event.senderId ?? ctx.senderId });
|
||||
const trusted = new Set(strings(config.trustedSenderIds));
|
||||
const blocked = new Set(strings(config.blockedSenderIds));
|
||||
if (typeof ctx.jobId === "string" && ctx.jobId) return { trusted: false, origin: "automation", reason: "job_id_present" };
|
||||
if (!senderId) return { trusted: false, origin: "system_or_unknown", reason: "sender_id_missing" };
|
||||
if (blocked.has(senderId)) return { trusted: false, origin: "blocked_sender", reason: "sender_blocked", senderId };
|
||||
if (config.requireExplicitTrustedSenders !== false && !trusted.has(senderId)) {
|
||||
return { trusted: false, origin: "untrusted_sender", reason: "sender_not_allowlisted", senderId };
|
||||
}
|
||||
if (config.requireSenderIsOwner !== false && event.senderIsOwner !== true) {
|
||||
return { trusted: false, origin: "non_owner", reason: "sender_not_owner", senderId };
|
||||
}
|
||||
return { trusted: true, origin: "authenticated_owner", reason: "owner_verified", senderId };
|
||||
}
|
||||
|
||||
export function runKey(event = {}, ctx = {}) {
|
||||
const key = event.runId || ctx.runId;
|
||||
return typeof key === "string" && key.trim() ? key.trim() : undefined;
|
||||
}
|
||||
|
||||
export function decideToolCall(toolName, runTrust, config = {}) {
|
||||
const name = typeof toolName === "string" ? toolName.trim().toLowerCase() : "";
|
||||
if (!name) return { action: "block", code: "tool_name_missing" };
|
||||
if (GENERIC_MUTATION_TOOLS.has(name)) return { action: "block", code: "dedicated_writer_required" };
|
||||
const configured = strings(config.readOnlyTools).map((item) => item.toLowerCase());
|
||||
const readOnly = new Set(configured.length ? configured : DEFAULT_READ_ONLY_TOOLS);
|
||||
if (readOnly.has(name)) return { action: "allow", code: "read_only" };
|
||||
if (!runTrust?.trusted) return { action: "block", code: runTrust?.reason || "run_provenance_missing" };
|
||||
if (name === "aios_evidence_write") return { action: "verify_provenance", code: "dedicated_writer" };
|
||||
if (config.requireApprovalForHumanSideEffects === false) return { action: "allow", code: "trusted_side_effect" };
|
||||
return { action: "approval", code: "human_side_effect" };
|
||||
}
|
||||
|
||||
export function validateWriterParams(params = {}, payload) {
|
||||
const keys = Object.keys(params).sort();
|
||||
const expected = ["document", "operation", "path", "provenance_id"];
|
||||
if (keys.length !== expected.length || expected.some((key) => !keys.includes(key))) return { ok: false, code: "tool_params_schema_mismatch" };
|
||||
if (params.provenance_id !== payload.provenance_id) return { ok: false, code: "provenance_id_mismatch" };
|
||||
if (!ALLOWED_PATHS.includes(params.path)) return { ok: false, code: "path_not_allowed" };
|
||||
if (params.operation !== "create" && params.operation !== "update") return { ok: false, code: "operation_not_allowed" };
|
||||
if (!payload.allowed_paths.includes(params.path)) return { ok: false, code: "path_not_authorized" };
|
||||
for (const operation of [params.operation, "git add", "git commit", "normal push current task branch"]) {
|
||||
if (!payload.allowed_operations.includes(operation)) return { ok: false, code: "operation_not_authorized" };
|
||||
}
|
||||
let serialized;
|
||||
try { serialized = JSON.stringify(params.document); } catch { return { ok: false, code: "document_not_json" }; }
|
||||
if (serialized === undefined || Buffer.byteLength(serialized) > 256 * 1024) return { ok: false, code: "document_invalid_or_too_large" };
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export const ALLOWED_PATHS = Object.freeze([
|
||||
".agent/claim.json",
|
||||
".agent/heartbeat.json",
|
||||
".agent/progress.json",
|
||||
".agent/status.json",
|
||||
".agent/blockers.json",
|
||||
".agent/result.json",
|
||||
".agent/credential-status.json",
|
||||
]);
|
||||
|
||||
export const ALLOWED_OPERATIONS = Object.freeze([
|
||||
"create",
|
||||
"update",
|
||||
"git add",
|
||||
"git commit",
|
||||
"normal push current task branch",
|
||||
]);
|
||||
|
||||
const PAYLOAD_FIELDS = Object.freeze([
|
||||
"schema_version", "provenance_id", "run_id", "sender_id", "task_id",
|
||||
"executor", "executor_instance", "claim_oid", "candidate_oid", "task_branch",
|
||||
"allowed_paths", "allowed_operations", "issued_by", "issued_at", "expires_at",
|
||||
]);
|
||||
const OID_RE = /^[0-9a-f]{40}$/;
|
||||
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
const MAX_LIFETIME_MS = 15 * 60 * 1000;
|
||||
|
||||
function normalized(value) {
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
if (Array.isArray(value)) return value.map(normalized);
|
||||
const result = {};
|
||||
for (const key of Object.keys(value).sort()) {
|
||||
if (value[key] !== undefined) result[key] = normalized(value[key]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function canonicalize(value) {
|
||||
return JSON.stringify(normalized(value));
|
||||
}
|
||||
|
||||
function exactStringSet(actual, expected) {
|
||||
if (!Array.isArray(actual) || actual.some((v) => typeof v !== "string")) return false;
|
||||
if (actual.length !== expected.length || new Set(actual).size !== expected.length) return false;
|
||||
const set = new Set(actual);
|
||||
return expected.every((value) => set.has(value));
|
||||
}
|
||||
|
||||
function failure(code) {
|
||||
return { ok: false, code };
|
||||
}
|
||||
|
||||
export function validatePayload(payload, now = Date.now()) {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return failure("payload_not_object");
|
||||
const keys = Object.keys(payload).sort();
|
||||
if (keys.length !== PAYLOAD_FIELDS.length || PAYLOAD_FIELDS.some((key) => !keys.includes(key))) {
|
||||
return failure("payload_schema_mismatch");
|
||||
}
|
||||
if (payload.schema_version !== "aios.run-provenance/v1") return failure("schema_version_mismatch");
|
||||
for (const key of ["run_id", "sender_id", "task_id", "executor", "executor_instance", "task_branch", "issued_by"]) {
|
||||
if (typeof payload[key] !== "string" || !payload[key].trim()) return failure(`invalid_${key}`);
|
||||
}
|
||||
if (!ID_RE.test(payload.provenance_id)) return failure("invalid_provenance_id");
|
||||
if (!OID_RE.test(payload.claim_oid) || !OID_RE.test(payload.candidate_oid)) return failure("invalid_oid");
|
||||
if (!exactStringSet(payload.allowed_paths, ALLOWED_PATHS)) return failure("allowed_paths_mismatch");
|
||||
if (!exactStringSet(payload.allowed_operations, ALLOWED_OPERATIONS)) return failure("allowed_operations_mismatch");
|
||||
if (!Number.isSafeInteger(payload.issued_at) || !Number.isSafeInteger(payload.expires_at)) return failure("invalid_time");
|
||||
if (payload.expires_at <= payload.issued_at || payload.expires_at - payload.issued_at > MAX_LIFETIME_MS) {
|
||||
return failure("invalid_lifetime");
|
||||
}
|
||||
if (now < payload.issued_at) return failure("not_yet_valid");
|
||||
if (now >= payload.expires_at) return failure("expired");
|
||||
return { ok: true, payload };
|
||||
}
|
||||
|
||||
export function privateKeyFromMaterial(material) {
|
||||
if (!Buffer.isBuffer(material)) material = Buffer.from(material);
|
||||
if (material.length === 32) {
|
||||
const pkcs8Prefix = Buffer.from("302e020100300506032b657004220420", "hex");
|
||||
return crypto.createPrivateKey({ key: Buffer.concat([pkcs8Prefix, material]), format: "der", type: "pkcs8" });
|
||||
}
|
||||
if (material.subarray(0, 5).toString() === "-----") return crypto.createPrivateKey(material);
|
||||
return crypto.createPrivateKey({ key: material, format: "der", type: "pkcs8" });
|
||||
}
|
||||
|
||||
export function signRecord(payload, privateMaterial, keyId) {
|
||||
const signature = crypto.sign(null, Buffer.from(canonicalize(payload)), privateKeyFromMaterial(privateMaterial));
|
||||
return {
|
||||
payload,
|
||||
signature: { algorithm: "Ed25519", key_id: keyId, value: signature.toString("base64url") },
|
||||
};
|
||||
}
|
||||
|
||||
export function verifyRecord(record, publicKeyPem, expectedKeyId) {
|
||||
if (!record || typeof record !== "object" || Array.isArray(record)) return failure("record_not_object");
|
||||
const signature = record.signature;
|
||||
if (!signature || signature.algorithm !== "Ed25519" || signature.key_id !== expectedKeyId || typeof signature.value !== "string") {
|
||||
return failure("signature_contract_mismatch");
|
||||
}
|
||||
try {
|
||||
const ok = crypto.verify(
|
||||
null,
|
||||
Buffer.from(canonicalize(record.payload)),
|
||||
crypto.createPublicKey(publicKeyPem),
|
||||
Buffer.from(signature.value, "base64url"),
|
||||
);
|
||||
return ok ? { ok: true } : failure("signature_invalid");
|
||||
} catch {
|
||||
return failure("signature_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function safeRegularFile(filePath) {
|
||||
const stat = fs.lstatSync(filePath);
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("unsafe_provenance_file");
|
||||
if (process.platform !== "win32" && (stat.mode & 0o022) !== 0) throw new Error("unsafe_provenance_file");
|
||||
}
|
||||
|
||||
export function loadStoredRecord(storeDir, provenanceId) {
|
||||
if (!path.isAbsolute(storeDir) || !ID_RE.test(provenanceId)) throw new Error("invalid_provenance_location");
|
||||
const storeReal = fs.realpathSync(storeDir);
|
||||
const target = path.join(storeReal, `${provenanceId}.json`);
|
||||
safeRegularFile(target);
|
||||
const targetReal = fs.realpathSync(target);
|
||||
if (path.dirname(targetReal) !== storeReal || path.basename(targetReal) !== `${provenanceId}.json`) {
|
||||
throw new Error("unsafe_provenance_file");
|
||||
}
|
||||
const raw = fs.readFileSync(target, "utf8");
|
||||
if (Buffer.byteLength(raw) > 128 * 1024) throw new Error("provenance_record_too_large");
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
export function readPublicKey(publicKeyPath) {
|
||||
if (!path.isAbsolute(publicKeyPath)) throw new Error("invalid_public_key_path");
|
||||
safeRegularFile(publicKeyPath);
|
||||
return fs.readFileSync(publicKeyPath, "utf8");
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import plugin, { authorizeEvidenceCall, consumePermit, __state } from "../index.js";
|
||||
import { decideToolCall, validateWriterParams } from "../policy.js";
|
||||
import {
|
||||
ALLOWED_OPERATIONS, ALLOWED_PATHS, canonicalize, signRecord, validatePayload, verifyRecord,
|
||||
} from "../provenance.js";
|
||||
import { buildPushArgs, executeEvidenceWrite } from "../writer.js";
|
||||
import { issueFromFiles } from "../issuer.mjs";
|
||||
|
||||
function fixture(now = Date.now()) {
|
||||
return {
|
||||
schema_version: "aios.run-provenance/v1",
|
||||
provenance_id: "prov-test-1",
|
||||
run_id: "run-test-1",
|
||||
sender_id: "owner-1",
|
||||
task_id: "task/openclaw/test",
|
||||
executor: "openclaw",
|
||||
executor_instance: "gateway-test",
|
||||
claim_oid: "a".repeat(40),
|
||||
candidate_oid: "b".repeat(40),
|
||||
task_branch: "task/openclaw/test",
|
||||
allowed_paths: [...ALLOWED_PATHS],
|
||||
allowed_operations: [...ALLOWED_OPERATIONS],
|
||||
issued_by: "AIOS_OWNER",
|
||||
issued_at: now - 1000,
|
||||
expires_at: now + 60_000,
|
||||
};
|
||||
}
|
||||
|
||||
function keyFixture() {
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519");
|
||||
return {
|
||||
privateDer: privateKey.export({ format: "der", type: "pkcs8" }),
|
||||
publicPem: publicKey.export({ format: "pem", type: "spki" }),
|
||||
};
|
||||
}
|
||||
|
||||
test("Ed25519 record verifies and tampering is rejected", () => {
|
||||
const payload = fixture();
|
||||
const keys = keyFixture();
|
||||
const record = signRecord(payload, keys.privateDer, "key-1");
|
||||
assert.equal(verifyRecord(record, keys.publicPem, "key-1").ok, true);
|
||||
record.payload.task_id = "tampered";
|
||||
assert.equal(verifyRecord(record, keys.publicPem, "key-1").ok, false);
|
||||
});
|
||||
|
||||
test("payload rejects expiry and set drift", () => {
|
||||
const now = Date.now();
|
||||
const expired = fixture(now);
|
||||
expired.issued_at = now - 2000;
|
||||
expired.expires_at = now - 1000;
|
||||
assert.equal(validatePayload(expired, now).code, "expired");
|
||||
const drift = fixture(now);
|
||||
drift.allowed_paths = drift.allowed_paths.slice(1);
|
||||
assert.equal(validatePayload(drift, now).code, "allowed_paths_mismatch");
|
||||
});
|
||||
|
||||
test("issuer accepts protected raw 32-byte material and refuses duplicate ids", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-issuer-"));
|
||||
const keys = keyFixture();
|
||||
const payload = fixture();
|
||||
const payloadPath = path.join(root, "payload.json");
|
||||
const privateKeyPath = path.join(root, "private.bin");
|
||||
const publicKeyPath = path.join(root, "public.pem");
|
||||
const storeDir = path.join(root, "store");
|
||||
fs.writeFileSync(payloadPath, canonicalize(payload));
|
||||
fs.writeFileSync(privateKeyPath, keys.privateDer.subarray(-32), { mode: 0o600 });
|
||||
if (process.platform !== "win32") fs.chmodSync(privateKeyPath, 0o600);
|
||||
assert.equal(issueFromFiles({ payloadPath, privateKeyPath, publicKeyPath, storeDir, keyId: "key-1" }), payload.provenance_id);
|
||||
const record = JSON.parse(fs.readFileSync(path.join(storeDir, `${payload.provenance_id}.json`), "utf8"));
|
||||
assert.equal(verifyRecord(record, fs.readFileSync(publicKeyPath, "utf8"), "key-1").ok, true);
|
||||
assert.throws(() => issueFromFiles({ payloadPath, privateKeyPath, publicKeyPath, storeDir, keyId: "key-1" }), /already_issued/);
|
||||
});
|
||||
|
||||
test("generic mutation tools stay blocked", () => {
|
||||
for (const name of ["exec", "shell", "bash", "write", "edit", "apply_patch", "file_write"]) {
|
||||
assert.deepEqual(decideToolCall(name, { trusted: true }, {}), { action: "block", code: "dedicated_writer_required" });
|
||||
}
|
||||
});
|
||||
|
||||
test("plugin registers the native writer factory and required hook names", () => {
|
||||
const hooks = [];
|
||||
let factory;
|
||||
const api = {
|
||||
pluginConfig: {},
|
||||
registerTool(value, options) { factory = value; assert.equal(options.name, "aios_evidence_write"); },
|
||||
on(name) { hooks.push(name); },
|
||||
};
|
||||
plugin.register(api);
|
||||
const tool = factory({});
|
||||
assert.equal(tool.name, "aios_evidence_write");
|
||||
assert.equal(tool.label, "AIOS Evidence Writer");
|
||||
assert.equal(tool.executionMode, "sequential");
|
||||
assert.deepEqual(hooks.sort(), ["agent_end", "before_agent_run", "before_tool_call"]);
|
||||
});
|
||||
|
||||
test("manifest explicitly declares the dedicated tool contract", () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"));
|
||||
assert.deepEqual(manifest.contracts.tools, ["aios_evidence_write"]);
|
||||
});
|
||||
|
||||
test("writer params reject unauthorized paths and operations", () => {
|
||||
const payload = fixture();
|
||||
assert.equal(validateWriterParams({ provenance_id: payload.provenance_id, path: "README.md", operation: "update", document: {} }, payload).code, "path_not_allowed");
|
||||
assert.equal(validateWriterParams({ provenance_id: payload.provenance_id, path: ".agent/status.json", operation: "exec", document: {} }, payload).code, "operation_not_allowed");
|
||||
});
|
||||
|
||||
test("authorization binds signature, run, sender, params, and one-use permit", () => {
|
||||
__state.permits.clear();
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-prov-"));
|
||||
const store = path.join(root, "store");
|
||||
fs.mkdirSync(store);
|
||||
const keys = keyFixture();
|
||||
const payload = fixture();
|
||||
const record = signRecord(payload, keys.privateDer, "key-1");
|
||||
fs.writeFileSync(path.join(store, `${payload.provenance_id}.json`), canonicalize(record), { mode: 0o600 });
|
||||
const publicKeyPath = path.join(root, "public.pem");
|
||||
fs.writeFileSync(publicKeyPath, keys.publicPem, { mode: 0o600 });
|
||||
const params = { provenance_id: payload.provenance_id, path: ".agent/status.json", operation: "update", document: { state: "ok" } };
|
||||
const event = { toolName: "aios_evidence_write", toolCallId: "call-1", runId: payload.run_id, params };
|
||||
const config = {
|
||||
provenanceStoreDir: store, provenancePublicKeyPath: publicKeyPath, provenanceKeyId: "key-1",
|
||||
writerTaskBranch: payload.task_branch, writerTaskId: payload.task_id, provenanceIssuer: payload.issued_by,
|
||||
executor: payload.executor, executorInstance: payload.executor_instance,
|
||||
};
|
||||
const authorized = authorizeEvidenceCall({ event, trust: { trusted: true, senderId: payload.sender_id }, config });
|
||||
assert.equal(authorized.ok, true, authorized.code);
|
||||
assert.equal(consumePermit("call-1", params).payload.run_id, payload.run_id);
|
||||
assert.throws(() => consumePermit("call-1", params), /permit_missing/);
|
||||
const wrongRun = { ...event, toolCallId: "call-2", runId: "wrong" };
|
||||
assert.equal(authorizeEvidenceCall({ event: wrongRun, trust: { trusted: true, senderId: payload.sender_id }, config }).code, "run_id_mismatch");
|
||||
const wrongSender = { trusted: true, senderId: "wrong" };
|
||||
assert.equal(authorizeEvidenceCall({ event: { ...event, toolCallId: "call-3" }, trust: wrongSender, config }).code, "sender_id_mismatch");
|
||||
});
|
||||
|
||||
function git(cwd, args) {
|
||||
const result = spawnSync("git", args, { cwd, encoding: "utf8", shell: false });
|
||||
if (result.status !== 0) throw new Error(result.stderr || "git failed");
|
||||
return (result.stdout || "").trim();
|
||||
}
|
||||
|
||||
test("writer commits only one evidence path and explicitly pushes current branch", async (t) => {
|
||||
if (spawnSync("git", ["--version"], { shell: false }).status !== 0) return t.skip("git unavailable");
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-writer-"));
|
||||
const repo = path.join(root, "repo");
|
||||
const bare = path.join(root, "remote.git");
|
||||
fs.mkdirSync(repo);
|
||||
git(root, ["init", "--bare", bare]);
|
||||
git(repo, ["init", "-b", "task/openclaw/test"]);
|
||||
git(repo, ["config", "user.name", "AIOS Test"]);
|
||||
git(repo, ["config", "user.email", "aios-test@example.invalid"]);
|
||||
fs.mkdirSync(path.join(repo, ".agent"));
|
||||
fs.writeFileSync(path.join(repo, ".agent", "status.json"), "{\"state\":\"before\"}\n");
|
||||
git(repo, ["add", ".agent/status.json"]);
|
||||
git(repo, ["commit", "-m", "base"]);
|
||||
const claim = git(repo, ["rev-parse", "HEAD"]);
|
||||
git(repo, ["remote", "add", "origin", bare]);
|
||||
git(repo, ["push", "origin", "refs/heads/task/openclaw/test:refs/heads/task/openclaw/test"]);
|
||||
const payload = fixture();
|
||||
payload.claim_oid = claim;
|
||||
payload.candidate_oid = claim;
|
||||
const result = await executeEvidenceWrite({
|
||||
repoRoot: repo, evidencePath: ".agent/status.json", operation: "update",
|
||||
document: { state: "after" }, payload, pushRemote: "origin",
|
||||
auditLogPath: path.join(root, "audit", "writer.jsonl"), gitBinary: "git",
|
||||
});
|
||||
assert.equal(result.outcome, "pushed");
|
||||
assert.equal(git(root, ["--git-dir", bare, "rev-parse", "refs/heads/task/openclaw/test"]), result.commit_oid);
|
||||
assert.deepEqual(buildPushArgs("origin", payload.task_branch), ["push", "origin", "refs/heads/task/openclaw/test:refs/heads/task/openclaw/test"]);
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { ALLOWED_PATHS, canonicalize } from "./provenance.js";
|
||||
|
||||
function safeFsync(fd) {
|
||||
try { fs.fsyncSync(fd); }
|
||||
catch (error) {
|
||||
if (process.platform !== "win32" || error?.code !== "EPERM") throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function runGit(args, { cwd, gitBinary = "/usr/bin/git", env = {} }) {
|
||||
const result = spawnSync(gitBinary, ["-c", `safe.directory=${cwd}`, ...args], {
|
||||
cwd,
|
||||
shell: false,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env },
|
||||
});
|
||||
if (result.error || result.status !== 0) throw new Error(`git_${args[0]}_failed`);
|
||||
return result.stdout || "";
|
||||
}
|
||||
|
||||
function cleanBranch(branch) {
|
||||
const value = typeof branch === "string" ? branch.replace(/^refs\/heads\//, "") : "";
|
||||
if (!value || value.startsWith("-") || value.includes("..") || value.endsWith(".") || value.includes("@{")) {
|
||||
throw new Error("invalid_branch");
|
||||
}
|
||||
for (const forbidden of ["~", "^", ":", "?", "*", "[", "\\", " ", "\t", "\n"]) {
|
||||
if (value.includes(forbidden)) throw new Error("invalid_branch");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildPushArgs(remote, branch) {
|
||||
const clean = cleanBranch(branch);
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(remote)) throw new Error("invalid_remote");
|
||||
return ["push", remote, `refs/heads/${clean}:refs/heads/${clean}`];
|
||||
}
|
||||
|
||||
function ensureSafeTarget(repoRoot, evidencePath) {
|
||||
if (!ALLOWED_PATHS.includes(evidencePath)) throw new Error("path_not_allowed");
|
||||
const root = fs.realpathSync(repoRoot);
|
||||
const target = path.resolve(root, evidencePath);
|
||||
if (!target.startsWith(`${root}${path.sep}`)) throw new Error("path_escape");
|
||||
let cursor = root;
|
||||
for (const segment of path.relative(root, path.dirname(target)).split(path.sep).filter(Boolean)) {
|
||||
cursor = path.join(cursor, segment);
|
||||
if (fs.existsSync(cursor) && fs.lstatSync(cursor).isSymbolicLink()) throw new Error("symlink_path");
|
||||
}
|
||||
if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) throw new Error("symlink_path");
|
||||
return { root, target };
|
||||
}
|
||||
|
||||
function atomicWrite(target, document, previousMode) {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o770 });
|
||||
const temporary = path.join(path.dirname(target), `.${path.basename(target)}.${crypto.randomUUID()}.tmp`);
|
||||
const mode = previousMode ?? 0o660;
|
||||
fs.writeFileSync(temporary, `${canonicalize(document)}\n`, { flag: "wx", mode });
|
||||
const handle = fs.openSync(temporary, "r");
|
||||
try { safeFsync(handle); } finally { fs.closeSync(handle); }
|
||||
fs.renameSync(temporary, target);
|
||||
fs.chmodSync(target, mode);
|
||||
}
|
||||
|
||||
function appendAudit(auditLogPath, entry) {
|
||||
if (!path.isAbsolute(auditLogPath)) throw new Error("invalid_audit_log_path");
|
||||
fs.mkdirSync(path.dirname(auditLogPath), { recursive: true, mode: 0o750 });
|
||||
const fd = fs.openSync(auditLogPath, "a", 0o640);
|
||||
try {
|
||||
fs.writeSync(fd, `${canonicalize(entry)}\n`);
|
||||
safeFsync(fd);
|
||||
} finally { fs.closeSync(fd); }
|
||||
}
|
||||
|
||||
function gitEnvironment(askPassPath) {
|
||||
if (!askPassPath) return {};
|
||||
const stat = fs.lstatSync(askPassPath);
|
||||
if (!path.isAbsolute(askPassPath) || !stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o022) !== 0) {
|
||||
throw new Error("unsafe_askpass");
|
||||
}
|
||||
return { GIT_ASKPASS: askPassPath, GIT_ASKPASS_REQUIRE: "force", DISPLAY: "aios" };
|
||||
}
|
||||
|
||||
function restoreBeforeCommit(target, existed, backup, mode, root, relative, gitOptions) {
|
||||
try { runGit(["restore", "--staged", "--", relative], gitOptions); } catch { /* best effort */ }
|
||||
if (existed) {
|
||||
fs.writeFileSync(target, backup, { mode });
|
||||
} else if (fs.existsSync(target)) {
|
||||
fs.unlinkSync(target);
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeEvidenceWrite({
|
||||
repoRoot, evidencePath, operation, document, payload, pushRemote = "gitea-http",
|
||||
askPassPath = "", auditLogPath, gitBinary = "/usr/bin/git",
|
||||
}) {
|
||||
const { root, target } = ensureSafeTarget(repoRoot, evidencePath);
|
||||
const relative = path.relative(root, target).replace(/\\/g, "/");
|
||||
const gitOptions = { cwd: root, gitBinary, env: gitEnvironment(askPassPath) };
|
||||
const branch = cleanBranch(payload.task_branch);
|
||||
if (runGit(["symbolic-ref", "--short", "HEAD"], gitOptions).trim() !== branch) throw new Error("branch_mismatch");
|
||||
if (runGit(["rev-parse", "HEAD"], gitOptions).trim() !== payload.claim_oid) throw new Error("head_mismatch");
|
||||
runGit(["merge-base", "--is-ancestor", payload.candidate_oid, payload.claim_oid], gitOptions);
|
||||
if (runGit(["status", "--porcelain", "--untracked-files=all"], gitOptions).trim()) throw new Error("worktree_not_clean");
|
||||
|
||||
const existed = fs.existsSync(target);
|
||||
if (operation === "create" && existed) throw new Error("path_exists_for_create");
|
||||
if (operation === "update" && !existed) throw new Error("path_missing_for_update");
|
||||
const backup = existed ? fs.readFileSync(target) : null;
|
||||
const priorMode = existed ? fs.statSync(target).mode & 0o777 : 0o660;
|
||||
let committed = false;
|
||||
let commitOid = null;
|
||||
try {
|
||||
atomicWrite(target, document, priorMode);
|
||||
runGit(["add", "--", relative], gitOptions);
|
||||
const staged = runGit(["diff", "--cached", "--name-only", "-z"], gitOptions).split("\0").filter(Boolean);
|
||||
if (staged.length !== 1 || staged[0] !== relative) throw new Error("staged_scope_mismatch");
|
||||
runGit(["commit", "-m", `chore(aios): provenance ${payload.provenance_id} ${path.basename(relative)}`], gitOptions);
|
||||
committed = true;
|
||||
commitOid = runGit(["rev-parse", "HEAD"], gitOptions).trim();
|
||||
runGit(buildPushArgs(pushRemote, branch), gitOptions);
|
||||
appendAudit(auditLogPath, {
|
||||
schema_version: "aios.writer-audit/v1", provenance_id: payload.provenance_id,
|
||||
task_id: payload.task_id, run_id: payload.run_id, path: relative, operation,
|
||||
branch, commit_oid: commitOid, outcome: "pushed", recorded_at: Date.now(),
|
||||
});
|
||||
return { path: relative, operation, branch, commit_oid: commitOid, outcome: "pushed" };
|
||||
} catch (error) {
|
||||
const code = error instanceof Error ? error.message : "writer_failed";
|
||||
if (!committed) restoreBeforeCommit(target, existed, backup, priorMode, root, relative, gitOptions);
|
||||
try {
|
||||
appendAudit(auditLogPath, {
|
||||
schema_version: "aios.writer-audit/v1", provenance_id: payload.provenance_id,
|
||||
task_id: payload.task_id, run_id: payload.run_id, path: relative, operation,
|
||||
branch, commit_oid: commitOid, outcome: committed ? "committed_push_blocked" : "rejected",
|
||||
error_code: code, recorded_at: Date.now(),
|
||||
});
|
||||
} catch { /* the original failure remains authoritative */ }
|
||||
throw new Error(code);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
@@ -16,6 +17,7 @@ from aios_openclaw import (
|
||||
InProcessRuntimeTransport,
|
||||
OpenClawAdapter,
|
||||
)
|
||||
from aios_openclaw.contract import OpenClawCommand, sign_command, verify_command_signature
|
||||
from aios_v2 import (
|
||||
ControlPlaneService,
|
||||
ExecutionEvidence,
|
||||
@@ -28,7 +30,7 @@ from aios_v2 import (
|
||||
from aios_v2.persistence import DurableControlPlaneService, SQLiteSnapshotRepository
|
||||
|
||||
|
||||
SIGNING_MATERIAL = b"candidate-test-signing-material-0001"
|
||||
SIGNING_MATERIAL = bytes(range(32))
|
||||
|
||||
|
||||
def context(
|
||||
@@ -64,6 +66,24 @@ def bootstrap(service: ControlPlaneService, tenant_id: str = "tenant-a"):
|
||||
|
||||
|
||||
class CandidateFlowTests(unittest.TestCase):
|
||||
def test_ed25519_command_signature_accepts_valid_and_rejects_tampering(self) -> None:
|
||||
command = OpenClawCommand(
|
||||
command_id="cmd-signature-test",
|
||||
tenant_id="tenant-a",
|
||||
task_id="task-signature-test",
|
||||
trace_id="trace-signature-test",
|
||||
action="echo",
|
||||
payload={"message": "hello"},
|
||||
idempotency_key="signature-test-1",
|
||||
key_id="openclaw-v2r1-candidate-ed25519",
|
||||
)
|
||||
signed = sign_command(command, SIGNING_MATERIAL)
|
||||
self.assertTrue(verify_command_signature(signed, SIGNING_MATERIAL))
|
||||
self.assertFalse(
|
||||
verify_command_signature(replace(signed, payload={"message": "tampered"}), SIGNING_MATERIAL)
|
||||
)
|
||||
self.assertFalse(verify_command_signature(signed, bytes(reversed(SIGNING_MATERIAL))))
|
||||
|
||||
def test_openclaw_full_flow_reconciles_completed_task(self) -> None:
|
||||
service = ControlPlaneService()
|
||||
_, operator = bootstrap(service)
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
|
||||
try:
|
||||
import requests # noqa: F401
|
||||
except ModuleNotFoundError:
|
||||
requests_stub = types.ModuleType("requests")
|
||||
requests_stub.RequestException = type("RequestException", (Exception,), {})
|
||||
requests_stub.get = lambda *args, **kwargs: None
|
||||
sys.modules["requests"] = requests_stub
|
||||
|
||||
|
||||
MODULE_PATH = Path(__file__).parents[2] / "agents" / "scripts" / "autonomous_controller.py"
|
||||
SPEC = importlib.util.spec_from_file_location("autonomous_controller", MODULE_PATH)
|
||||
controller = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(controller)
|
||||
|
||||
|
||||
def response(status_code, oid=None):
|
||||
payload = {"commit": {"id": oid}} if oid is not None else {}
|
||||
return SimpleNamespace(status_code=status_code, json=lambda: payload)
|
||||
|
||||
|
||||
class BranchSyncHealthTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.patches = [
|
||||
mock.patch.object(controller, "CANDIDATE_BRANCH", "candidate"),
|
||||
mock.patch.object(controller, "TASK_BRANCHES", ("task",)),
|
||||
mock.patch.object(
|
||||
controller,
|
||||
"_git",
|
||||
return_value=SimpleNamespace(returncode=0, stdout="https://example.invalid/repo.git\n"),
|
||||
),
|
||||
]
|
||||
for patcher in self.patches:
|
||||
patcher.start()
|
||||
|
||||
def tearDown(self):
|
||||
for patcher in reversed(self.patches):
|
||||
patcher.stop()
|
||||
|
||||
def test_local_and_gitea_oid_match(self):
|
||||
with mock.patch.object(
|
||||
controller, "_local_branch_state", return_value={"status": "synced", "oid": "a"}
|
||||
), mock.patch.object(
|
||||
controller, "_gitea_branch_state", return_value={"status": "synced", "oid": "a"}
|
||||
):
|
||||
result = controller.h_branch_sync()
|
||||
self.assertTrue(result["oid_match"])
|
||||
self.assertTrue(result["branch_sync"])
|
||||
self.assertEqual(result["sync_status"], "fully_synced")
|
||||
|
||||
def test_local_newer_gitea_older_is_pending(self):
|
||||
with mock.patch.object(
|
||||
controller, "_local_branch_state", return_value={"status": "synced", "oid": "new"}
|
||||
), mock.patch.object(
|
||||
controller, "_gitea_branch_state", return_value={"status": "synced", "oid": "old"}
|
||||
):
|
||||
result = controller.h_branch_sync()
|
||||
self.assertFalse(result["oid_match"])
|
||||
self.assertEqual(result["sync_status"], "gitea_pending")
|
||||
|
||||
def test_sync_status_mapping(self):
|
||||
matched = {"local_sync": {"status": "synced"}, "gitea_sync": {"status": "synced"}, "oid_match": True}
|
||||
missing = {"local_sync": {"status": "synced"}, "gitea_sync": {"status": "missing"}, "oid_match": False}
|
||||
failed = {"local_sync": {"status": "synced"}, "gitea_sync": {"status": "failed"}, "oid_match": False}
|
||||
self.assertEqual(controller._sync_status({"x": matched}), "fully_synced")
|
||||
self.assertEqual(controller._sync_status({"x": missing}), "local_only")
|
||||
self.assertEqual(controller._sync_status({"x": failed}), "gitea_failed")
|
||||
self.assertEqual(controller._sync_status({"x": matched, "y": missing}), "gitea_pending")
|
||||
|
||||
def test_persisted_health_summary_contains_sync_contract(self):
|
||||
branch_health = {
|
||||
"ok": True,
|
||||
"local_sync": {"status": "synced", "oid": "a"},
|
||||
"gitea_sync": {"status": "synced", "oid": "a"},
|
||||
"oid_match": True,
|
||||
"branch_sync": True,
|
||||
"sync_status": "fully_synced",
|
||||
"branches": {},
|
||||
}
|
||||
summary = controller.build_health_summary(
|
||||
{"branch_sync": branch_health, "controller_checkout": {"ok": True}}, 0, 0
|
||||
)
|
||||
for field in ("local_sync", "gitea_sync", "oid_match", "branch_sync", "sync_status"):
|
||||
self.assertIn(field, summary)
|
||||
|
||||
|
||||
class GiteaStateTests(unittest.TestCase):
|
||||
def test_gitea_health_uses_configured_credential_without_returning_it(self):
|
||||
secret = "sentinel-controller-token"
|
||||
with mock.patch.object(controller, "GITEA_TOKEN", secret), mock.patch.object(
|
||||
controller.requests, "get", return_value=SimpleNamespace(status_code=200, json=lambda: {"version": "test"})
|
||||
) as request:
|
||||
result = controller.h_gitea()
|
||||
self.assertEqual(request.call_args.kwargs["headers"], {"Authorization": "token " + secret})
|
||||
self.assertNotIn(secret, json.dumps(result))
|
||||
|
||||
def test_systemd_credential_file_is_preferred(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
credential = Path(temp) / "gitea-token"
|
||||
credential.write_text("file-token\n")
|
||||
value = controller._load_gitea_token(
|
||||
{"CREDENTIALS_DIRECTORY": temp, "GITEA_TOKEN": "plaintext-fallback"}
|
||||
)
|
||||
self.assertEqual(value, "file-token")
|
||||
|
||||
def test_remote_unreachable(self):
|
||||
with mock.patch.object(
|
||||
controller.requests, "get", side_effect=controller.requests.RequestException("offline")
|
||||
):
|
||||
result = controller._gitea_branch_state("candidate")
|
||||
self.assertEqual(result, {"status": "failed", "oid": None, "reason": "remote_unreachable"})
|
||||
|
||||
def test_remote_authentication_failure(self):
|
||||
with mock.patch.object(controller.requests, "get", return_value=response(403)):
|
||||
result = controller._gitea_branch_state("candidate")
|
||||
self.assertEqual(result["reason"], "authentication_failed")
|
||||
|
||||
def test_secret_never_enters_status_output(self):
|
||||
secret = "sentinel-credential-value"
|
||||
with mock.patch.object(controller, "GITEA_TOKEN", secret), mock.patch.object(
|
||||
controller.requests, "get", return_value=response(401)
|
||||
):
|
||||
result = controller._gitea_branch_state("candidate")
|
||||
self.assertNotIn(secret, json.dumps(result))
|
||||
|
||||
|
||||
class LocalRepositoryTests(unittest.TestCase):
|
||||
@unittest.skipUnless(shutil.which("git"), "git executable is required")
|
||||
def test_missing_branch(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
repo = Path(temp) / "repo.git"
|
||||
subprocess.run(["git", "init", "--bare", str(repo)], check=True, capture_output=True)
|
||||
with mock.patch.object(controller, "AUTHORITATIVE_REPO", repo):
|
||||
result = controller._local_branch_state("does-not-exist")
|
||||
self.assertEqual(result["status"], "missing")
|
||||
|
||||
@unittest.skipUnless(shutil.which("git"), "git executable is required")
|
||||
def test_controller_detached_and_clean(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
checkout = Path(temp) / "checkout"
|
||||
subprocess.run(["git", "init", str(checkout)], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", str(checkout), "config", "user.name", "test"], check=True)
|
||||
subprocess.run(["git", "-C", str(checkout), "config", "user.email", "test@example.invalid"], check=True)
|
||||
(checkout / "tracked.txt").write_text("ok\n")
|
||||
subprocess.run(["git", "-C", str(checkout), "add", "tracked.txt"], check=True)
|
||||
subprocess.run(["git", "-C", str(checkout), "commit", "-m", "test"], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", str(checkout), "checkout", "--detach"], check=True, capture_output=True)
|
||||
with mock.patch.object(controller, "CONTROLLER_CHECKOUT", checkout):
|
||||
result = controller.h_controller_checkout()
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertTrue(result["detached"])
|
||||
self.assertTrue(result["clean"])
|
||||
|
||||
def test_legacy_repository_name_removed(self):
|
||||
legacy = "aios-" + "v23"
|
||||
self.assertNotIn(legacy, MODULE_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user