Compare commits

..
14 changed files with 641 additions and 777 deletions
@@ -0,0 +1,90 @@
# OpenClaw isolated-worktree concurrency acceptance result
```yaml
status: PASS
task_id: TASK-WORKTREE-CONCURRENCY-OPENCLAW-20260715T060254UTC8
run_id: 20260715T060254UTC8
runtime_user: aiman
runtime_group: gitops
runtime_groups: gitops sudo docker aiman
umask: "0002"
worktree: /data/aios-gitops/worktrees/openclaw/TASK-WORKTREE-CONCURRENCY-OPENCLAW-20260715T060254UTC8
branch: task/openclaw/TASK-WORKTREE-CONCURRENCY-OPENCLAW-20260715T060254UTC8
base_commit: b05eb1babe9c657e8776c09eec71302166902cc3
preflight: PASS
workspace_clean_at_start: true
deep_directory_acl_inheritance: PASS
openclaw_commit_push: PASS
commit_rc: 0
push_rc: 0
gitea_oid_match: true
permission_denied_count: 0
untracked_overwrite_count: 0
git_lock_collision_count: 0
workspace_clean_after: true
production_service_touched: false
force_push: false
```
Preflight evidence:
```text
preflight_status: PASS
runtime_user: aiman
runtime_groups: gitops sudo docker aiman
umask: 0002
worktree: /data/aios-gitops/worktrees/openclaw/TASK-WORKTREE-CONCURRENCY-OPENCLAW-20260715T060254UTC8
git_dir: /data/aios-gitops/repo.git/worktrees/TASK-WORKTREE-CONCURRENCY-OPENCLAW-20260715T060254UTC8
branch: task/openclaw/TASK-WORKTREE-CONCURRENCY-OPENCLAW-20260715T060254UTC8
workspace_clean: true
task_path_writable: true
result_path_writable: true
git_lock_present: false
```
Deep ACL probe expected evidence:
```text
probe=.agent/results/openclaw/probes/TASK-WORKTREE-CONCURRENCY-OPENCLAW-20260715T060254UTC8/level-1/level-2/level-3/acl-probe.txt
probe_owner=aiman:gitops
probe_mode=664
deep_directory_owner=aiman:gitops
deep_directory_mode=2775
default:group:gitops:rwx
```
The post-commit OID and command return codes are authoritatively recorded in /run/aios-gitops-concurrency/20260715T060254UTC8/openclaw.done after Gitea verification.
Barrier execution evidence:
execute_at_epoch: 1784066844
ready_sha256_verified: true
staged_path_count: 3
author: openclaw <openclaw@aios.gitops>
push_remote: gitea
push_refspec: HEAD:refs/heads/task/openclaw/TASK-WORKTREE-CONCURRENCY-OPENCLAW-20260715T060254UTC8
force_push: false
main_tag_baseline_service_deploy_touched: false
Deep directory getfacl -cp:
user::rwx
user:aiman:rwx
group::rwx
group:gitops:rwx
mask::rwx
other::r-x
default:user::rwx
default:user:aiman:rwx
default:group::rwx
default:group:gitops:rwx
default:mask::rwx
default:other::r-x
Probe file getfacl -cp:
user::rw-
user:aiman:rwx #effective:rw-
group::rwx #effective:rw-
group:gitops:rwx #effective:rw-
mask::rw-
other::r--
@@ -0,0 +1,7 @@
task_id=TASK-WORKTREE-CONCURRENCY-OPENCLAW-20260715T060254UTC8
run_id=20260715T060254UTC8
agent=openclaw
purpose=deep_directory_acl_inheritance_probe
expected_user=aiman
expected_group=gitops
expected_umask=0002
@@ -0,0 +1,267 @@
# 执行 GitOps 独立 Worktree 与统一运行身份迁移
```yaml
task_id: TASK-GITOPS-ISOLATED-WORKTREE-MIGRATION-20260714T233742UTC8
created_at: 2026-07-14T23:37:42+08:00
timezone: Asia/Shanghai
owner_approved: true
user_action_required: false
risk: high
production_business_change: false
```
## 目标
根治共享 worktree 与多身份写入导致的 ACL、checkout 和未跟踪文件覆盖问题。
实施:
1. 统一 GitOps/OpenClaw 写入身份为 `aiman:gitops`;
2. 统一 `UMask=0002`;
3. 为 GitOps 根目录设置 setgid 与默认 ACL;
4. 建立 bare repo 和每任务独立 worktree;
5. 启用任务启动前自检;
6. 验证 OpenClaw 与 ChatGPT 并发任务互不干扰;
7. 保留原共享 workspace 作为只读回滚源,禁止智能体继续在其中切换分支。
## 强制通知
收到任务后立即微信通知 Owner:
```text
【任务已接收】GitOps独立worktree与统一运行身份迁移开始。将暂停写入服务、建立独立任务工作树并验证并发执行;生产业务版本不变。
```
任务结束后无论 PASS、BLOCKED 或 ROLLED_BACK,立即微信汇报结果并记录 message_id。
## 1. 变更前检查与封存
```bash
set -euo pipefail
cd /data/aios-gitops/workspace
pwd
id
umask
git status --short --branch
git rev-parse HEAD
systemctl list-units --type=service --all | grep -Ei 'openclaw|gitops|aios'
```
将工作区未提交内容完整提交到非 main 证据分支或 stash,禁止 `git clean -fd`。
记录当前运行服务、用户、组、umask、PID、工作区 HEAD 和未跟踪文件清单。
## 2. 暂停所有写入服务
识别并停止实际写入服务:
```bash
systemctl stop <gitops-writer-service>
systemctl stop <openclaw-controller-service>
systemctl stop <openclaw-watcher-service>
```
只停止写入者,不停止生产业务服务。记录每条命令和退出码。
## 3. 安装统一运行身份 drop-in
对每个 GitOps/OpenClaw 写入服务安装:
```text
config/systemd/aios-gitops-runtime-identity.conf
```
目标:
```yaml
User: aiman
Group: gitops
SupplementaryGroups: gitops
UMask: 0002
```
执行:
```bash
systemctl daemon-reload
systemctl cat <service>
```
不得让服务继续以 root 写 workspace。
## 4. 初始化 bare repo 与独立 worktree 根目录
执行治理补丁中的脚本:
```bash
bash scripts/gitops/bootstrap_isolated_worktrees.sh
```
验收:
```yaml
bare_repo: /data/aios-gitops/repo.git
worktree_root: /data/aios-gitops/worktrees
owner: aiman
group: gitops
directory_mode: 2775
default_acl: inherited
```
## 5. 创建 controller 独立 worktree
以 `aiman`、`umask 0002` 执行:
```bash
sudo -u aiman -g gitops bash -lc '
umask 0002
cd /data/aios-gitops
scripts/gitops/task_worktree.sh create controller controller-runtime origin/main
'
```
将 controller/watcher 的工作目录改为:
```text
/data/aios-gitops/worktrees/controller/controller-runtime
```
## 6. 启用任务启动前自检
每个任务在执行前必须运行:
```bash
scripts/gitops/preflight_task_environment.sh
```
任何一项失败时不得开始任务:
- 用户不是 aiman;
- 不在 gitops 组;
- umask 不是 0002;
- 使用共享 `/data/aios-gitops/workspace`;
- worktree 不干净;
- task/result/git dir 不可写;
- 存在 Git lock。
## 7. 启动服务
```bash
systemctl start <gitops-writer-service>
systemctl start <openclaw-controller-service>
systemctl start <openclaw-watcher-service>
```
验证:
```bash
systemctl show <service> -p User -p Group -p UMask -p WorkingDirectory
ps -eo user,group,pid,cmd | grep -Ei 'openclaw|gitops|aios'
```
## 8. 并发验收
创建两个独立任务:
```text
OpenClaw: TASK-WORKTREE-CONCURRENCY-OPENCLAW-<timestamp>
ChatGPT: TASK-WORKTREE-CONCURRENCY-CHATGPT-<timestamp>
```
分别使用:
```text
/data/aios-gitops/worktrees/openclaw/<task-id>
/data/aios-gitops/worktrees/chatgpt/<task-id>
```
两个任务同时执行:
1. 写各自 `.agent/tasks/<agent>/`;
2. 写各自 `.agent/results/<agent>/`;
3. 创建深层子目录,验证默认 ACL;
4. commit;
5. push;
6. 不切换共享 workspace 分支。
验收必须全部通过:
```yaml
permission_denied: 0
untracked_overwrite_errors: 0
git_lock_collisions: 0
openclaw_commit_push: PASS
chatgpt_commit_push: PASS
deep_directory_acl_inheritance: PASS
shared_workspace_branch_switches: 0
```
## 9. 回滚
若迁移失败:
1. 停止新服务;
2. 保留失败 worktree 供审计;
3. 恢复 systemd drop-in 备份;
4. `daemon-reload`;
5. 恢复原服务状态;
6. 不删除 `/data/aios-gitops/workspace`;
7. 生产业务保持不变。
## 10. 结果文件
提交:
```text
.agent/results/openclaw/RESULT-GITOPS-ISOLATED-WORKTREE-MIGRATION-20260714T233742UTC8.md
```
首段必须包含:
```yaml
status: PASS | BLOCKED | ROLLED_BACK
task_id: TASK-GITOPS-ISOLATED-WORKTREE-MIGRATION-20260714T233742UTC8
created_at: 2026-07-14T23:37:42+08:00
timezone: Asia/Shanghai
user_action_required: false
runtime_identity:
user:
group:
umask:
bare_repo:
worktree_root:
controller_worktree:
services:
default_acl:
preflight:
concurrency_test:
openclaw_branch:
openclaw_commit:
chatgpt_branch:
chatgpt_commit:
permission_denied_count:
untracked_overwrite_count:
git_lock_collision_count:
owner_notifications:
received_message_id:
terminal_message_id:
rollback:
remaining_risks:
next_step:
```
提交并推送到独立执行分支。禁止直接推 main。
## 完成微信模板
PASS:
```text
【任务完成】GitOps独立worktree迁移已通过。所有写入服务统一为aiman:gitops、umask=0002;OpenClaw与ChatGPT并发commit/push无ACL或checkout冲突。生产业务版本未变化。
```
阻塞:
```text
【任务阻塞】GitOps独立worktree迁移未通过。原因=<具体服务/权限/并发验收>;已保留原workspace和回滚能力,生产业务未变化。
```
@@ -0,0 +1,18 @@
# OpenClaw isolated-worktree concurrency acceptance
```yaml
task_id: TASK-WORKTREE-CONCURRENCY-OPENCLAW-20260715T060254UTC8
run_id: 20260715T060254UTC8
agent: openclaw
runtime_user: aiman
runtime_group: gitops
umask: "0002"
worktree: /data/aios-gitops/worktrees/openclaw/TASK-WORKTREE-CONCURRENCY-OPENCLAW-20260715T060254UTC8
branch: task/openclaw/TASK-WORKTREE-CONCURRENCY-OPENCLAW-20260715T060254UTC8
base_commit: b05eb1babe9c657e8776c09eec71302166902cc3
barrier: /run/aios-gitops-concurrency/20260715T060254UTC8
```
Scope: run preflight, create only this task file, the matching result file, and one deep ACL probe file. Publish openclaw.ready atomically. Wait for coordinator GO and execute_at_epoch. At that epoch add only these three paths, commit with command-scoped identity openclaw <openclaw@aios.gitops>, push explicitly and without force to the exact Gitea branch, and verify the remote OID.
Prohibited: main, tags, baselines, services, deployment, shared workspace, force push, secret output, or any other worktree/barrier.
-218
View File
@@ -1,218 +0,0 @@
# AIOS V2 Implementation Status
- 扫描日期:2026-07-20
- 当前发布基线:`V1R4C6`
- 基线提交:`e204a90398bf646d46245b3088226749fa056edd`
- 实施分支:`patch/aios-v2-implementation-status-next-phase-20260720`
- 决策角色:AIOS V2 CTO
- 总体判定:**进入主线收敛阶段,不重写现有系统。**
## 1. 执行摘要
AIOS V2 已经具备一批可复用的候选实现,包括签名租户上下文、权威 Tenant Router、Tenant Cell 生命周期、OpenClaw Fleet 适配、Hermes 租户队列与 Profile 隔离、消息账本、内存/密钥作用域以及追加式审计链。GitOps Runtime 当前可用,分支、提交和审计元数据机制可以继续作为所有变更的唯一入口。
当前尚不能宣称 AIOS V2 已交付。`V1R4C6` 的提升理由仍注明未部署生产;V2R1 实现主要存在于候选发布、集成、任务和结果分支,未进入 `main` 或当前基线。请求的 `docs/aios-v2/` 权威设计目录在已核对的 `main`、当前治理分支和抽查的 V2R1 集成/设计分支中未发现,因此架构目标尚未形成可被自动验证的单一事实源。
CTO 决策:
1. 不推倒 OpenClaw、Hermes、Agent Router 和现有 GitOps 能力。
2. AIOS 收敛为唯一控制面;OpenClaw 变为执行适配器;Hermes 变为租户隔离的 Runtime;Agent Router 变为统一调度与路由平面。
3. 候选能力先做证据映射、边界硬化和小步集成,再进入基线。
4. 所有 API、控制能力、运行状态与审计查询必须 Web 化。
5. 所有模块必须在入口、存储、队列、缓存、密钥、日志、审计和前端会话层显式携带并校验 `tenant_id`。
6. 所有修改必须通过非 `main` 分支、可审查提交、自动测试和受控提升完成。
## 2. 扫描范围与证据边界
### 已读取
- GitOps Runtime 状态与当前基线。
- `main`、V2R1 候选发布分支、集成/任务/结果分支的定向差异。
- `release/v2r1c0-tenant-cell-fabric-20260712` 中的 `active-runtime/app/aios_v2r1` 候选实现。
- 同一候选分支中的 `tests/v2r1` 合同测试与发布门禁说明。
- Gateway、自动编排、租户隔离、WebUI、事件总线等相关分支和结果分支的提交证据。
### 限制
- `docs/aios-v2/` 未出现在已核对的权威分支,无法完成“对该目录全部文件逐份比对”。本报告将此项列为 P0,而不是假设文档已经存在。
- 从 `V1R4C6` 到 `main`、以及部分大型任务分支的累计差异超过 GitOps diff 接口上限。因此本次采用定向目录、最近提交和结果证据扫描;下一阶段必须生成机器可读的能力—提交—测试索引。
- “分支存在”不等于“能力已合入”;“测试报告存在”不等于“生产门禁已通过”。状态必须区分基线、候选、验证和部署。
## 3. 当前架构扫描
### 3.1 权威基线
| 项目 | 当前状态 | 结论 |
|---|---|---|
| 发布基线 | `V1R4C6` | 当前唯一被提升的版本 |
| 生产部署 | 未确认;基线理由注明未部署生产 | 不得视为生产完成 |
| GitOps Runtime | Active | 可作为唯一变更通道 |
| V2R1 代码进入 `main` | 未发现 | 候选能力尚未成为权威实现 |
| `docs/aios-v2/` | 未发现 | P0 文档与治理缺口 |
### 3.2 候选运行时能力
`release/v2r1c0-tenant-cell-fabric-20260712` 中已发现以下候选模块:
| 模块 | 已有能力 | 当前成熟度 |
|---|---|---|
| `tenant_context.py` | HMAC 签名上下文、规范化消息模型、来源校验、基础工具策略 | 候选;缺少密钥轮换、重放防护和集中策略版本 |
| `router.py` | SQLite 权威租户、渠道、主体和会话路由;fail-closed 校验 | 候选;需统一到控制面并替换单机存储假设 |
| `cell_manager.py` / `cli.py` | Tenant Cell 注册和状态操作 | 原型;JSON 注册表无并发控制,状态机约束不足 |
| `fleet.py` | OpenClaw Fleet 命令适配 | 可复用适配层雏形;缺少超时、认证、幂等、审计和能力协商 |
| `hermes_fabric.py` | 强制租户分区的任务队列、来源限制、领取接口 | 候选;共享 SQLite 仍是逻辑隔离,需生产级队列和配额 |
| `hermes_profile.py` | HOME、工作区和路径逃逸防护 | 可复用;需容器/进程级隔离证据 |
| `memory_scope.py` | session/profile/cell/tenant/shared artifact 命名空间 | 可复用合同;需绑定实际存储后端和查询层 |
| `secret_scope.py` | 仅保存 SecretRef,校验 tenant/cell 所有权 | 可复用合同;尚无真实 broker、轮换和撤销流程 |
| `message_ledger.py` | 精确消息 ID、TTL、冲突检测和引用解析 | 候选;需迁移、容量、加密、备份和多实例并发验证 |
| `audit.py` | 追加式哈希链 | 原型;当前链为全局序列,应按租户/审计域隔离并支持外部不可变存储 |
### 3.3 测试与门禁
已发现可执行合同测试,覆盖 Tenant Router 隔离、TenantContext 签名和 Hermes 来源/重复事件处理。候选验收清单明确要求:
- 跨租户内存、Cell、密钥和队列访问必须拒绝。
- 非当前已认证人工命令不得触发副作用。
- OpenClaw Runtime Hook 与副作用审批属于必需项。
- 在可执行测试与运行时证据通过之前,不得生产部署。
因此当前标记为“候选能力存在”,而不是“发布完成”。
### 3.4 控制面与自动化
结果分支显示 Gateway 和自动编排已出现较强测试证据,包括单元/集成测试与 Codex 任务领取、生命周期回写能力。但是这些成果仍分散在结果/集成分支,尚未形成 AIOS 唯一入口,也未完成从任务结果到权威基线的提升闭环。
### 3.5 Web 化现状
仓库存在 WebUI、观测与控制面相关分支,但分支名称和最后提交内容有不一致现象。当前不能仅依据分支名认定功能完成。需要建立 API 路由、页面、权限、租户隔离、E2E 测试和部署证据五项映射。
## 4. 与 AIOS V2 目标架构对比
目标逻辑架构:
```text
Web Console / Public API
|
AIOS Control Plane
- Tenant Registry
- Identity & Policy
- Agent Registry
- Workflow / Task Lifecycle
- Approval & Audit
- Runtime Inventory
|
Agent Router / Scheduling Plane
/ \
OpenClaw Adapter Hermes Runtime Adapter
| |
OpenClaw Cells Hermes Workers / Profiles
```
边界规则:
- 只有 AIOS Control Plane 可以创建、审批、取消和审计任务。
- Router 只负责基于权威上下文的选择、调度和转发,不拥有租户事实源。
- OpenClaw 不拥有租户、策略或任务生命周期;只执行有签名、可审计、可幂等的命令。
- Hermes 不拥有控制面策略;只运行隔离 Profile、消费租户队列并返回结果/遥测。
- Web Console 不直接调用 OpenClaw/Hermes;只调用 AIOS API。
- 每次调用均传播 `tenant_id`、`principal_id`、`session_id`、`task_id`、`trace_id` 和策略版本。
## 5. Gap Analysis
| ID | 领域 | 当前 | 目标 | Gap | 优先级 |
|---|---|---|---|---|---|
| G-001 | 架构事实源 | `docs/aios-v2/` 不在权威分支 | 全部 V2 设计、ADR、接口和门禁可追踪 | 无法自动核对目标架构 | P0 |
| G-002 | 主线收敛 | V2 能力分散于候选/结果分支 | `main` 上单一集成历史 | 分支存在但未纳管、未提升 | P0 |
| G-003 | 唯一控制面 | Gateway/Router/执行器均可能承载控制逻辑 | AIOS 是唯一任务与策略入口 | 控制权边界尚未强制 | P0 |
| G-004 | 多租户强制 | 候选模块多为显式字段和 SQLite 逻辑隔离 | 全链路强制隔离、负向测试、生产存储策略 | 缺少统一 middleware、RLS/物理隔离策略和 E2E 证据 | P0 |
| G-005 | 审计与重放 | 全局哈希链;上下文有 nonce 但无 replay store | 按租户/审计域不可篡改、可查询、可验证;命令防重放 | 审计域、保留、导出、重放防护不足 | P0 |
| G-006 | OpenClaw 边界 | Fleet CLI 适配已有 | 版本化执行协议、幂等、超时、取消、回调、审计 | 仍可能被控制面之外直接调用 | P1 |
| G-007 | Hermes 边界 | 租户队列/Profile 已有 | Runtime-only、配额、公平调度、取消、恢复 | 共享存储和控制逻辑仍需拆分 | P1 |
| G-008 | Web 化 | 多个局部 Web/UI 分支 | 所有控制能力均有 Web/API,权限一致 | 无统一 Console、API contract 和 E2E 门禁 | P1 |
| G-009 | 生产数据层 | 多个候选模块使用 SQLite/JSON | HA、迁移、备份、加密、容量和恢复目标 | 原型数据层不能直接生产 | P1 |
| G-010 | 证据治理 | 分支名与内容可能不一致,大 diff 超限 | 能力—提交—测试—部署机器可读映射 | 无法可靠计算完成度 | P0 |
| G-011 | 发布闭环 | 基线提升与生产部署脱节 | 构建、测试、安全、审批、提升、部署、回滚闭环 | 当前基线仍注明未部署 | P0 |
| G-012 | 可观测性 | 局部分支存在观测/WebUI 工作 | tenant/task/agent/trace 统一指标日志追踪 | 统一 telemetry contract 尚未成为门禁 | P1 |
## 6. 能力标记
### 6.1 保留并复用
- GitOps Runtime、非主线分支、提交元数据、审批式 Tag/回滚流程。
- OpenClaw Fleet 和现有执行能力。
- Hermes Runtime、Profile、队列和工作区能力。
- Agent Router 的路由/调度经验和已有接口。
- V2R1 TenantContext、CanonicalMessage、TenantRouter、MemoryScope、SecretRef、MessageLedger 合同。
- 现有 Gateway、自动领取、任务生命周期回写和测试资产。
- 已有 WebUI、审计和观测代码中可以由证据证明的部分。
### 6.2 必须重构而非重写
- 将 OpenClaw 内部的租户、策略、审批和任务状态决策移到 AIOS;保留执行器和 Fleet 能力。
- 将 Hermes 的控制逻辑移到 AIOS/Router;保留 Runtime、Profile 与 Worker。
- 合并重复 Router/Gateway 入口,形成一个权威 Tenant/Policy middleware。
- 将 SQLite/JSON 原型抽象为 repository/queue/audit 接口,允许先兼容、后替换生产后端。
- 将审计链改为租户或明确审计域分区,并加入签名、外部固化、查询授权和验证作业。
- 将所有 UI 改为 AIOS API 客户端,禁止浏览器直接访问执行层。
- 将测试报告升级为可复现命令、commit、exit code、pass count 和 artifact digest。
### 6.3 暂停继续扩展
在 P0 收敛完成前,不新增:
- 绕过 AIOS Gateway 的 OpenClaw/Hermes 公共入口。
- 未携带 TenantContext 的任务、消息、缓存或后台作业。
- 只存在于个人/临时分支且没有验收合同的大型功能。
- 单次超大提交;每个任务必须可独立审查、测试和回滚。
## 7. 实施路线
### Phase 0:权威化与门禁(立即)
1. 创建 `docs/aios-v2/` 单一事实源和 ADR 索引。
2. 生成能力—提交—测试—部署 Evidence Index。
3. 提取 V2R1 候选能力到窄范围集成分支,不直接合并整个历史分支。
4. 建立统一 TenantContext middleware、Policy Decision 和审计接口。
5. 建立 AIOS Control Plane Gateway 唯一入口与禁止旁路测试。
6. 建立 CI Gate:多租户负向测试、重放、幂等、权限、Web E2E、审计验证。
### Phase 1:执行层解耦
1. OpenClaw Adapter v1:版本化协议、能力发现、start/stop/execute/cancel/status、幂等键、超时、结果回调。
2. Hermes Runtime Adapter v1:enqueue/claim/heartbeat/cancel/result、配额、公平调度和 Tenant Profile 强制。
3. Router 只读取 AIOS 权威注册表与策略结果,不保留第二套租户事实源。
### Phase 2:Web 与生产化
1. AIOS Console:Tenant、Agent、Workflow、Task、Approval、Runtime、Audit、GitOps 页面。
2. 统一 API、RBAC/ABAC、租户会话隔离、CSRF/CORS、速率限制和操作审计。
3. 替换/封装原型数据层,完成迁移、备份恢复、HA、容量和灾备演练。
4. 完成受控基线提升、部署、冒烟和回滚证据。
## 8. 下一阶段出口标准
只有同时满足以下条件,才允许进入 V2R1 集成候选:
- `docs/aios-v2/` 在目标集成分支存在,且架构、ADR、API、租户模型和门禁互相链接。
- AIOS Gateway 是任务创建、审批、状态变更和取消的唯一公共入口。
- OpenClaw/Hermes 旁路调用被代码、网络策略或测试明确拒绝。
- 所有持久化记录和消息均含不可为空的 `tenant_id`,跨租户负向测试通过。
- TenantContext 具备密钥版本、重放缓存、时钟偏差、撤销和审计策略。
- 审计按租户/审计域隔离,能够从 Web/API 授权查询并验证完整性。
- Codex 和 OpenClaw 任务均返回 branch、commit、测试命令、exit code、pass count、风险与回滚说明。
- 变更拆分为可审查提交,不出现无法由 diff 接口检查的单任务超大提交。
- 未执行直接 `main` push、Tag、生产部署或密钥变更。
## 9. 当前状态结论
| 结论 | 状态 |
|---|---|
| 不重写现有代码 | 已决策 |
| 优先复用 OpenClaw/Hermes/Agent Router | 已决策 |
| OpenClaw 与 Hermes 逐步解耦 | 已列入 Phase 1 |
| AIOS 成为唯一控制面 | P0 实施中 |
| 所有功能 Web 化 | P1 规划完成,尚未验收 |
| 所有模块多租户隔离 | 候选合同存在,生产级全链路尚未完成 |
| 所有修改通过 Git 任务流程 | 当前已遵守 |
**总体完成度判定:架构与候选能力约处于“可收敛、不可发布”阶段。下一步不是继续增加功能,而是建立权威事实源、统一入口、租户门禁和可验证的集成闭环。**
@@ -1,550 +0,0 @@
#!/usr/bin/env python3
"""Isolated Git worktree lifecycle for AIOS GitOps write requests.
The long-running controller workspace is read-only and remains detached. Each
write request gets a short-lived detached worktree. A branch-scoped lease and
compare-and-swap ref update prevent concurrent writers from silently replacing
one another.
"""
from __future__ import annotations
import fcntl
import hashlib
import json
import os
import re
import shutil
import subprocess
import threading
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
ZERO_OID = "0" * 40
BRANCH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,179}$")
class GitWorkspaceError(RuntimeError):
"""Base error for isolated worktree operations."""
class BranchLeaseBusy(GitWorkspaceError):
"""Another request currently owns the target branch lease."""
class BranchCheckedOut(GitWorkspaceError):
"""The target branch is attached to a non-ephemeral worktree."""
class ConcurrentBranchUpdate(GitWorkspaceError):
"""The branch changed after the request acquired its lease."""
@dataclass
class WorkspaceLease:
branch: str
base: str
path: Path
start_oid: str
local_oid_before: str | None
remote_oid_before: str | None
lock_file: Any
class GitOpsWorktreeManager:
"""Manage one detached worktree per GitOps write request.
A manager instance is safe for a ``ThreadingHTTPServer``. Request state is
stored in thread-local storage, while a filesystem lease serializes writers
targeting the same branch across processes.
"""
def __init__(
self,
controller_workspace: str | Path,
worktree_root: str | Path,
*,
remote: str = "origin",
lock_root: str | Path | None = None,
author_name: str = "chatgpt-gitops-bot",
author_email: str = "chatgpt-gitops-bot@cnnetmkt.top",
) -> None:
self.controller_workspace = Path(controller_workspace).resolve()
self.worktree_root = Path(worktree_root).resolve()
self.remote = remote
self.author_name = author_name
self.author_email = author_email
self._state = threading.local()
if not self.controller_workspace.exists():
raise GitWorkspaceError(
f"controller workspace does not exist: {self.controller_workspace}"
)
common = self._git(
["rev-parse", "--path-format=absolute", "--git-common-dir"],
cwd=self.controller_workspace,
).stdout.strip()
self.common_git_dir = Path(common).resolve()
self.worktree_root.mkdir(parents=True, exist_ok=True)
preferred_lock_root = Path(
lock_root
or os.getenv("AIOS_GITOPS_BRANCH_LOCK_ROOT", "/run/lock/aios-gitops")
)
try:
preferred_lock_root.mkdir(parents=True, exist_ok=True)
self.lock_root = preferred_lock_root.resolve()
except PermissionError:
fallback = self.worktree_root / ".locks"
fallback.mkdir(parents=True, exist_ok=True)
self.lock_root = fallback.resolve()
@staticmethod
def validate_branch(branch: str) -> str:
value = str(branch or "").strip()
invalid = (
not BRANCH_RE.fullmatch(value)
or value in {"main", "master"}
or value.startswith(("main/", "master/"))
or value.endswith(("/", ".", ".lock"))
or value.startswith(("/", "."))
or ".." in value
or "//" in value
or "@{" in value
or "\\" in value
)
if invalid:
raise ValueError(f"invalid or protected branch: {value!r}")
return value
@staticmethod
def validate_path(path: str) -> str:
raw = str(path or "").replace("\\", "/").strip()
candidate = Path(raw)
if (
not raw
or candidate.is_absolute()
or ".." in candidate.parts
or candidate.parts[0] == ".git"
):
raise ValueError(f"invalid repository path: {path!r}")
return str(candidate)
def _git(
self,
args: Iterable[str],
*,
cwd: Path | None = None,
check: bool = True,
) -> subprocess.CompletedProcess[str]:
command = ["git", *list(args)]
proc = subprocess.run(
command,
cwd=str(cwd or self.controller_workspace),
text=True,
capture_output=True,
)
if check and proc.returncode != 0:
message = proc.stderr.strip() or proc.stdout.strip() or "git command failed"
raise GitWorkspaceError(f"{message}; command={command!r}")
return proc
def _git_dir(
self, args: Iterable[str], *, check: bool = True
) -> subprocess.CompletedProcess[str]:
return self._git(
[f"--git-dir={self.common_git_dir}", *list(args)],
cwd=self.controller_workspace,
check=check,
)
def _rev_parse(self, ref: str) -> str | None:
proc = self._git_dir(["rev-parse", "--verify", ref], check=False)
return proc.stdout.strip() if proc.returncode == 0 else None
def _remote_oid(self, branch: str) -> str | None:
proc = self._git(
["ls-remote", "--heads", self.remote, f"refs/heads/{branch}"],
cwd=self.controller_workspace,
check=False,
)
if proc.returncode != 0 or not proc.stdout.strip():
return None
return proc.stdout.split()[0]
def _fetch_remote_tracking(self, branch: str) -> str | None:
remote_oid = self._remote_oid(branch)
if not remote_oid:
return None
destination = f"refs/remotes/{self.remote}/{branch}"
self._git(
[
"fetch",
"--no-tags",
self.remote,
f"+refs/heads/{branch}:{destination}",
],
cwd=self.controller_workspace,
)
return remote_oid
def _checked_out_paths(self, branch: str) -> list[str]:
target_ref = f"refs/heads/{branch}"
proc = self._git_dir(["worktree", "list", "--porcelain"])
paths: list[str] = []
current_path: str | None = None
for line in proc.stdout.splitlines():
if line.startswith("worktree "):
current_path = line.removeprefix("worktree ").strip()
elif line.startswith("branch ") and line.removeprefix("branch ").strip() == target_ref:
if current_path:
paths.append(current_path)
return paths
def _assert_branch_not_checked_out(self, branch: str) -> None:
paths = self._checked_out_paths(branch)
if paths:
raise BranchCheckedOut(
f"branch {branch!r} is checked out at: {', '.join(paths)}"
)
def _acquire_lock(self, branch: str):
digest = hashlib.sha256(branch.encode("utf-8")).hexdigest()
path = self.lock_root / f"{digest}.lock"
handle = path.open("a+", encoding="utf-8")
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
handle.close()
raise BranchLeaseBusy(f"branch lease is busy: {branch}") from exc
handle.seek(0)
handle.truncate()
handle.write(json.dumps({"branch": branch, "pid": os.getpid()}))
handle.flush()
return handle
def _release_lock(self, handle: Any) -> None:
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
finally:
handle.close()
def _current(self) -> WorkspaceLease | None:
return getattr(self._state, "lease", None)
def current_workspace(self) -> Path:
lease = self._current()
return lease.path if lease else self.controller_workspace
def begin(self, branch: str, base: str = "main", create: bool = False) -> str:
del create # Existing branches are resumed; they are never destructively reset.
if self._current() is not None:
raise GitWorkspaceError("request already owns a worktree lease")
branch = self.validate_branch(branch)
base = str(base or "main").strip()
lock_file = self._acquire_lock(branch)
try:
self._assert_branch_not_checked_out(branch)
remote_oid = self._fetch_remote_tracking(branch)
local_oid = self._rev_parse(f"refs/heads/{branch}")
if remote_oid and local_oid and remote_oid != local_oid:
raise ConcurrentBranchUpdate(
f"local and remote branch diverged before write: {branch}"
)
start_oid = remote_oid or local_oid
if not start_oid:
base_remote = self._fetch_remote_tracking(base)
base_local = self._rev_parse(f"refs/heads/{base}")
start_oid = base_remote or base_local or self._rev_parse(base)
if not start_oid:
raise GitWorkspaceError(f"cannot resolve base ref: {base}")
suffix = uuid.uuid4().hex[:12]
safe_branch = re.sub(r"[^A-Za-z0-9._-]+", "-", branch)[:96]
path = self.worktree_root / f"{safe_branch}-{suffix}"
self._git_dir(["worktree", "add", "--detach", str(path), start_oid])
self._state.lease = WorkspaceLease(
branch=branch,
base=base,
path=path,
start_oid=start_oid,
local_oid_before=local_oid,
remote_oid_before=remote_oid,
lock_file=lock_file,
)
return branch
except Exception:
self._release_lock(lock_file)
raise
def sh(self, args: list[str], check: bool = False) -> dict[str, Any]:
proc = self._git(args, cwd=self.current_workspace(), check=False)
if check and proc.returncode != 0:
message = proc.stderr.strip() or proc.stdout.strip() or "git command failed"
raise GitWorkspaceError(message)
return {
"code": proc.returncode,
"stdout": proc.stdout.strip(),
"stderr": proc.stderr.strip(),
"cmd": ["git", *args],
}
def write_file(self, path: str, content: str) -> str:
lease = self._current()
if lease is None:
raise GitWorkspaceError("write attempted without an active branch lease")
relative = self.validate_path(path)
target = lease.path / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
return relative
def write_json(self, path: str, data: Any) -> str:
return self.write_file(
path, json.dumps(data, ensure_ascii=False, indent=2) + "\n"
)
def _update_local_branch(self, lease: WorkspaceLease, new_oid: str) -> None:
self._assert_branch_not_checked_out(lease.branch)
ref = f"refs/heads/{lease.branch}"
expected = lease.local_oid_before or ZERO_OID
proc = self._git_dir(["update-ref", ref, new_oid, expected], check=False)
if proc.returncode != 0:
raise ConcurrentBranchUpdate(
proc.stderr.strip()
or f"local branch changed concurrently: {lease.branch}"
)
def _rollback_local_branch(self, lease: WorkspaceLease, new_oid: str) -> None:
ref = f"refs/heads/{lease.branch}"
if lease.local_oid_before:
self._git_dir(
["update-ref", ref, lease.local_oid_before, new_oid], check=False
)
else:
self._git_dir(["update-ref", "-d", ref, new_oid], check=False)
def _push_remote(self, lease: WorkspaceLease, new_oid: str) -> dict[str, Any]:
remote_url = self._git(
["remote", "get-url", self.remote],
cwd=lease.path,
check=False,
)
if remote_url.returncode != 0:
return {"status": "synced", "target": "common_git_dir"}
command = ["push", "--porcelain"]
if lease.remote_oid_before:
command.append(
f"--force-with-lease=refs/heads/{lease.branch}:{lease.remote_oid_before}"
)
command.extend(
[self.remote, f"HEAD:refs/heads/{lease.branch}"]
)
proc = self._git(command, cwd=lease.path, check=False)
if proc.returncode != 0:
self._rollback_local_branch(lease, new_oid)
raise ConcurrentBranchUpdate(
proc.stderr.strip()
or proc.stdout.strip()
or f"remote branch changed concurrently: {lease.branch}"
)
tracking_ref = f"refs/remotes/{self.remote}/{lease.branch}"
tracking_expected = lease.remote_oid_before or ZERO_OID
self._git_dir(
["update-ref", tracking_ref, new_oid, tracking_expected], check=False
)
return {
"status": "synced",
"target": self.remote,
"stdout": proc.stdout.strip(),
"stderr": proc.stderr.strip(),
}
def commit(self, paths: list[str] | str | None, message: str) -> dict[str, Any]:
lease = self._current()
if lease is None:
raise GitWorkspaceError("commit attempted without an active branch lease")
raw_paths = paths or ["."]
if isinstance(raw_paths, str):
raw_paths = [raw_paths]
safe_paths = [self.validate_path(p) if p != "." else "." for p in raw_paths]
self._git(["add", "--", *safe_paths], cwd=lease.path)
status = self._git(["status", "--porcelain"], cwd=lease.path).stdout.strip()
if not status:
result = {
"status": "no_changes",
"branch": lease.branch,
"rev": lease.start_oid[:12],
"sync": {"status": "unchanged"},
"workspace_mode": "detached",
}
self.finish(success=True)
return result
commit_proc = self._git(
[
"-c",
f"user.name={self.author_name}",
"-c",
f"user.email={self.author_email}",
"commit",
"-m",
message,
],
cwd=lease.path,
)
new_oid = self._git(["rev-parse", "HEAD"], cwd=lease.path).stdout.strip()
self._update_local_branch(lease, new_oid)
sync = self._push_remote(lease, new_oid)
result = {
"status": "committed",
"branch": lease.branch,
"rev": new_oid[:12],
"commit": new_oid,
"git": {
"code": commit_proc.returncode,
"stdout": commit_proc.stdout.strip(),
"stderr": commit_proc.stderr.strip(),
},
"sync": sync,
"workspace_mode": "detached",
}
self.finish(success=True)
return result
def finish(self, *, success: bool) -> dict[str, Any] | None:
lease = self._current()
if lease is None:
return None
self._state.lease = None
retained = False
cleanup_error: str | None = None
try:
status = self._git(
["status", "--porcelain"], cwd=lease.path, check=False
).stdout.strip()
if success and not status:
proc = self._git_dir(
["worktree", "remove", str(lease.path)], check=False
)
if proc.returncode != 0:
cleanup_error = proc.stderr.strip() or proc.stdout.strip()
retained = True
else:
retained = lease.path.exists()
finally:
self._release_lock(lease.lock_file)
self._git_dir(["worktree", "prune", "--expire", "now"], check=False)
return {
"branch": lease.branch,
"worktree": str(lease.path),
"retained": retained,
"cleanup_error": cleanup_error,
}
def abort_current(self) -> dict[str, Any] | None:
"""Release an unfinished request, retaining dirty evidence for review."""
lease = self._current()
if lease is None:
return None
status = self._git(
["status", "--porcelain"], cwd=lease.path, check=False
).stdout.strip()
return self.finish(success=not bool(status))
def sync_branch(self, branch: str) -> dict[str, Any]:
"""Compatibility hook for the legacy API entrypoint."""
return {
"status": "managed_by_isolated_worktree",
"branch": self.validate_branch(branch),
}
def sync_tag(self, tag: str) -> dict[str, Any]:
proc = self._git(
["push", self.remote, f"refs/tags/{tag}:refs/tags/{tag}"],
cwd=self.controller_workspace,
check=False,
)
if proc.returncode != 0:
raise GitWorkspaceError(proc.stderr.strip() or proc.stdout.strip())
return {
"status": "synced",
"target": self.remote,
"tag": tag,
"stdout": proc.stdout.strip(),
"stderr": proc.stderr.strip(),
}
def runtime_status(self) -> dict[str, Any]:
branch = self._git(
["branch", "--show-current"], cwd=self.controller_workspace
).stdout.strip()
head = self._git(
["rev-parse", "HEAD"], cwd=self.controller_workspace
).stdout.strip()
dirty = bool(
self._git(
["status", "--porcelain"], cwd=self.controller_workspace
).stdout.strip()
)
return {
"workspace_mode": "branch" if branch else "detached",
"workspace_branch": branch or None,
"workspace_ref": head,
"workspace_dirty": dirty,
"common_git_dir": str(self.common_git_dir),
"task_worktree_root": str(self.worktree_root),
}
def remove_abandoned_clean_worktrees(
manager: GitOpsWorktreeManager, *, prefix: str | Path | None = None
) -> list[str]:
"""Remove clean detached worktrees below the managed root.
Dirty worktrees are deliberately retained. This helper is intended for a
startup recovery job and never removes worktrees outside the configured
managed root.
"""
root = Path(prefix or manager.worktree_root).resolve()
removed: list[str] = []
proc = manager._git_dir(["worktree", "list", "--porcelain"])
candidates: list[Path] = []
current: Path | None = None
detached = False
for line in [*proc.stdout.splitlines(), ""]:
if line.startswith("worktree "):
if current and detached:
candidates.append(current)
current = Path(line.removeprefix("worktree ").strip()).resolve()
detached = False
elif line == "detached":
detached = True
elif not line and current and detached:
candidates.append(current)
current = None
detached = False
for path in candidates:
if path == manager.controller_workspace or root not in path.parents:
continue
status = manager._git(["status", "--porcelain"], cwd=path, check=False)
if status.returncode == 0 and not status.stdout.strip():
removal = manager._git_dir(
["worktree", "remove", str(path)], check=False
)
if removal.returncode == 0:
removed.append(str(path))
elif not path.exists():
shutil.rmtree(path, ignore_errors=True)
manager._git_dir(["worktree", "prune", "--expire", "now"], check=False)
return removed
+59
View File
@@ -0,0 +1,59 @@
version: 1
policy_id: gitops-isolated-worktree-governance-20260714T232948UTC8
runtime_identity:
user: aiman
group: gitops
umask: "0002"
prohibit_root_workspace_writes: true
repository:
source_worktree: /data/aios-gitops/workspace
bare_repo: /data/aios-gitops/repo.git
worktree_root: /data/aios-gitops/worktrees
remote: origin
worktrees:
controller: /data/aios-gitops/worktrees/controller
task_pattern: /data/aios-gitops/worktrees/{agent}/{task_id}
allowed_agents:
- openclaw
- chatgpt
- controller
require_clean_start: true
remove_after_successful_push: true
retain_on_failure: true
permissions:
owner: aiman
group: gitops
directory_mode: "2775"
file_mode: "0664"
default_acl:
user:aiman: rwx
group:gitops: rwx
apply_to:
- /data/aios-gitops
- /data/aios-gitops/worktrees
preflight:
required_user: aiman
required_group: gitops
required_umask: "0002"
require_writable_git_dir: true
require_writable_task_dir: true
require_writable_result_dir: true
reject_shared_source_worktree: true
reject_existing_git_lock: true
notifications:
owner_on_received: required
owner_on_terminal: required
preferred_channel: weixin
redact_secrets: true
rollback:
preserve_source_worktree: true
remove_created_worktrees: true
restore_service_dropins: true
restore_service_state: true
@@ -0,0 +1,14 @@
# Install as a systemd drop-in for GitOps/OpenClaw writer services.
# Example: /etc/systemd/system/<service>.service.d/10-runtime-identity.conf
[Service]
User=aiman
Group=gitops
SupplementaryGroups=gitops
UMask=0002
Environment=AIOS_GITOPS_ROOT=/data/aios-gitops
Environment=AIOS_BARE_REPO=/data/aios-gitops/repo.git
Environment=AIOS_WORKTREE_ROOT=/data/aios-gitops/worktrees
Environment=AIOS_RUNTIME_USER=aiman
Environment=AIOS_RUNTIME_GROUP=gitops
Environment=AIOS_RUNTIME_UMASK=0002
@@ -0,0 +1,9 @@
{
"id": "patch-1784043007770-e97206c3",
"patch_name": "gitops-isolated-worktree-governance-20260714T232948UTC8",
"branch": "patch/gitops-isolated-worktree-governance-20260714T232948UTC8",
"risk": "high",
"description": "Root-cause remediation for recurring ACL and shared-worktree failures: unified aiman:gitops runtime identity, UMask=0002, inherited default ACLs, per-task isolated Git worktrees, startup preflight, migration/rollback scripts, systemd drop-in templates, audit evidence, and an OpenClaw execution task. No production business deployment in this patch.",
"created_by": "chatgpt-gitops-bot",
"created_at": "2026-07-14T15:30:07Z"
}
@@ -1,9 +0,0 @@
{
"type": "branch",
"branch": "patch/aios-v2-implementation-status-next-phase-20260720",
"base": "main",
"created_by": "chatgpt-gitops-bot",
"created_at": "2026-07-19T16:21:00Z",
"direct_main_push": false,
"owner_approval_required": true
}
@@ -0,0 +1,9 @@
{
"type": "branch",
"branch": "task/codex/gitops-isolated-worktree-migration-20260715T001652UTC8",
"base": "patch/gitops-isolated-worktree-governance-20260714T232948UTC8",
"created_by": "chatgpt-gitops-bot",
"created_at": "2026-07-14T16:17:28Z",
"direct_main_push": false,
"owner_approval_required": true
}
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="${AIOS_GITOPS_ROOT:-/data/aios-gitops}"
SOURCE="${AIOS_SOURCE_WORKTREE:-$ROOT/workspace}"
BARE="${AIOS_BARE_REPO:-$ROOT/repo.git}"
WORKTREES="${AIOS_WORKTREE_ROOT:-$ROOT/worktrees}"
RUNTIME_USER="${AIOS_RUNTIME_USER:-aiman}"
RUNTIME_GROUP="${AIOS_RUNTIME_GROUP:-gitops}"
if [[ "$(id -u)" -ne 0 ]]; then
echo "ERROR: bootstrap must run as root" >&2
exit 2
fi
getent passwd "$RUNTIME_USER" >/dev/null
getent group "$RUNTIME_GROUP" >/dev/null
[[ -d "$SOURCE/.git" || -f "$SOURCE/.git" ]] || {
echo "ERROR: source worktree not found: $SOURCE" >&2
exit 3
}
install -d -o "$RUNTIME_USER" -g "$RUNTIME_GROUP" -m 2775 "$ROOT" "$WORKTREES"
install -d -o "$RUNTIME_USER" -g "$RUNTIME_GROUP" -m 2775 \
"$WORKTREES/openclaw" "$WORKTREES/chatgpt" "$WORKTREES/controller"
if [[ ! -d "$BARE" ]]; then
sudo -u "$RUNTIME_USER" -g "$RUNTIME_GROUP" git clone --bare "$SOURCE" "$BARE"
fi
chown -R "$RUNTIME_USER:$RUNTIME_GROUP" "$BARE" "$WORKTREES"
find "$BARE" "$WORKTREES" -type d -exec chmod 2775 {} +
find "$BARE" "$WORKTREES" -type f -exec chmod ug+rw {} +
setfacl -R -m "u:${RUNTIME_USER}:rwX,g:${RUNTIME_GROUP}:rwX,m::rwX" "$BARE" "$WORKTREES"
find "$BARE" "$WORKTREES" -type d -exec \
setfacl -m "d:u:${RUNTIME_USER}:rwx,d:g:${RUNTIME_GROUP}:rwx,d:m::rwx" {} +
cat <<EOF
bootstrap_status: PASS
root: $ROOT
source_worktree: $SOURCE
bare_repo: $BARE
worktree_root: $WORKTREES
runtime_user: $RUNTIME_USER
runtime_group: $RUNTIME_GROUP
EOF
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
EXPECTED_USER="${AIOS_RUNTIME_USER:-aiman}"
EXPECTED_GROUP="${AIOS_RUNTIME_GROUP:-gitops}"
EXPECTED_UMASK="${AIOS_RUNTIME_UMASK:-0002}"
TASK_DIR="${AIOS_TASK_DIR:-.agent/tasks}"
RESULT_DIR="${AIOS_RESULT_DIR:-.agent/results}"
fail() {
echo "preflight_status: FAIL"
echo "reason: $1"
exit 1
}
USER_NOW="$(id -un)"
GROUPS_NOW="$(id -Gn)"
UMASK_NOW="$(umask)"
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
GIT_DIR="$(git rev-parse --git-dir 2>/dev/null || true)"
BRANCH="$(git branch --show-current 2>/dev/null || true)"
[[ "$USER_NOW" == "$EXPECTED_USER" ]] || fail "runtime user is $USER_NOW, expected $EXPECTED_USER"
[[ " $GROUPS_NOW " == *" $EXPECTED_GROUP "* ]] || fail "runtime user is not in $EXPECTED_GROUP"
[[ "$UMASK_NOW" == "$EXPECTED_UMASK" || "$UMASK_NOW" == "002" ]] || fail "umask is $UMASK_NOW, expected $EXPECTED_UMASK"
[[ -n "$ROOT" ]] || fail "not inside a git worktree"
[[ "$ROOT" == /data/aios-gitops/worktrees/* ]] || fail "shared source worktree is prohibited: $ROOT"
[[ -w "$ROOT" ]] || fail "worktree root is not writable"
[[ -w "$GIT_DIR" ]] || fail "git dir is not writable: $GIT_DIR"
[[ ! -e "$GIT_DIR/index.lock" ]] || fail "git index lock exists"
install -d -m 2775 "$TASK_DIR" "$RESULT_DIR"
[[ -w "$TASK_DIR" ]] || fail "task directory is not writable: $TASK_DIR"
[[ -w "$RESULT_DIR" ]] || fail "result directory is not writable: $RESULT_DIR"
[[ -z "$(git status --porcelain)" ]] || fail "worktree is not clean at task start"
cat <<EOF
preflight_status: PASS
runtime_user: $USER_NOW
runtime_groups: $GROUPS_NOW
umask: $UMASK_NOW
worktree: $ROOT
git_dir: $GIT_DIR
branch: $BRANCH
workspace_clean: true
task_path_writable: true
result_path_writable: true
git_lock_present: false
EOF
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-}"
AGENT="${2:-}"
TASK_ID="${3:-}"
BASE_REF="${4:-origin/main}"
ROOT="${AIOS_GITOPS_ROOT:-/data/aios-gitops}"
BARE="${AIOS_BARE_REPO:-$ROOT/repo.git}"
WORKTREES="${AIOS_WORKTREE_ROOT:-$ROOT/worktrees}"
RUNTIME_USER="${AIOS_RUNTIME_USER:-aiman}"
RUNTIME_GROUP="${AIOS_RUNTIME_GROUP:-gitops}"
usage() {
echo "usage: $0 create|remove <openclaw|chatgpt|controller> <task-id> [base-ref]" >&2
exit 64
}
[[ "$ACTION" =~ ^(create|remove)$ ]] || usage
[[ "$AGENT" =~ ^(openclaw|chatgpt|controller)$ ]] || usage
[[ "$TASK_ID" =~ ^[A-Za-z0-9._-]+$ ]] || {
echo "ERROR: invalid task id" >&2
exit 65
}
[[ "$(id -un)" == "$RUNTIME_USER" ]] || {
echo "ERROR: must run as $RUNTIME_USER" >&2
exit 66
}
[[ "$(id -gn)" == "$RUNTIME_GROUP" || " $(id -Gn) " == *" $RUNTIME_GROUP "* ]] || {
echo "ERROR: user must be in $RUNTIME_GROUP" >&2
exit 67
}
[[ "$(umask)" == "0002" || "$(umask)" == "002" ]] || {
echo "ERROR: umask must be 0002" >&2
exit 68
}
[[ -d "$BARE" ]] || {
echo "ERROR: bare repository missing: $BARE" >&2
exit 69
}
PATHNAME="$WORKTREES/$AGENT/$TASK_ID"
BRANCH="task/$AGENT/$TASK_ID"
case "$ACTION" in
create)
[[ ! -e "$PATHNAME" ]] || {
echo "ERROR: worktree already exists: $PATHNAME" >&2
exit 70
}
git --git-dir="$BARE" fetch --all --prune
git --git-dir="$BARE" worktree add -b "$BRANCH" "$PATHNAME" "$BASE_REF"
install -d -m 2775 "$PATHNAME/.agent/tasks/$AGENT" "$PATHNAME/.agent/results/$AGENT"
setfacl -m "u:${RUNTIME_USER}:rwx,g:${RUNTIME_GROUP}:rwx,m::rwx" "$PATHNAME"
find "$PATHNAME" -type d -exec chmod g+s {} +
find "$PATHNAME" -type d -exec \
setfacl -m "d:u:${RUNTIME_USER}:rwx,d:g:${RUNTIME_GROUP}:rwx,d:m::rwx" {} +
printf 'worktree: %s\nbranch: %s\nbase: %s\n' "$PATHNAME" "$BRANCH" "$BASE_REF"
;;
remove)
[[ -d "$PATHNAME" ]] || {
echo "ERROR: worktree not found: $PATHNAME" >&2
exit 71
}
[[ -z "$(git -C "$PATHNAME" status --porcelain)" ]] || {
echo "ERROR: worktree is not clean; retain for investigation" >&2
exit 72
}
git --git-dir="$BARE" worktree remove "$PATHNAME"
printf 'removed: %s\n' "$PATHNAME"
;;
esac