Compare commits

..
13 Commits
Author SHA1 Message Date
weichen 21e897b7f0 Record the U1 independent review verdict
ci / test-pg (pull_request) Skipped
ci / svc-realcombo (pull_request) Skipped
ci / test (pull_request) Successful in 8m33s
Why: fresh-session pi-glm review returned REVIEW PASS with no blocking
findings; report landed via transport at
docs/superpowers/reviews/2026-10-22-container-storage-u1-review.md.

Evidence: NOT_RUN (review receipt referenced)

Impact: task card only.
2026-10-22 11:19:48 +00:00
agent-exec-transport e04b7877c3 agent-exec transport 99605f17561d82dabbbf47596ef94de8/b1dcdad669fd 2026-10-22 11:13:45 +00:00
weichen e1f08650a1 Record U1 completion in the task card
Why: unit implemented via antigravity worker transport and verified by
host (80 targeted + 574 affected tests pass).

Evidence: NOT_RUN (verification receipts imported above)

Impact: task card only.
2026-10-22 10:50:26 +00:00
agent-exec-transport 2d75d101a0 agent-exec transport ed39db43befedd8a049053096d6640be/144300034285 2026-10-22 10:41:05 +00:00
weichen 6aa19e0810 Record routing-unit success receipt in U1 card
Why: the antigravity promotion for the U1 unit landed via worker
transport 735148439e276dd92445c591fe459d82 (commit 0c8bea37).

Evidence: NOT_RUN (task card only)

Impact: task card only.
2026-10-22 10:22:16 +00:00
agent-exec-transport 0c8bea3748 agent-exec transport 735148439e276dd92445c591fe459d82/43861c25f995 2026-10-22 10:20:37 +00:00
weichen a094d66e83 Disclose killed run-5 attempt in U1 card
Why: host killed run 5 before its first attempt produced work to avoid
exceeding the cursor attempt cap; private run dir removed, disclosed
here rather than silently dropped.

Evidence: NOT_RUN (task card only)

Impact: task card only.
2026-10-22 10:10:29 +00:00
weichen 0db2645f2d Record U1 attempt-4 receipt and exhaust the cursor candidate
Why: fourth attempt completed the work with passing tests but again
appended prose after the terminal JSON; cursor attempts exhausted,
moving to antigravity per role order.

Evidence: NOT_RUN (task card receipt row only)

Impact: task card only.
2026-10-22 10:09:04 +00:00
weichen 15284f8b45 Record U1 attempt-3 failed-unknown receipt
Why: cursor CLI rejected --model auto at startup with an empty model
list; standalone CLI smoke works, judged transient.

Evidence: NOT_RUN (task card receipt row only)

Impact: task card only.
2026-10-22 10:01:14 +00:00
weichen 0d0d9f91f0 Record U1 attempt-2 failed-unknown receipt
Why: worker completed and verified the unit but appended prose after
the terminal JSON, so the controller could not decode a completed
record; scene preserved, diff identical to attempt-1 patch.

Evidence: NOT_RUN (task card receipt row only)

Impact: task card only.

Refs: /tmp/container-storage-u1-run2-receipt.json
2026-10-22 09:54:51 +00:00
weichen cb72983e02 Record U1 attempt-1 failed-unknown receipt
Why: cursor attempt stalled on transport after completing the work;
scene preserved and reconciled (no external side effects, diff exported
to /tmp/container-storage-u1-attempt1.patch).

Evidence: NOT_RUN (task card receipt row only)

Impact: task card only.

Refs: /tmp/container-storage-u1-run-receipt.json
2026-10-22 09:43:35 +00:00
weichen b73592d4b9 Fill the U1 child task card with goal and scope
Why: record the U1 (environment and data-ownership contract) goal,
boundaries and completion conditions before dispatching the unit.

Evidence: NOT_RUN (task card only)

Impact: task card only.

Refs: docs/superpowers/plans/2026-10-22-container-storage-plan.md
2026-10-22 09:14:24 +00:00
weichen 3f66da541d Record the task card for feature-container-storage-plan-u1
Why: establish the authorized task workspace and its initial baseline.

Evidence: preserved precheck receipt in .task/feature-container-storage-plan-u1.md; R0=955a32de2bb49171348bad5f566cce935df19212.

Impact: task metadata only; the feature workspace starts at b360bcb974.

Refs: .task/feature-container-storage-plan-u1.md
2026-10-22 09:12:44 +00:00
12 changed files with 1259 additions and 48 deletions
+178
View File
@@ -0,0 +1,178 @@
# feature/container-storage-plan-u1 任务卡
任务主记录:<PR 链接,交付前填写>;状态:进行中;owner:weichen
- 权威仓:ssh://git@192.168.3.2:2222/weichen/Agent-System-Dev.git;目标分支:main;R0(initial_base_sha)= 955a32de2bb49171348bad5f566cce935df19212
- 工作区:/home/weichen/projects/Agent-System-Dev-wt-feature-container-storage-plan-u1(worktree);分支:feature/container-storage-plan-u1
- 分支类型:child;initiative_id:feature-container-storage-plan;root_target:main;immediate_target:feature/container-storage-plan;accepting_requirements:false;parent_branch:feature/container-storage-plan;parent_head_sha:b360bcb9745e20d9d4f9f06c44a2936f47217ae4;parent_pr:无;parent_source:origin
- 需求分流:免(父 initiative 子任务登记已列出 feature/container-storage-plan-u1;parent_head_sha=b360bcb9745e20d9d4f9f06c44a2936f47217ae4)
- 交付模式:goal-first(目标、范围和交付终点一次确认;范围内连续推进)
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)
- 合入执行:agent(已授权交付范围内,Review 通过、冲突解决且当前 HEAD 的 CI 通过后合入)
- 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位)
- 授权与交付终点:在父 initiative 授权范围内实施方案 U1(环境与数据归属合同),交付终点为合入父分支 feature/container-storage-plan。不含:生产数据盘点/访问、部署、生产迁移、容器改造实施(U2 起)。
- 当前阶段:实施与自检
## 目标与非目标
**目标(方案 U1)**:扩展现有实例配置与 manifest,绑定基座库、RDM 领域库、知识库三个逻辑数据库、持久文件根和环境身份(prod/test/verify);产出全部必需持久数据所有者的归属清单(类型、源版本、目录、引用形式、唯一写者;不含秘密与业务内容)。
**主要落点**:`scripts/svc_manifest.py`、`scripts/svc.sh`、`docs/svc.md`、`PROJECT.md`;新增环境 schema/示例建议置 `deploy/`。测试扩展 `tests/test_svc_identity.py`、`tests/test_svc_lifecycle_isolation.py`、`tests/test_svc_precheck.py`。
**完成条件**:配置、归属清单和迁移范围有唯一解释;明确需迁移的每个数据所有者;旧配置升级保留身份;错库/错根/foreign marker/缺失必需绑定拒绝;同名项目或冲突端口在修改前失败;在另一 checkout 调用仍定位同一资源。
**非目标**:不实现容器启停(U2)、迁移执行(U3)、备份恢复包(U4)、克隆隔离(U5);不改业务权限模型;不引入第二份 DSN/卷权威(KTD3:manifest 唯一权威)。
## Agent 实施收据
路由与 fail-stop 规则见根目录 AGENT-EXECUTION.md 与 policy/delivery.md;每次尝试一行,原始日志留在源码外收据路径。
| 单元 ID | 预期范围 | 配置摘要 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|---|
| container-storage-u1-env-data-ownership | manifest/svc 绑定三库+文件根+环境身份;数据归属清单 | ccceb3bf… | implementation | cursor,antigravity,pi-glm,pi,codex | 1 | cursor(auto) | failed-unknown | 无(失败保留) | 否 | /tmp/container-storage-u1-run-receipt.json |
| container-storage-u1-env-data-ownership | 同上(采用 attempt1 补丁并验证) | ccceb3bf… | implementation | cursor,… | 2 | cursor(auto) | failed-unknown | 无(JSON 后带尾文解码失败,现场保留) | 否 | /tmp/container-storage-u1-run2-receipt.json |
| container-storage-u1-env-data-ownership | 同上 | ccceb3bf… | implementation | cursor,… | 3 | cursor(auto) | failed-unknown | 无(CLI 启动即报 Cannot use this model: auto,模型列表为空;单测 CLI 正常,判瞬时故障) | 否 | /tmp/container-storage-u1-run3-receipt.json |
| container-storage-u1-env-data-ownership | 同上 | ccceb3bf… | implementation | cursor,antigravity,… | 4 | cursor(auto) | failed-unknown | 无(工作完成且 80 测试通过,但终态 JSON 后再次附带尾文解码失败;cursor 累计 4 次尝试耗尽,转下一候选) | 否 | /tmp/container-storage-u1-run4-receipt.json |
| container-storage-u1-env-data-ownership | — | ccceb3bf… | implementation | (run5 于尝试启动前被 host 终止并删除其私有运行目录,无工作产物;目的:避免 cursor 超上限重试) | 5a | — | cancelled | 无 | 否 | 无(无收据;host 披露于本行) |
| container-storage-routing-promote-antigravity | AGENT-EXECUTION.md implementation 顺序临时调整 | ccceb3bf… | implementation | cursor,… | 1(第 2 次运行;第 1 次遇 cursor 模型列表瞬时为空 failed-unknown) | cursor(auto) | completed | apply(0c8bea37) | 否 | /tmp/container-storage-routing-receipt2.json |
| container-storage-u1-env-data-ownership | 同上(采用 attempt1 补丁内容,经 antigravity 全新会话独立重做并验证) | ccceb3bf… | implementation | antigravity,pi-glm,pi,cursor,codex | 1 | agy(gemini-3.8-flash-high) | completed | apply(2d75d101) | 否 | /tmp/container-storage-u1-run7-receipt.json |
| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at |
|---|---|---|---|
| 启动 | 955a32de2bb49171348bad5f566cce935df19212 | b360bcb9745e20d9d4f9f06c44a2936f47217ae4 | 2026-10-22T09:12:44Z |
## 证据(Evidence)
<!-- CMD="<命令>" make evidence 写入;手抄/转写输出视为无效证据(AGENTS.md 证据与验证门禁)。 -->
| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) |
|---|---|---|---|---|---|
## 结果与验证(完成后填写)
- 结果:U1 完成。manifest/svc 扩展三库+文件根+环境身份绑定与错误拒绝;deploy/data-ownership.md 归属清单与 env-bindings.example.env 示例;文档同步。经 agent-exec transport ed39db43 进入分支(2d75d101)。
- 验证:三套件 80 项 PASS(1 skip,SKIP 为环境缺 DSN 的既有用例);make test-affected 574 项 PASS(18 skip)。证据见上方两个导入收据(SELF)。容器真实运行验证属 U2,NOT_RUN。
- 影响与剩余项:无阻塞。生产数据盘点需授权(方案 U1 生产部分推迟到 O1 前核实)。
## 合入与清理
- Review 记录:REVIEW PASS(无阻塞项;N1—N8 非阻塞,报告 docs/superpowers/reviews/2026-10-22-container-storage-u1-review.md,transport 7f0f... 见 /tmp/container-storage-u1-review-receipt2.json)。审阅者:pi-glm 全新会话(fresh_session,非实施者);base 955a32de、head e1f08650;重放 80 项单测 OK
- 冲突记录:NOT_RUN(最新目标同步、冲突处置、组合兼容核验及复审/补测)
- CI 记录:NOT_RUN(当前 HEAD 完整 SHA、全部必需检查、运行链接、执行身份及 GATE 结果)
- 合入记录:NOT_RUN(交付授权范围、PR、被核验 HEAD、合入 SHA、目标分支及内容核验)
- 清理记录:NOT_RUN(授权依据、写者退出确认、worktree/分支/任务卡处置)
<!-- Local receipt: SELF. Digests detect corruption; they do not authenticate a reviewer or CI. -->
```json
{
"summary_schema": 1,
"source": "SELF",
"timestamp": "2026-10-22T09:11:55.612223+00:00",
"command": "make precheck",
"exit_code": 0,
"cwd": "/home/weichen/projects/Agent-System-Dev-wt-feature-container-storage-plan",
"before_head": "b360bcb9745e20d9d4f9f06c44a2936f47217ae4",
"after_head": "b360bcb9745e20d9d4f9f06c44a2936f47217ae4",
"branch": "feature/container-storage-plan",
"target_branch": "main",
"target_head": "955a32de2bb49171348bad5f566cce935df19212",
"root_target": "main",
"immediate_target": "main",
"before_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"after_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"output_tail": [
"make[1]: Entering directory '/home/weichen/projects/Agent-System-Dev-wt-feature-container-storage-plan'",
"[repo] branch=feature/container-storage-plan head=b360bcb974",
"[remote] origin/main=955a32de2b ahead=2 behind=0",
"[tree] clean submodules_drift=0 uninit=0",
"[worktrees] linked=6",
"[initiatives] local cards (not authoritative): feature/container-storage-plan",
"[hooks] core.hooksPath=scripts/hooks(pre-commit/post-merge 实际接线)",
"[facts] policy/facts.md 6 条事实均在有效期内",
"[required-reading] core/baseline.md, workflows/delivery.md §1, policy/delivery.md",
"[R0] 955a32de2b(完整:955a32de2bb49171348bad5f566cce935df19212)",
"[worktree] before-sync: recursive component checks passed",
"[verdict] READY — 保留本次 precheck receipt 后建任务工作区:PRECHECK_RECEIPT=/tmp/task-precheck.json make task-start NAME=feature/your-branch",
"make[1]: Leaving directory '/home/weichen/projects/Agent-System-Dev-wt-feature-container-storage-plan'"
],
"output_sha256": "58ef898d944057a44fb02e1fabb6acc2fd97943beed355977229d13711b2e6b2",
"receipt_sha256": "59e8b2e7b7032c19207a19f9427486439ca15250333e83ddd162db739c5dc395",
"receipt_path": "/home/weichen/.cache/agent-system-dev/evidence/59e8b2e7b7032c19207a19f9427486439ca15250333e83ddd162db739c5dc395.json"
}
```
<!-- Local receipt: SELF. Digests detect corruption; they do not authenticate a reviewer or CI. -->
```json
{
"summary_schema": 1,
"source": "SELF",
"timestamp": "2026-10-22T10:41:41.286181+00:00",
"command": "PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_svc_identity tests.test_svc_lifecycle_isolation tests.test_svc_precheck",
"exit_code": 0,
"cwd": "/home/weichen/projects/Agent-System-Dev-wt-feature-container-storage-plan-u1",
"before_head": "2d75d101a04ce1d1c9fa1d66d9567894222399d5",
"after_head": "2d75d101a04ce1d1c9fa1d66d9567894222399d5",
"branch": "feature/container-storage-plan-u1",
"target_branch": "main",
"target_head": "98ee59c477785b2d5094fcbea3e67a4dc864c388",
"root_target": "main",
"immediate_target": "feature/container-storage-plan",
"before_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"after_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"output_tail": [
"........................s.......................................................",
"----------------------------------------------------------------------",
"Ran 80 tests in 66.437s",
"",
"OK (skipped=1)"
],
"output_sha256": "1bc6f83d7e4c64a628d5f29bad07fc4e8cc2468937c66bda4bd9a5e00fc22587",
"receipt_sha256": "bfe74991277ebe308f667532e9b21af8215f453db124afeab1fc3d2115adf67d",
"receipt_path": "/home/weichen/.cache/agent-system-dev/evidence/bfe74991277ebe308f667532e9b21af8215f453db124afeab1fc3d2115adf67d.json"
}
```
<!-- Local receipt: SELF. Digests detect corruption; they do not authenticate a reviewer or CI. -->
```json
{
"summary_schema": 1,
"source": "SELF",
"timestamp": "2026-10-22T10:42:53.576009+00:00",
"command": "make test-affected",
"exit_code": 0,
"cwd": "/home/weichen/projects/Agent-System-Dev-wt-feature-container-storage-plan-u1",
"before_head": "2d75d101a04ce1d1c9fa1d66d9567894222399d5",
"after_head": "2d75d101a04ce1d1c9fa1d66d9567894222399d5",
"branch": "feature/container-storage-plan-u1",
"target_branch": "main",
"target_head": "98ee59c477785b2d5094fcbea3e67a4dc864c388",
"root_target": "main",
"immediate_target": "feature/container-storage-plan",
"before_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"after_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"output_tail": [
"test_invalid_json_is_denied (test_write_guard.WriteGuardTests.test_invalid_json_is_denied) ... ok",
"test_missing_parent_on_main_is_denied (test_write_guard.WriteGuardTests.test_missing_parent_on_main_is_denied) ... ok",
"test_missing_unknown_and_malformed_write_input_are_denied (test_write_guard.WriteGuardTests.test_missing_unknown_and_malformed_write_input_are_denied) ... ok",
"test_non_git_destination_is_explicitly_outside_scope (test_write_guard.WriteGuardTests.test_non_git_destination_is_explicitly_outside_scope) ... ok",
"test_nonwrite_tools_are_outside_scope (test_write_guard.WriteGuardTests.test_nonwrite_tools_are_outside_scope) ... ok",
"test_relative_path_uses_hook_cwd_not_process_cwd (test_write_guard.WriteGuardTests.test_relative_path_uses_hook_cwd_not_process_cwd) ... ok",
"test_symlink_into_main_with_missing_parents_is_denied (test_write_guard.WriteGuardTests.test_symlink_into_main_with_missing_parents_is_denied) ... ok",
"test_valid_multi_file_patch_on_feature_is_allowed (test_write_guard.WriteGuardTests.test_valid_multi_file_patch_on_feature_is_allowed) ... ok",
"",
"----------------------------------------------------------------------",
"Ran 574 tests in 400.853s",
"",
"OK (skipped=18)",
"make[2]: Leaving directory '/home/weichen/projects/Agent-System-Dev-wt-feature-container-storage-plan-u1'",
"make[1]: Leaving directory '/home/weichen/projects/Agent-System-Dev-wt-feature-container-storage-plan-u1'"
],
"output_sha256": "d738cef7d9ea78bcc4624e0fe8eb87b651e88712fcd283bb26c21755168604e9",
"receipt_sha256": "0565d5b327cb8d01e48deda867bd7369dd5728b5ad9024b51bbd6cb1a2fdf246",
"receipt_path": "/home/weichen/.cache/agent-system-dev/evidence/0565d5b327cb8d01e48deda867bd7369dd5728b5ad9024b51bbd6cb1a2fdf246.json"
}
```
+3 -1
View File
@@ -41,7 +41,9 @@ execution:
max_attempts_per_profile: 3
roles:
implementation: [cursor, antigravity, pi-glm, pi, codex]
# cursor 在 U1 单元已耗尽 4 次尝试:1 次传输中断、2 次终态 JSON 后附带尾文、1 次模型列表瞬时为空。
# 临时调整,交付前回退。
implementation: [antigravity, pi-glm, pi, cursor, codex]
repair: [cursor, antigravity, pi-glm, pi, codex]
test: [pi-glm, pi, antigravity, cursor, codex]
review: [pi-glm, pi, antigravity, cursor, codex]
+6 -1
View File
@@ -2,7 +2,10 @@
- 用途:开发集成父仓(gitlink 权威);非分发单元。
- 组合:`modules/`(产品组件)+ `deps/`(共享依赖);完整组件清单以 [`.gitmodules`](.gitmodules) 为准。
- 部署:非容器;Releases 仓从本仓固定提交导出版本。
- 部署:非容器为现行默认(`storage_mode=legacy`);容器化存储改造见
[容器化存储方案](docs/superpowers/plans/2026-10-22-container-storage-plan.md),
环境与数据归属合同(U1)见 [deploy/data-ownership.md](deploy/data-ownership.md)。
Releases 仓从本仓固定提交导出版本。
## 验证
@@ -56,6 +59,8 @@
[计划](modules/documents/docs/proposals/2026-10-12-work-companion-ui-plan.md) 覆盖任务摘要、对话/输入、窄屏和真实状态投影。
- [服务重启与全量数据恢复](modules/documents/docs/proposals/2026-10-12-service-restart-data-recovery.md):svc 启停、异常退出后的依赖、配置/数据身份、任务/HITL/副作用、文件与组合备份恢复;
U8 规格见 [SQLite 实例守护](docs/recovery/u8-sqlite-instance-guard.md)。
- [单一应用二进制与容器化数据库、知识库存储](docs/superpowers/plans/2026-10-22-container-storage-plan.md):两容器三库、外置文件根、环境独立绑定;
U1 归属清单见 [deploy/data-ownership.md](deploy/data-ownership.md)。
- [svc 统一部署与一次性登录 Key](modules/documents/docs/proposals/2026-10-12-svc-unified-deploy-and-login-key.md):
二进制/源码统一部署、环境准备、无初始密码开户与账户恢复;规格见 `docs/svc-deploy/`,
账户与恢复续审见 [续审记录](docs/svc-deploy/2026-10-12-account-durability-plan-review.md)。
+40
View File
@@ -0,0 +1,40 @@
# 容器化存储:数据归属清单(U1)
权威设计:`docs/superpowers/plans/2026-10-22-container-storage-plan.md`(U1、KTD1—KTD4)。
实例身份与期望态唯一权威:`runtime/<name>/manifest.json`(`scripts/svc_manifest.py`);
本清单只登记**数据所有者、类型、引用形式与唯一写者**,不含秘密值与业务内容。
生产体量/源版本盘点需另行授权访问后补全;未核实项不得据此声明迁移完成。
## 归属总表
| 所有者 | 类型 | 引用形式(登记键) | 唯一写者 | 迁移范围 |
|---|---|---|---|---|
| 基座逻辑库 | PostgreSQL 逻辑库(账号/项目/消息/文档/设置等基座表族) | `base_dbref` 或 `base_dsn_ref`(绝对路径秘密文件)+ `base_role_ref` + `base_port`;身份锚 `identity.base_db_ref` | 本实例应用进程(经基座 DSN);引导/迁移角色与应用角色分离(U2) | 必需 |
| RDM 领域库 | PostgreSQL 逻辑库(RDM 域表/项目文档树等) | `rdm_dbref` 或 `rdm_dsn_ref` + `rdm_role_ref` + `rdm_port`;锚 `identity.rdm_db_ref` | 本实例应用内 RDM 域(独立 DSN);非基座连接串 | 必需 |
| 知识库 | PostgreSQL/pgvector 逻辑库(语料、索引投影) | `kb_dbref` 或 `kb_dsn_ref` + `kb_role_ref` + `kb_port`;锚 `identity.kb_db_ref` | 本实例知识库接入(应用内);索引可重建但语料库为权威 | 必需 |
| 持久文件根 | 目录树(工件、RDM uploads、KB 原件及必要解析产物) | `files_root`(**绝对路径**,不相对 checkout/worktree 解析);锚 `identity.files_root` | 本实例应用进程;禁止他实例共用同一根 | 必需 |
| 环境身份 | 枚举 | `env_class` ∈ `prod`\|`test`\|`verify`;锚 `identity.env_class` | 运维显式登记;克隆时换新(U5) | 必需(container) |
| 实例身份 | 稳定 ID + 数据 marker | `instance_id`;`<data_root>/.agentx/data-marker.json` | `svc` manifest 治理;启动只校验不重建 | 必需 |
| Compose 项目 | 容器项目名 | `compose_project`;同 runtime 内唯一 | 本环境容器驱动(U2);冲突在修改前拒绝 | container 必需 |
`storage_mode=legacy`(缺省):沿用既有 SQLite / 宿主 PG(`db`/`dbref`/`dsn`)路径,不要求三库与 `files_root`。
`storage_mode=container`:上表 container 行全部必需;缺任一绑定、错库、错文件根、foreign marker、同名 Compose 或端口冲突 → **拒绝**,不静默建空库、不回退默认。
凭据只允许以受保护文件路径引用(`*_dsn_ref` / `*_role_ref`),明文不得入 manifest、日志或本清单。
## 配置形态
见同目录 [`env-bindings.example.env`](env-bindings.example.env)。写入实例:
```bash
# 先写齐绑定键,最后打开 container 合同(缺一即拒)
scripts/svc.sh <name> … # 经 svc_set_key / manifest set;Compose·端口冲突在修改前失败
```
另一 checkout/worktree 只要指向同一绝对 `files_root` 与库引用,仍定位同一资源(路径不相对仓库根解析)。
## 与旧恢复清单关系
细粒度基座表/插件枚举仍见 [`docs/recovery/u1-data-ownership.md`](../docs/recovery/u1-data-ownership.md)。
本文件是容器化三库+文件根的**合同级**归属;实施迁移(U3)时两者对照,必需所有者未覆盖则阻塞切换。
+31
View File
@@ -0,0 +1,31 @@
# 容器化存储实例绑定示例(U1)——写入 runtime/<name> 经 manifest 权威登记。
# 秘密只引用受保护文件路径,不在此填写明文密码/DSN 值。
# 完整合同见 deploy/data-ownership.md;schema 键由 scripts/svc_manifest.py 校验。
# legacy(缺省)| container(三库+文件根+环境身份全部必需)
storage_mode=container
# 环境身份:prod | test | verify(三选一)
env_class=prod
# 持久文件根:必须是绝对路径(不相对代码 checkout/worktree 解析)
files_root=/var/lib/agent-system/state/prod/files
# Compose 项目名:同机各环境唯一;冲突在修改前拒绝
compose_project=agentx-prod
# 基座逻辑库:dbref=<pg实例>/<库名> 或 dsn_ref=<绝对路径秘密文件>(二选一)
# base_dbref=pg-business/agent_base
base_dsn_ref=/etc/agent-system/prod/base.dsn
base_role_ref=/etc/agent-system/prod/base.role
base_port=15432
# RDM 领域库
rdm_dsn_ref=/etc/agent-system/prod/rdm.dsn
rdm_role_ref=/etc/agent-system/prod/rdm.role
rdm_port=15432
# 知识库(pgvector 容器独立端口)
kb_dsn_ref=/etc/agent-system/prod/kb.dsn
kb_role_ref=/etc/agent-system/prod/kb.role
kb_port=15433
@@ -0,0 +1,64 @@
# 独立 Review:容器化存储 U1(环境与数据归属合同)
- 日期:2026-10-22
- 审阅对象:`feature/container-storage-plan-u1` 分支 HEAD
- base:`955a32de2bb49171348bad5f566cce935df19212`
- head:`e1f08650a12656611f27438f23fddbfab6a96299`(分支尖端;实施内容经 agent-exec transport 提交 `2d75d101a04ce1d1c9fa1d66d9567894222399d5`,另有路由调整 transport 提交 `0c8bea37` 与任务卡记录提交)
- 送审 diff:`/tmp/container-storage-u1-review.diff`(已核对与 `git diff 955a32de..HEAD` 的代码部分逐字一致;仅 `.task/**` 任务卡不在 diff 内,属 host 直写豁免区)
- 方案依据:`docs/superpowers/plans/2026-10-22-container-storage-plan.md` U1 小节、KTD1—KTD4
- 审阅方式:全新会话独立审阅(非实施者);实际读代码核对绑定解析、拒绝路径与身份保留逻辑;未实施任何修复。
## 总体判定:REVIEW PASS
无阻塞项。U1 验收场景(旧配置升级保留身份、错库/错根/foreign marker 拒绝、同名项目或冲突端口修改前失败、另一 checkout 定位同一资源、缺必需绑定不静默新建空库)均有实现且被测试覆盖;KTD3 单一权威合同成立;无秘密入仓;测试 80/80 通过(1 项真实 PG 专项显式 NOT_RUN)。下列非阻塞项建议在 U2 及最终交付前处理。
## 阻塞项
无。
## 非阻塞项
| # | 级别 | 位置 | 发现 | 建议 |
|---|---|---|---|---|
| N1 | 建议 | `scripts/svc.sh` `svc_set_key`/`svc_del_key`(4674/4691 行起) | manifest `set` 因绑定校验或冲突被拒(exit 2/3/7)时,回退路径仍把该键写入派生视图 `svc.env`,整体操作"成功"返回。权威 manifest 未被污染(`resolve_storage_bindings` 只读 manifest),且两种漂移方向在启动时均 fail-closed(`svc.env=container`/`manifest=legacy` → `verify --require-storage` 触发 `完整存储合同要求 storage_mode=container` 拒启;反向漂移由 record_state 重建自愈),但"冲突在修改前失败"的语义被弱化为"权威拒绝+视图静默写入"。当前无任何调用方经 `svc_set_key` 写存储键(调用点仅 autostart/exec/dbref/dsn/conffile/addr/tags),风险处于潜伏状态。 | 存储键(STORAGE_KEYS 集合)在 manifest set 失败时硬失败,不走 legacy 单键回退。 |
| N2 | 建议 | `scripts/svc.sh` `svc_manifest_verify`(1302 行) | container 分支门控用 `svc_get storage_mode`(读派生 `svc.env`)而非 manifest identity。manifest=container 而 svc.env 过期(manifest 与 svc.env 两次原子写之间的崩溃窗口,或绕过 svc 手改 manifest)时,该次启动跳过存储校验;下次 record_state 经 bulk-set 重新派生后自愈。窗口窄且可自愈,但与"manifest 为权威"的门控意图不一致。 | 门控改为读 manifest identity 的 `storage_mode`(或在检测到两处不一致时拒绝并提示重建派生视图)。 |
| N3 | 建议 | `deploy/data-ownership.md`(持久文件根行) | 声明"禁止他实例共用同一根",但 `check_conflicts` 只扫描 compose 项目名与端口,不检测跨实例 files_root 重叠/嵌套;测试夹具(precheck `test_compose_project_conflict_refused`)中 b 的根位于 a 的根之下,同样未拦截。无 U1 验收场景要求此项,属文档强于实现。 | 收紧措辞为"登记层不拦截,容器挂载层(U2)负责隔离",或在 U2 增加跨实例根重叠预检。 |
| N4 | 记录 | `scripts/svc_manifest.py` `check_conflicts` | 冲突扫描范围限定同一 runtime 目录(`RUNTIME="$ROOT/runtime"`,`--runtime` 显式传入)。同机跨 checkout 的多个 runtime 目录之间的 compose 项目名/端口冲突在 U1 登记层不可见,实际碰撞推迟到 U2 容器启动时由端口占用/compose 报错暴露。实现注释与文档均按"同一 runtime"表述,范围自洽;U2 场景"prod/test/verify 同时运行互不冲突"已承接此项。 | U2 落地容器驱动时复核跨 runtime 同机冲突的最终防线。 |
| N5 | 建议 | `tests/` | 两个覆盖缺口:(a) 无 container 模式实例完整 start/stop 生命周期测试——`preserve_storage_keys`(record_state → bulk-set 写回时保住存储键)与 svc.sh 传 `--runtime` 的改动未被端到端 exercised,若该逻辑回归,绑定会在一次生命周期写回后被静默清掉;(b) `test_CS_U1_missing_binding_rejected` 只覆盖"全部缺失",未覆盖"三库中任一缺失"(如 base/rdm 齐备仅 kb 缺失)的逐槽拒绝。 | 各补一条测试:container 实例启停后锚点与 kv 绑定不变;逐槽缺失各断言一次。 |
| N6 | 记录 | `AGENT-EXECUTION.md`(implementation 角色顺序) | 不在 U1 单元 allowed_paths 内,属越界文件变更;但它是独立登记的路由单元(任务卡行 `container-storage-routing-promote-antigravity`,transport `0c8bea37`,收据 `/tmp/container-storage-routing-receipt2.json`),披露完整,且变更注释自述"临时调整,交付前回退"。 | 按其自述在 U1 合入/交付前回退;不阻塞 U1 评审结论。 |
| N7 | 记录 | `scripts/svc_manifest.py` | `slot_port_of` 只校验 `isdigit()`,接受 `0` 与超范围数值,未限 1—65535。 | 顺手项,U2 接真实容器端口发布前收紧即可。 |
| N8 | 记录 | `scripts/svc_manifest.py` `cmd_verify` | container 启动路径把 `resolve_storage_bindings`(源自 manifest)的值回传给 `verify` 比对,本质是 identity↔kv 自一致性检查(可拦手改 manifest 的不一致),并非运行时"连到外宾库"检测;后者按方案属 U2(启动顺序真实认证/身份检查)与 U3(库内身份)范围。U1 的"错库拒绝"在绑定锚点层面成立(`base 库绑定漂移` 路径有测试),范围声明准确。 | 无需改动;U2 接线时保留本检查作为快速失败层。 |
## 核对点(实际读代码/跑测试核对)
1. **旧配置升级保留身份**:`build()` 对已带 storage 键的 kv 只做形态校验(`require_complete=(mode==container)`),不重建 instance_id;`test_CS_U1_legacy_upgrade_keeps_identity` 断言升级后 `instance_id`/`data_root` 不变、锚点落 identity。✅
2. **身份保留/重锚**:`reanchor(write=False)` 预览仅用于冲突扫描、不落 marker;`mutate` 顺序为"校验 → 冲突扫描 → 重锚 → 落盘",冲突时不落盘;`slot_ref_of` 冲突(dbref 与 dsn_ref 同设)、相对路径 `*_dsn_ref`/`*_role_ref`/`files_root` 均拒绝;legacy→container 升级基座可回落既有 `dbref=` 专键,明文 `dsn=` 不进锚点(I5)。✅
3. **拒绝路径**:container 缺任一必需绑定在 set(`validate_storage_kv` exit 3)与 verify(`--require-storage` + `require_complete=True`)双端拒绝;错文件根/错库绑定漂移 exit 6(测试断言"文件根"/"base 库绑定漂移");foreign marker 沿用 base 逻辑(T5b);compose 项目名/端口冲突 exit 7 且发生在落盘前(`check_conflicts` 在 `save` 之前)。✅
4. **不静默新建空库**:U1 层无任何默认绑定生成;`resolve_storage_bindings` 缺绑定 exit 3 → svc.sh 拒绝;manifest 缺失时 verify exit 4、marker 缺失/损坏 exit 5,均阻塞启动。未发现静默回退空库路径。✅
5. **另一 checkout 定位同一资源**:`files_root` 强制绝对路径并 normpath,锚点/解析不依赖 cwd 与仓库根;`test_CS_U1_other_checkout_keeps_absolute_files_root` 与 T6 从外部 cwd 调用验证。✅
6. **调用方环境污染**:`env_config` 重置存储全局并 `unset AGENT_SYSTEM_FILES_ROOT/ENV_CLASS/COMPOSE_PROJECT/BASE_DB_REF/RDM_DB_REF/KB_DB_REF`;`test_caller_storage_env_does_not_reach_serve` 检查 serve 进程 environ 与 serve.env;三条棘轮测试守卫 env_config。✅
7. **KTD3 单一权威**:`resolve_storage_bindings` 内嵌 python 直读 `manifest.json`(不读 svc.env、不读调用方 env);U1 未引入 compose 文件/第二份卷或 DSN 权威;凭据仅存受保护文件路径引用。`svc.env` 仍是派生只读视图(record_state 不产出存储键,`preserve_storage_keys` 自 manifest 旧 kv 保留)。✅
8. **归属清单与契约文档**:`deploy/data-ownership.md` 覆盖基座/RDM/KB/文件根/环境身份/实例身份/Compose 项目七类所有者,含引用形式与唯一写者;体量/源版本/表清单明示"需另行授权访问后补全"(与方案"生产盘点需先获得对应访问授权"及诚实出口一致);无秘密值。`docs/svc.md` §10.1 键表与 `PROJECT.md` 指针已同步;链接目标 `docs/recovery/u1-data-ownership.md`、`docs/recovery/u1-identity-manifest.md` 均存在。✅
9. **测试充分性**:三测试文件合计覆盖全部五条 U1 验收场景 + 调用方隔离 + 原子写崩溃窗口;缺口仅 N5 两项(均非验收场景直接要求)。`PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_svc_identity tests.test_svc_lifecycle_isolation tests.test_svc_precheck` → **80 tests, OK (skipped=1)**,唯一 skip 为真实 PG 专项显式 NOT_RUN(诚实标注)。✅
10. **秘密与越界**:diff 无明文口令/DSN/token(示例 env 仅受保护路径,测试仅 fixture 值 `ref-only`/`x`);changed files 中除 `AGENT-EXECUTION.md`(见 N6,已另行登记)外全部落在 U1 主要文件/allowed_paths 内;`.task/**` 为 host 直写豁免。✅
## 复现命令
```bash
# 1) 送审 diff 范围与实际 HEAD diff 一致(代码部分逐字相同)
git diff 955a32de2bb49171348bad5f566cce935df19212 HEAD \
-- AGENT-EXECUTION.md PROJECT.md deploy docs/superpowers/plans docs/svc.md scripts tests \
| diff - /tmp/container-storage-u1-review.diff && echo IDENTICAL
# 2) 单元测试(本机实测:Ran 80 tests, OK (skipped=1),约 64s)
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest \
tests.test_svc_identity tests.test_svc_lifecycle_isolation tests.test_svc_precheck
# 3) 对象 SHA
git rev-parse HEAD # e1f08650a12656611f27438f23fddbfab6a96299
git rev-parse 955a32de # 955a32de2bb49171348bad5f566cce935df19212
```
## 结论
U1 单元按方案完成且质量合格:绑定/身份权威唯一、拒绝路径完整且 fail-closed、验收场景全部落地并有测试背书。上述 N1—N8 均不阻塞本单元交付;N1/N2/N5 建议随 U2 或合入前收口,N6 的路由临时调整须按其自述在交付前回退。
+12
View File
@@ -866,9 +866,21 @@ scripts/svc.sh prod deploy --env .env.prod # 等价
| `dbref` | agent 托管数据库绑定 `<pg名>/<库名>`(启动时由实例登记重建 DSN;见 §4.5) |
| `dsn` | agent 外部直连 DSN(受保护;未命中本机实例登记的直连存放于此) |
| `conffile` | agent 配置文件绝对路径(`<name> config` 命令写入,跨重启保留;见 §4.6) |
| `storage_mode` | `legacy`(缺省,SQLite/宿主 PG)/ `container`(三库+文件根合同;见 [deploy/data-ownership.md](../deploy/data-ownership.md)) |
| `env_class` | 环境身份:`prod` / `test` / `verify`(container 必需) |
| `files_root` | 持久文件根**绝对路径**(不相对 checkout 解析;container 必需) |
| `compose_project` | Compose 项目名(同 runtime 唯一;冲突在修改前拒绝) |
| `base_dbref` / `base_dsn_ref` / `base_role_ref` / `base_port` | 基座逻辑库绑定(dsn/role 仅为受保护文件路径引用) |
| `rdm_dbref` / `rdm_dsn_ref` / `rdm_role_ref` / `rdm_port` | RDM 领域库绑定 |
| `kb_dbref` / `kb_dsn_ref` / `kb_role_ref` / `kb_port` | 知识库绑定 |
| `user` / `password` / `dbname` / `authmode` / `pgdata` | postgres 专属 |
| `htdocs` | static 专属 |
容器存储绑定由 `manifest.json` 冻结为身份锚点(与 `data_root`/`db_*` 同级);旧配置
升级保留 `instance_id`。`storage_mode=container` 时缺任一必需绑定、错库、错文件根、
foreign marker、同名 Compose 或端口冲突均拒绝启动/修改,不静默建空库、不回退默认。
示例键见 [`deploy/env-bindings.example.env`](../deploy/env-bindings.example.env)。
### 10.2 history.jsonl 事件
一行一个 JSON:`ts / event / env / kind / source / result / pid / addr / detail`。
+103 -10
View File
@@ -1012,8 +1012,9 @@ record_state() {
chmod 600 "$tmp"
# U1 身份:manifest 治理下的整份提交(bulk-set 走 temp→fsync→rename 原子链,
# revision 递增,svc.env 由 manifest 派生);失败保留旧配置,不产生半写态。
# 存储绑定键(STORAGE_KEYS)由 manifest bulk-set 自旧 kv 保留,避免生命周期写回冲掉。
if manifest_ok; then
if python3 "$MANIFEST_PY" "$ENV_DIR" bulk-set < "$tmp"; then
if python3 "$MANIFEST_PY" "$ENV_DIR" bulk-set --runtime "$RUNTIME" < "$tmp"; then
rm -f "$tmp"
return 0
fi
@@ -1217,18 +1218,103 @@ svc_manifest_migrate() {
c_err "[$ENV_NAME] manifest 迁移失败(保持旧治理):$(printf '%s' "$out" | tail -1)"
return 0
}
# resolve_storage_bindings:从本实例 manifest 权威解析容器存储绑定(不读调用方环境)。
# 结果全局:STORAGE_MODE ENV_CLASS FILES_ROOT COMPOSE_PROJECT
# BASE_DB_REF RDM_DB_REF KB_DB_REF BASE_PORT RDM_PORT KB_PORT
# storage_mode=container 且缺任一必需绑定时返回 1(拒绝静默默认/建空库);
# legacy / 未配置返回 0 且 STORAGE_MODE=legacy。
resolve_storage_bindings() {
STORAGE_MODE=legacy; ENV_CLASS=""; FILES_ROOT=""; COMPOSE_PROJECT=""
BASE_DB_REF=""; RDM_DB_REF=""; KB_DB_REF=""
BASE_PORT=""; RDM_PORT=""; KB_PORT=""
manifest_ok || return 0
local out rc=0
out="$(python3 - "$ENV_DIR" <<'PY' 2>&1
import json, os, sys
env_dir = sys.argv[1]
path = os.path.join(env_dir, "manifest.json")
with open(path, encoding="utf-8") as f:
m = json.load(f)
ident = m.get("identity") or {}
kv = m.get("kv") or {}
mode = (kv.get("storage_mode") or ident.get("storage_mode") or "legacy").strip() or "legacy"
print("MODE=%s" % mode)
print("ENV_CLASS=%s" % (kv.get("env_class") or ident.get("env_class") or ""))
print("FILES_ROOT=%s" % (kv.get("files_root") or ident.get("files_root") or ""))
print("COMPOSE_PROJECT=%s" % (kv.get("compose_project") or ident.get("compose_project") or ""))
print("BASE_DB_REF=%s" % (ident.get("base_db_ref") or ""))
print("RDM_DB_REF=%s" % (ident.get("rdm_db_ref") or ""))
print("KB_DB_REF=%s" % (ident.get("kb_db_ref") or ""))
print("BASE_PORT=%s" % (kv.get("base_port") or ident.get("base_port") or ""))
print("RDM_PORT=%s" % (kv.get("rdm_port") or ident.get("rdm_port") or ""))
print("KB_PORT=%s" % (kv.get("kb_port") or ident.get("kb_port") or ""))
if mode == "container":
missing = []
if (kv.get("env_class") or ident.get("env_class") or "") not in ("prod", "test", "verify"):
missing.append("env_class")
fr = kv.get("files_root") or ident.get("files_root") or ""
if not fr or not os.path.isabs(fr):
missing.append("files_root")
if not (kv.get("compose_project") or ident.get("compose_project") or ""):
missing.append("compose_project")
for slot, key in (("base", "base_db_ref"), ("rdm", "rdm_db_ref"), ("kb", "kb_db_ref")):
if not (ident.get(key) or ""):
missing.append(slot + "_db")
if not (kv.get(slot + "_port") or ident.get(slot + "_port") or ""):
missing.append(slot + "_port")
if not (kv.get(slot + "_role_ref") or ""):
missing.append(slot + "_role_ref")
if missing:
print("MISSING=%s" % ",".join(missing), file=sys.stderr)
sys.exit(3)
PY
)" || rc=$?
if [ "$rc" -ne 0 ]; then
c_err "[$ENV_NAME] 存储绑定不完整或无效,拒绝(不静默新建空库/回退默认):$(printf '%s' "$out" | tail -1)"
return 1
fi
local line
while IFS= read -r line; do
case "$line" in
MODE=*) STORAGE_MODE="${line#MODE=}" ;;
ENV_CLASS=*) ENV_CLASS="${line#ENV_CLASS=}" ;;
FILES_ROOT=*) FILES_ROOT="${line#FILES_ROOT=}" ;;
COMPOSE_PROJECT=*) COMPOSE_PROJECT="${line#COMPOSE_PROJECT=}" ;;
BASE_DB_REF=*) BASE_DB_REF="${line#BASE_DB_REF=}" ;;
RDM_DB_REF=*) RDM_DB_REF="${line#RDM_DB_REF=}" ;;
KB_DB_REF=*) KB_DB_REF="${line#KB_DB_REF=}" ;;
BASE_PORT=*) BASE_PORT="${line#BASE_PORT=}" ;;
RDM_PORT=*) RDM_PORT="${line#RDM_PORT=}" ;;
KB_PORT=*) KB_PORT="${line#KB_PORT=}" ;;
esac
done <<<"$out"
return 0
}
# svc_manifest_verify:启动前身份校验——数据 marker 存在且 instance_id 匹配;
# 生效 --data / 数据库绑定与冻结锚点一致。失败即拒绝启动(KTD1/KTD2,I4)。
# 顺序:文件 marker → DB verify(缺/错即拦)。ensure 只在领养(svc_manifest_migrate)
# 与显式 init——普通启动不建身份,缺行即事件(修复路径:svc init,或换具备
# identity 能力的产物更新时由候选门禁补齐)。
# 顺序:文件 marker →(container)存储绑定完整/无漂移 → DB verify(缺/错即拦)。
# ensure 只在领养(svc_manifest_migrate)与显式 init——普通启动不建身份,缺行即事件。
svc_manifest_verify() {
manifest_ok || return 0
local out
if ! out="$(python3 "$MANIFEST_PY" "$ENV_DIR" verify \
--data "$ENV_DATA" --db-mode "${DB_MODE:-}" --db-ref "${DB_REF:-}" 2>&1)"; then
local out verify_args storage_mode
verify_args=(--data "$ENV_DATA" --db-mode "${DB_MODE:-}" --db-ref "${DB_REF:-}")
storage_mode="$(svc_get storage_mode)"
if [ "$storage_mode" = container ]; then
# 生效值只来自本实例登记(env_config 已剥离调用方污染);缺绑/错根/错库拒绝。
resolve_storage_bindings || return 1
verify_args+=(--require-storage
--files-root "$FILES_ROOT" --env-class "$ENV_CLASS"
--compose-project "$COMPOSE_PROJECT"
--base-db-ref "$BASE_DB_REF" --rdm-db-ref "$RDM_DB_REF" --kb-db-ref "$KB_DB_REF")
python3 "$MANIFEST_PY" "$ENV_DIR" check-conflicts --runtime "$RUNTIME" >/dev/null || {
c_err "[$ENV_NAME] 存储绑定冲突,拒绝启动(Compose 项目名或端口与他实例冲突)"
return 1
}
fi
if ! out="$(python3 "$MANIFEST_PY" "$ENV_DIR" verify "${verify_args[@]}" 2>&1)"; then
c_err "[$ENV_NAME] 身份校验失败,拒绝启动:$(printf '%s' "$out" | tail -1)"
c_err " 数据根/数据库绑定是冻结身份;换根/重绑须显式迁移或登记命令(docs/recovery/u1-identity-manifest.md §5)"
c_err " 数据根/数据库/文件根绑定是冻结身份;换根/重绑须显式迁移或登记命令(docs/recovery/u1-identity-manifest.md §5;容器存储见 deploy/data-ownership.md)"
return 1
fi
# 文件校验通过后再碰库。fresh 允许库尚未建立;established 只验证;
@@ -1704,12 +1790,18 @@ env_config() {
# update --all:dev 成功后 genui/rdm1/rdmcand 端口仍被孤儿占用)。
SVC_DB_MODE=""; SVC_DB_SOURCE=""; SVC_DB_REF=""; SVC_DB_DSN_SAVE=""
SVC_PIDCHECK=""; SVC_EXEC=""; SVC_VERSION=""; SVC_SOURCE=""
STORAGE_MODE=""; ENV_CLASS=""; FILES_ROOT=""; COMPOSE_PROJECT=""
BASE_DB_REF=""; RDM_DB_REF=""; KB_DB_REF=""
BASE_PORT=""; RDM_PORT=""; KB_PORT=""
# U1:身份环境同样按实例重置——上一服务 verify 成功后导出的
# AGENT_SYSTEM_INSTANCE_ID/DBREF 不得串给下一服务(批量 update 单进程逐服务解析;
# 2026-10-12 实况:dev 的身份漏给 rdm1/rdmcand,叠加 RDM 产物无 identity 子命令
# 跳过校验,serve 带外宾身份即被基座门禁拒绝)。serve 只允许拿到本服务 verify
# 写入的身份。
# 容器存储 U1:调用方/上一服务的文件根、环境身份、Compose 与三库引用不得污染本实例。
unset AGENT_SYSTEM_INSTANCE_ID AGENT_SYSTEM_DBREF
unset AGENT_SYSTEM_FILES_ROOT AGENT_SYSTEM_ENV_CLASS AGENT_SYSTEM_COMPOSE_PROJECT
unset AGENT_SYSTEM_BASE_DB_REF AGENT_SYSTEM_RDM_DB_REF AGENT_SYSTEM_KB_DB_REF
local registered=""
[ -f "$ENV_DIR/svc.env" ] && registered="$(svc_kind "$name")"
SVC_KIND="${registered:-agent}"
@@ -4578,10 +4670,11 @@ check_port_available() {
# svc_set_key KEY VALUE:改写当前服务 svc.env 的单键。manifest 治理下经 manifest
# 整份提交(原子 + revision);legacy 路径保持先读后写 + 临时文件原子替换。
# 存储绑定键变更经 --runtime 做 Compose/端口冲突预检(修改前失败)。
svc_set_key() {
local key="$1" val="$2" rest tmp
if manifest_ok; then
python3 "$MANIFEST_PY" "$ENV_DIR" set "$key" "$val" && return 0
python3 "$MANIFEST_PY" "$ENV_DIR" set --runtime "$RUNTIME" "$key" "$val" && return 0
c_warn "[$ENV_NAME] manifest set 失败,回退 legacy 单键写"
fi
[ -f "$ENV_DIR/svc.env" ] || { c_err "svc.env 不存在:$ENV_DIR"; return 1; }
@@ -4598,7 +4691,7 @@ svc_set_key() {
svc_del_key() {
local key="$1"
if manifest_ok; then
python3 "$MANIFEST_PY" "$ENV_DIR" del "$key" && return 0
python3 "$MANIFEST_PY" "$ENV_DIR" del --runtime "$RUNTIME" "$key" && return 0
c_warn "[$ENV_NAME] manifest del 失败,回退 legacy 单键写"
fi
[ -f "$ENV_DIR/svc.env" ] || return 0
+399 -36
View File
@@ -1,16 +1,19 @@
#!/usr/bin/env python3
"""svc 实例身份 manifest(U1):manifest.json 为启动权威,svc.env 为派生只读视图。
设计合同:docs/recovery/u1-identity-manifest.md(已批准 2026-10-12)。
设计合同:docs/recovery/u1-identity-manifest.md(已批准 2026-10-12);
容器化存储扩展:docs/superpowers/plans/2026-10-22-container-storage-plan.md U1/KTD1—KTD4。
落地说明:v1.0 以「identity 锚点(数据根/数据库身份,冻结)+ kv 权威键值映射 +
desired 期望态」承载 KTD1/KTD2 的全部不变量;完整 typed 分区随 U2/U6 演进。
desired 期望态」承载不变量;本文件在同一权威上扩展三库/文件根/环境身份绑定,
不另造第二份登记权威。完整 typed 分区与容器启停属后续单元。
用法: svc_manifest.py ENV_DIR <migrate|init|status|set|del|bulk-set|set-desired|verify> [参数]
用法: svc_manifest.py ENV_DIR <migrate|init|status|set|del|bulk-set|set-desired|verify|check-conflicts> [参数]
不变量(对应设计 §1):
不变量(对应设计 §1 + 容器存储 U1):
I3 原子性 —— 整份 temp→fsync→rename→dirsync,任意时刻只有完整旧版或新版。
I4 阻塞 —— manifest 存在时,数据 marker 缺失/身份不符即拒绝,不新建空系统。
I5 机密 —— 本文件不新增任何机密;kv 沿袭 svc.env 既有内容(DSN 迁出属 U6)。
I5 机密 —— 凭据只存受保护文件路径引用(*_dsn_ref / *_role_ref),不存明文。
"""
import json
import os
@@ -22,7 +25,27 @@ SCHEMA = 1
MANIFEST = "manifest.json"
DERIVED = "svc.env"
MARKER_REL = os.path.join(".agentx", "data-marker.json")
PROTECTED = ("db", "dbref", "dsn", "conffile") # 显式持久化写入可重锚;启动期漂移由 verify 拦截
# 显式持久化写入可重锚;启动期漂移由 verify 拦截。含容器存储绑定键。
PROTECTED = (
"db", "dbref", "dsn", "conffile",
"storage_mode", "env_class", "files_root", "compose_project",
"base_dbref", "base_dsn_ref", "base_role_ref", "base_port",
"rdm_dbref", "rdm_dsn_ref", "rdm_role_ref", "rdm_port",
"kb_dbref", "kb_dsn_ref", "kb_role_ref", "kb_port",
)
# record_state/bulk-set 重建时须保留的存储绑定键(避免被生命周期写回冲掉)。
STORAGE_KEYS = (
"storage_mode", "env_class", "files_root", "compose_project",
"base_dbref", "base_dsn_ref", "base_role_ref", "base_port",
"rdm_dbref", "rdm_dsn_ref", "rdm_role_ref", "rdm_port",
"kb_dbref", "kb_dsn_ref", "kb_role_ref", "kb_port",
)
ENV_CLASSES = ("prod", "test", "verify")
DB_SLOTS = ("base", "rdm", "kb")
STORAGE_MODES = ("legacy", "container")
def die(msg, code=1):
@@ -116,6 +139,144 @@ def db_anchor_of(kv):
return "", ""
def storage_mode_of(kv):
mode = (kv.get("storage_mode") or "").strip() or "legacy"
if mode not in STORAGE_MODES:
die("storage_mode 仅允许 legacy|container,收到:%s" % mode, 2)
return mode
def files_root_of(kv):
"""持久文件根:必须已是绝对路径;绝不相对 checkout/worktree 解析。"""
fr = (kv.get("files_root") or "").strip()
if not fr:
return ""
if not os.path.isabs(fr):
die("files_root 必须是绝对路径(不相对 checkout 解析):%s" % fr, 2)
return os.path.normpath(fr)
def slot_ref_of(kv, slot):
"""逻辑库引用锚点:优先 dbref,否则 dsn_ref(受保护文件路径);明文 dsn 禁止。"""
dbref = (kv.get("%s_dbref" % slot) or "").strip()
dsn_ref = (kv.get("%s_dsn_ref" % slot) or "").strip()
if dbref and dsn_ref:
die("%s 绑定冲突:不可同时设置 %s_dbref 与 %s_dsn_ref" % (slot, slot, slot), 2)
if dbref:
return "dbref:%s" % dbref
if dsn_ref:
if not os.path.isabs(dsn_ref):
die("%s_dsn_ref 必须是绝对路径(秘密文件引用):%s" % (slot, dsn_ref), 2)
return "dsn_ref:%s" % os.path.normpath(dsn_ref)
# 基座可回落既有 dbref/dsn 专键(legacy→container 升级兼容,dsn 明文不进锚点)
if slot == "base":
legacy = (kv.get("dbref") or "").strip()
if legacy:
return "dbref:%s" % legacy
# 明文 dsn= 不是引用;container 模式不得用其充当绑定
return ""
def slot_port_of(kv, slot):
return (kv.get("%s_port" % slot) or "").strip()
def slot_role_ref_of(kv, slot):
role = (kv.get("%s_role_ref" % slot) or "").strip()
if not role:
return ""
if not os.path.isabs(role):
die("%s_role_ref 必须是绝对路径(角色秘密文件引用):%s" % (slot, role), 2)
return os.path.normpath(role)
def validate_storage_kv(kv, require_complete=None):
"""校验存储绑定形态。container 模式缺任一必需绑定即拒绝(不静默默认/建空库)。"""
mode = storage_mode_of(kv)
if require_complete is None:
require_complete = mode == "container"
env_class = (kv.get("env_class") or "").strip()
if env_class and env_class not in ENV_CLASSES:
die("env_class 仅允许 prod|test|verify,收到:%s" % env_class, 2)
files_root = files_root_of(kv) # 相对路径在此失败
compose = (kv.get("compose_project") or "").strip()
if compose and (("/" in compose) or (".." in compose) or (" " in compose)):
die("compose_project 非法(禁止路径分隔/空格):%s" % compose, 2)
for slot in DB_SLOTS:
port = slot_port_of(kv, slot)
if port and not port.isdigit():
die("%s_port 必须是正整端口号,收到:%s" % (slot, port), 2)
slot_ref_of(kv, slot) # 冲突/相对路径检查
slot_role_ref_of(kv, slot)
if not require_complete:
return mode
# container 完整合同
if mode != "container":
die("完整存储合同要求 storage_mode=container", 2)
if env_class not in ENV_CLASSES:
die("container 模式缺少必需绑定 env_class(prod|test|verify)", 3)
if not files_root:
die("container 模式缺少必需绑定 files_root(绝对路径)", 3)
if not compose:
die("container 模式缺少必需绑定 compose_project", 3)
for slot in DB_SLOTS:
ref = slot_ref_of(kv, slot)
port = slot_port_of(kv, slot)
role = slot_role_ref_of(kv, slot)
if not ref:
die("container 模式缺少必需绑定 %s 库(%s_dbref 或 %s_dsn_ref)"
% (slot, slot, slot), 3)
if not port:
die("container 模式缺少必需绑定 %s_port" % slot, 3)
if not role:
die("container 模式缺少必需绑定 %s_role_ref(角色秘密文件路径)" % slot, 3)
return mode
def storage_anchor_of(kv):
"""从 kv 计算冻结的存储身份锚点(写入 identity;legacy 为空串)。"""
mode = storage_mode_of(kv)
if mode == "legacy" and not any(kv.get(k) for k in STORAGE_KEYS if k != "storage_mode"):
return {
"storage_mode": "legacy",
"env_class": "",
"files_root": "",
"compose_project": "",
"base_db_ref": "",
"base_port": "",
"rdm_db_ref": "",
"rdm_port": "",
"kb_db_ref": "",
"kb_port": "",
}
# 非完整 container 时仍记录已显式写入的锚点(供漂移检测);完整校验另走 validate
if mode == "container":
validate_storage_kv(kv, require_complete=True)
else:
validate_storage_kv(kv, require_complete=False)
return {
"storage_mode": mode,
"env_class": (kv.get("env_class") or "").strip(),
"files_root": files_root_of(kv),
"compose_project": (kv.get("compose_project") or "").strip(),
"base_db_ref": slot_ref_of(kv, "base"),
"base_port": slot_port_of(kv, "base"),
"rdm_db_ref": slot_ref_of(kv, "rdm"),
"rdm_port": slot_port_of(kv, "rdm"),
"kb_db_ref": slot_ref_of(kv, "kb"),
"kb_port": slot_port_of(kv, "kb"),
}
def preserve_storage_keys(new_kv, prev_kv):
"""bulk-set/生命周期写回时保留未出现在新写集中的存储绑定键。"""
out = dict(new_kv)
for k in STORAGE_KEYS:
if k not in out and k in prev_kv:
out[k] = prev_kv[k]
return out
def marker_path(data_root):
return os.path.join(data_root, MARKER_REL)
@@ -140,13 +301,18 @@ def read_marker(data_root):
return {"schema": -1}
def reanchor(env_dir, m):
"""按显式持久化写入重算身份锚点;数据根变化时在新根补写 marker(目录存在才写)。"""
def reanchor(env_dir, m, write=True):
"""按显式持久化写入重算身份锚点;数据根变化时在新根补写 marker(目录存在才写)。
write=False 仅预览身份(冲突扫描用),不落 marker。
"""
ident = dict(m["identity"])
ident["data_root"] = data_root_of(m["kv"], env_dir)
ident["db_mode"], ident["db_ref"] = db_anchor_of(m["kv"])
for k, v in storage_anchor_of(m["kv"]).items():
ident[k] = v
if ident != m["identity"]:
if os.path.isdir(ident["data_root"]):
if write and os.path.isdir(ident["data_root"]):
write_marker(ident["data_root"], m["instance_id"])
m["identity"] = ident
return m
@@ -160,6 +326,9 @@ def build(env_dir, kv, allow_create):
if not allow_create:
die("数据根不存在且非显式 init,拒绝建立身份(防新建空系统掩盖丢数据):%s" % data_root, 3)
os.makedirs(data_root, exist_ok=True)
# 旧配置升级:若已带 storage 键则校验;纯 legacy 不强制 container 合同
if storage_mode_of(kv) == "container" or any(kv.get(k) for k in STORAGE_KEYS):
validate_storage_kv(kv, require_complete=(storage_mode_of(kv) == "container"))
existing = read_marker(data_root)
if existing is not None:
if existing.get("instance_id") != kv.get("instance_id"):
@@ -170,6 +339,14 @@ def build(env_dir, kv, allow_create):
instance_id = kv.get("instance_id") or "svc-%s-%s" % (name, secrets.token_hex(6))
write_marker(data_root, instance_id)
db_mode, db_ref = db_anchor_of(kv)
storage = storage_anchor_of(kv)
identity = {
"data_root": data_root,
"db_mode": db_mode,
"db_ref": db_ref,
"db_marker": "fresh",
}
identity.update(storage)
m = {
"schema": SCHEMA,
"revision": 1,
@@ -177,18 +354,114 @@ def build(env_dir, kv, allow_create):
"service_group": name,
"created_at": now_iso(),
"desired": "running" if kv.get("autostart") == "1" else "stopped",
"identity": {
"data_root": data_root,
"db_mode": db_mode,
"db_ref": db_ref,
"db_marker": "fresh",
},
"identity": identity,
"kv": {k: v for k, v in kv.items() if k != "autostart"},
}
save(env_dir, m)
return m
def runtime_dir_of(env_dir):
return os.path.dirname(os.path.abspath(env_dir))
def iter_sibling_manifests(env_dir, runtime_dir=None):
rt = os.path.abspath(runtime_dir) if runtime_dir else runtime_dir_of(env_dir)
self_abs = os.path.abspath(env_dir)
if not os.path.isdir(rt):
return
for name in sorted(os.listdir(rt)):
d = os.path.join(rt, name)
if not os.path.isdir(d) or os.path.abspath(d) == self_abs:
continue
path = os.path.join(d, MANIFEST)
if not os.path.isfile(path):
# 兼容尚未 migrate 的 sibling:读 svc.env 中的存储键
env_path = os.path.join(d, DERIVED)
if not os.path.isfile(env_path):
continue
kv = {}
with open(env_path, encoding="utf-8") as f:
for line in f:
line = line.rstrip("\n")
if line and "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
kv[k] = v
yield name, {
"instance_id": kv.get("instance_id", ""),
"identity": storage_anchor_of(kv) if any(kv.get(k) for k in STORAGE_KEYS) else {},
"kv": kv,
}
continue
try:
with open(path, encoding="utf-8") as f:
yield name, json.load(f)
except Exception: # noqa: BLE001 —— 损坏 sibling 不阻断本实例冲突扫描语义以外的操作
continue
def binding_ports_from(m_or_kv, is_manifest=True):
if is_manifest:
kv = m_or_kv.get("kv") or {}
ident = m_or_kv.get("identity") or {}
else:
kv = m_or_kv
ident = {}
ports = set()
for slot in DB_SLOTS:
p = (kv.get("%s_port" % slot) or "").strip() or (ident.get("%s_port" % slot) or "").strip()
if p:
ports.add(p)
return ports
def compose_of(m_or_kv, is_manifest=True):
if is_manifest:
kv = m_or_kv.get("kv") or {}
ident = m_or_kv.get("identity") or {}
return (kv.get("compose_project") or ident.get("compose_project") or "").strip()
return (m_or_kv.get("compose_project") or "").strip()
def check_conflicts(env_dir, m, runtime_dir=None):
"""同一 runtime 下 Compose 项目名或存储端口冲突 → 修改前失败。"""
kv = m["kv"]
ident = m.get("identity") or {}
compose = (kv.get("compose_project") or ident.get("compose_project") or "").strip()
ports = binding_ports_from(m, is_manifest=True)
if not compose and not ports:
return
self_name = os.path.basename(os.path.abspath(env_dir))
for name, other in iter_sibling_manifests(env_dir, runtime_dir):
other_compose = compose_of(other, is_manifest=True)
if compose and other_compose and compose == other_compose:
die("Compose 项目名冲突:compose_project=%s 已被实例 %s 占用(修改前拒绝)"
% (compose, name), 7)
other_ports = binding_ports_from(other, is_manifest=True)
clash = ports & other_ports
if clash:
die("存储端口冲突:port=%s 与实例 %s 冲突(修改前拒绝)"
% (",".join(sorted(clash)), name), 7)
# 亦与 sibling 的应用监听 addr 端口冲突(避免与已登记服务抢口)
if ports:
rt = os.path.abspath(runtime_dir) if runtime_dir else runtime_dir_of(env_dir)
for name in sorted(os.listdir(rt)) if os.path.isdir(rt) else []:
if name == self_name:
continue
env_path = os.path.join(rt, name, DERIVED)
if not os.path.isfile(env_path):
continue
with open(env_path, encoding="utf-8") as f:
for line in f:
if line.startswith("addr="):
addr = line[5:].strip()
ap = addr.rsplit(":", 1)[-1] if addr else ""
if ap in ports:
die("存储端口冲突:port=%s 与服务 %s 的 addr=%s 冲突(修改前拒绝)"
% (ap, name, addr), 7)
break
def cmd_migrate(env_dir):
if load(env_dir) is not None:
print("manifest 已存在,幂等跳过")
@@ -221,32 +494,41 @@ def cmd_status(env_dir):
return 0
def mutate(env_dir, fn):
def mutate(env_dir, fn, runtime_dir=None):
m = load(env_dir)
if m is None:
die("manifest 不存在:先 migrate/init", 3)
before = json.dumps(m["identity"], sort_keys=True)
fn(m)
# 存储绑定变更:先形态校验,再冲突扫描,最后重锚——冲突时不落盘
if any(k in m["kv"] for k in STORAGE_KEYS) or storage_mode_of(m["kv"]) == "container":
validate_storage_kv(m["kv"], require_complete=(storage_mode_of(m["kv"]) == "container"))
# 预览重锚后的 identity 以做冲突检测(不落 marker / 不写盘)
preview = json.loads(json.dumps(m))
preview = reanchor(env_dir, preview, write=False)
check_conflicts(env_dir, preview, runtime_dir=runtime_dir)
m["revision"] = int(m["revision"]) + 1
m = reanchor(env_dir, m)
m = reanchor(env_dir, m, write=True)
save(env_dir, m)
if before != json.dumps(m["identity"], sort_keys=True):
print("[manifest] 身份锚点已随显式持久化写入更新(data=%s db=%s/%s)"
% (m["identity"]["data_root"], m["identity"]["db_mode"], m["identity"]["db_ref"]))
print("[manifest] 身份锚点已随显式持久化写入更新(data=%s db=%s/%s storage=%s files=%s)"
% (m["identity"]["data_root"], m["identity"]["db_mode"], m["identity"]["db_ref"],
m["identity"].get("storage_mode", "legacy"),
m["identity"].get("files_root") or "-"))
return 0
def cmd_set(env_dir, key, value):
return mutate(env_dir, lambda m: m["kv"].__setitem__(key, value))
def cmd_set(env_dir, key, value, runtime_dir=None):
return mutate(env_dir, lambda m: m["kv"].__setitem__(key, value), runtime_dir=runtime_dir)
def cmd_del(env_dir, key):
def cmd_del(env_dir, key, runtime_dir=None):
def drop(m):
m["kv"].pop(key, None)
return mutate(env_dir, drop)
return mutate(env_dir, drop, runtime_dir=runtime_dir)
def cmd_bulk_set(env_dir):
def cmd_bulk_set(env_dir, runtime_dir=None):
kv = {}
for line in sys.stdin.read().splitlines():
if line and "=" in line and not line.startswith("#"):
@@ -255,8 +537,8 @@ def cmd_bulk_set(env_dir):
kv.pop("autostart", None) # desired 独占期望态
def replace(m):
m["kv"] = kv
return mutate(env_dir, replace)
m["kv"] = preserve_storage_keys(kv, m["kv"])
return mutate(env_dir, replace, runtime_dir=runtime_dir)
def cmd_set_desired(env_dir, state):
@@ -265,7 +547,20 @@ def cmd_set_desired(env_dir, state):
return mutate(env_dir, lambda m: m.__setitem__("desired", state))
def cmd_verify(env_dir, data_root=None, db_mode=None, db_ref=None):
def cmd_check_conflicts(env_dir, runtime_dir=None):
m = load(env_dir)
if m is None:
die("manifest 不存在:先 migrate/init", 3)
check_conflicts(env_dir, m, runtime_dir=runtime_dir)
print("绑定冲突检查通过 instance_id=%s compose=%s"
% (m["instance_id"], m["identity"].get("compose_project") or "-"))
return 0
def cmd_verify(env_dir, data_root=None, db_mode=None, db_ref=None,
files_root=None, env_class=None, compose_project=None,
base_db_ref=None, rdm_db_ref=None, kb_db_ref=None,
require_storage=False):
m = load(env_dir)
if m is None:
die("manifest 缺失:本实例已进入 manifest 治理,缺失即事件(先 init/人工处置,禁止静默重建)", 4)
@@ -284,14 +579,51 @@ def cmd_verify(env_dir, data_root=None, db_mode=None, db_ref=None):
die("数据库模式漂移:生效 %s ≠ 锚定 %s" % (db_mode, ident["db_mode"]), 6)
if db_mode and ident["db_mode"] and db_ref is not None and (db_ref or "") != ident["db_ref"]:
die("数据库绑定漂移:生效 %s ≠ 锚定 %s(重绑须经显式登记命令)" % (db_ref or "<空>", ident["db_ref"]), 6)
print("身份校验通过 instance_id=%s data=%s db=%s/%s"
% (m["instance_id"], ident["data_root"], ident["db_mode"] or "-", ident["db_ref"] or "-"))
storage_mode = ident.get("storage_mode") or "legacy"
if storage_mode == "container" or require_storage:
# container:缺绑定/错根/错库一律拒绝,不回退默认、不建空库
validate_storage_kv(m["kv"], require_complete=True)
if files_root is not None:
got = os.path.normpath(files_root) if os.path.isabs(files_root) else files_root
if not os.path.isabs(files_root):
die("文件根拒绝相对路径(不相对 checkout 解析):%s" % files_root, 6)
if got != (ident.get("files_root") or ""):
die("文件根漂移:生效 %s ≠ 锚定 %s" % (got, ident.get("files_root")), 6)
if env_class is not None and env_class != (ident.get("env_class") or ""):
die("环境身份漂移:生效 %s ≠ 锚定 %s" % (env_class, ident.get("env_class")), 6)
if compose_project is not None and compose_project != (ident.get("compose_project") or ""):
die("Compose 项目漂移:生效 %s ≠ 锚定 %s" % (compose_project, ident.get("compose_project")), 6)
for slot, got in (("base", base_db_ref), ("rdm", rdm_db_ref), ("kb", kb_db_ref)):
if got is None:
continue
want = ident.get("%s_db_ref" % slot) or ""
if (got or "") != want:
die("%s 库绑定漂移:生效 %s ≠ 锚定 %s(错库拒绝,不静默新建)"
% (slot, got or "<空>", want or "<空>"), 6)
print("身份校验通过 instance_id=%s data=%s db=%s/%s storage=%s env=%s files=%s"
% (m["instance_id"], ident["data_root"], ident["db_mode"] or "-", ident["db_ref"] or "-",
storage_mode, ident.get("env_class") or "-", ident.get("files_root") or "-"))
return 0
def _parse_runtime_flag(args):
runtime_dir = None
out = []
it = iter(args)
for a in it:
if a == "--runtime":
runtime_dir = next(it, None)
else:
out.append(a)
return runtime_dir, out
def main(argv):
if len(argv) < 3:
die("用法: svc_manifest.py ENV_DIR <migrate|init|status|set|del|bulk-set|set-desired|verify> [参数]", 2)
die("用法: svc_manifest.py ENV_DIR <migrate|init|status|set|del|bulk-set|"
"set-desired|verify|check-conflicts> [参数]", 2)
env_dir, cmd = argv[1], argv[2]
args = argv[3:]
if cmd == "migrate":
@@ -300,16 +632,27 @@ def main(argv):
return cmd_init(env_dir)
if cmd == "status":
return cmd_status(env_dir)
if cmd == "set" and len(args) == 2:
return cmd_set(env_dir, args[0], args[1])
if cmd == "del" and len(args) == 1:
return cmd_del(env_dir, args[0])
if cmd == "set":
runtime_dir, args = _parse_runtime_flag(args)
if len(args) == 2:
return cmd_set(env_dir, args[0], args[1], runtime_dir=runtime_dir)
if cmd == "del":
runtime_dir, args = _parse_runtime_flag(args)
if len(args) == 1:
return cmd_del(env_dir, args[0], runtime_dir=runtime_dir)
if cmd == "bulk-set":
return cmd_bulk_set(env_dir)
runtime_dir, args = _parse_runtime_flag(args)
return cmd_bulk_set(env_dir, runtime_dir=runtime_dir)
if cmd == "set-desired" and len(args) == 1:
return cmd_set_desired(env_dir, args[0])
if cmd == "check-conflicts":
runtime_dir, _ = _parse_runtime_flag(args)
return cmd_check_conflicts(env_dir, runtime_dir=runtime_dir)
if cmd == "verify":
data_root = db_mode = db_ref = None
files_root = env_class = compose_project = None
base_db_ref = rdm_db_ref = kb_db_ref = None
require_storage = False
it = iter(args)
for a in it:
if a == "--data":
@@ -318,7 +661,27 @@ def main(argv):
db_mode = next(it, None)
elif a == "--db-ref":
db_ref = next(it, None)
return cmd_verify(env_dir, data_root, db_mode, db_ref)
elif a == "--files-root":
files_root = next(it, None)
elif a == "--env-class":
env_class = next(it, None)
elif a == "--compose-project":
compose_project = next(it, None)
elif a == "--base-db-ref":
base_db_ref = next(it, None)
elif a == "--rdm-db-ref":
rdm_db_ref = next(it, None)
elif a == "--kb-db-ref":
kb_db_ref = next(it, None)
elif a == "--require-storage":
require_storage = True
return cmd_verify(
env_dir, data_root, db_mode, db_ref,
files_root=files_root, env_class=env_class,
compose_project=compose_project,
base_db_ref=base_db_ref, rdm_db_ref=rdm_db_ref, kb_db_ref=kb_db_ref,
require_storage=require_storage,
)
die("未知命令或参数不全:%s %s" % (cmd, " ".join(args)), 2)
+193
View File
@@ -500,6 +500,199 @@ class IdentityTests(tsr.SvcAutoRecoverTests):
self.skipTest("真 PG 专项应由隔离套件执行(本桩文件不连真实 PG)")
self.skipTest("真实 PG 身份专项 NOT_RUN:需真实实例(SVC_TEST_PG=1 + AGENT_PG_TEST_DSN)")
# ---- 容器存储 U1:环境与数据归属合同 ----
def _storage_secret_dir(self):
d = self.root / "storage-secrets"
d.mkdir(exist_ok=True)
for slot in ("base", "rdm", "kb"):
(d / ("%s.dsn" % slot)).write_text("ref-only\n", encoding="utf-8")
(d / ("%s.role" % slot)).write_text("ref-only\n", encoding="utf-8")
return d
def _apply_container_bindings(self, name, compose, ports, files_root, env_class="prod"):
"""先写齐绑定键,最后打开 storage_mode=container(缺一即拒)。
经 svc_manifest.py set(与 svc_set_key 同源权威)。"""
sec = self._storage_secret_dir()
items = [
("env_class", env_class),
("files_root", str(files_root)),
("compose_project", compose),
("base_dsn_ref", str(sec / "base.dsn")),
("base_role_ref", str(sec / "base.role")),
("base_port", ports[0]),
("rdm_dsn_ref", str(sec / "rdm.dsn")),
("rdm_role_ref", str(sec / "rdm.role")),
("rdm_port", ports[1]),
("kb_dsn_ref", str(sec / "kb.dsn")),
("kb_role_ref", str(sec / "kb.role")),
("kb_port", ports[2]),
("storage_mode", "container"),
]
man = self.root / "scripts" / "svc_manifest.py"
env_dir = self.runtime(name)
runtime = env_dir.parent
for k, v in items:
proc = subprocess.run(
["python3", str(man), str(env_dir), "set", "--runtime", str(runtime), k, str(v)],
cwd=str(self.root), env=self.env, text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30)
self.assertEqual(proc.returncode, 0, "%s=%s → %s" % (k, v, proc.stdout))
def test_CS_U1_legacy_upgrade_keeps_identity(self):
"""旧配置升级到 container 绑定:instance_id / data_root 保留。"""
self.seed_agent("cs1", 9471)
self.start_ok("cs1")
m0 = self.manifest("cs1")
iid, data = m0["instance_id"], m0["identity"]["data_root"]
self.svc("cs1", "stop")
files = self.root / "files-cs1"
files.mkdir()
self._apply_container_bindings("cs1", "svc-cs1", ("25432", "25433", "25434"), files)
m1 = self.manifest("cs1")
self.assertEqual(m1["instance_id"], iid)
self.assertEqual(m1["identity"]["data_root"], data)
self.assertEqual(m1["identity"]["storage_mode"], "container")
self.assertEqual(m1["identity"]["env_class"], "prod")
self.assertEqual(m1["identity"]["files_root"], str(files))
self.assertTrue(m1["identity"]["base_db_ref"].startswith("dsn_ref:"))
self.assertTrue(m1["identity"]["rdm_db_ref"].startswith("dsn_ref:"))
self.assertTrue(m1["identity"]["kb_db_ref"].startswith("dsn_ref:"))
def test_CS_U1_relative_files_root_rejected(self):
self.seed_agent("cs2", 9472)
self.start_ok("cs2")
self.svc("cs2", "stop")
man = self.root / "scripts" / "svc_manifest.py"
env_dir = self.runtime("cs2")
proc = subprocess.run(
["python3", str(man), str(env_dir), "set", "--runtime", str(env_dir.parent),
"files_root", "relative/not-abs"],
cwd=str(self.root), env=self.env, text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30)
self.assertNotEqual(proc.returncode, 0, proc.stdout)
self.assertIn("绝对路径", proc.stdout)
self.assertNotEqual(self.manifest("cs2")["identity"].get("files_root"),
"relative/not-abs")
def test_CS_U1_missing_binding_rejected(self):
"""container 模式缺任一必需绑定不得开启(不静默默认)。"""
self.seed_agent("cs3", 9473)
self.start_ok("cs3")
self.svc("cs3", "stop")
man = self.root / "scripts" / "svc_manifest.py"
env_dir = self.runtime("cs3")
rt = str(env_dir.parent)
proc = subprocess.run(
["python3", str(man), str(env_dir), "set", "--runtime", rt,
"storage_mode", "container"],
cwd=str(self.root), env=self.env, text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30)
self.assertNotEqual(proc.returncode, 0, proc.stdout)
self.assertIn("缺少必需绑定", proc.stdout)
self.assertNotEqual(self.manifest("cs3")["kv"].get("storage_mode"), "container")
def test_CS_U1_wrong_files_root_and_db_verify_rejected(self):
self.seed_agent("cs4", 9474)
self.start_ok("cs4")
self.svc("cs4", "stop")
files = self.root / "files-cs4"
files.mkdir()
self._apply_container_bindings("cs4", "svc-cs4", ("25532", "25533", "25534"), files)
man = self.root / "scripts" / "svc_manifest.py"
env_dir = self.runtime("cs4")
bad_files = subprocess.run(
["python3", str(man), str(env_dir), "verify", "--require-storage",
"--files-root", str(self.root / "other-files")],
cwd=str(self.root), env=self.env, text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30)
self.assertNotEqual(bad_files.returncode, 0, bad_files.stdout)
self.assertIn("文件根", bad_files.stdout)
bad_db = subprocess.run(
["python3", str(man), str(env_dir), "verify", "--require-storage",
"--files-root", str(files),
"--base-db-ref", "dbref:foreign/x"],
cwd=str(self.root), env=self.env, text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30)
self.assertNotEqual(bad_db.returncode, 0, bad_db.stdout)
self.assertIn("base 库绑定漂移", bad_db.stdout)
def test_CS_U1_compose_and_port_conflict_before_modify(self):
self.seed_agent("cs5a", 9475)
self.seed_agent("cs5b", 9476)
self.start_ok("cs5a")
self.start_ok("cs5b")
self.svc("cs5a", "stop")
self.svc("cs5b", "stop")
files_a = self.root / "files-cs5a"
files_b = self.root / "files-cs5b"
files_a.mkdir()
files_b.mkdir()
self._apply_container_bindings("cs5a", "svc-shared", ("25632", "25633", "25634"), files_a)
man = self.root / "scripts" / "svc_manifest.py"
env_b = self.runtime("cs5b")
rt = str(env_b.parent)
# 同 compose 名:修改前失败
sec = self._storage_secret_dir()
for k, v in (("env_class", "test"), ("files_root", str(files_b)),
("compose_project", "svc-shared")):
proc = subprocess.run(
["python3", str(man), str(env_b), "set", "--runtime", rt, k, v],
cwd=str(self.root), env=self.env, text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30)
if k == "compose_project":
self.assertNotEqual(proc.returncode, 0, proc.stdout)
self.assertIn("Compose 项目名冲突", proc.stdout)
else:
self.assertEqual(proc.returncode, 0, proc.stdout)
# 端口冲突
self.seed_agent("cs5c", 9477)
self.start_ok("cs5c")
self.svc("cs5c", "stop")
files_c = self.root / "files-cs5c"
files_c.mkdir()
env_c = self.runtime("cs5c")
for k, v in (
("env_class", "verify"),
("files_root", str(files_c)),
("compose_project", "svc-cs5c"),
("base_dsn_ref", str(sec / "base.dsn")),
("base_role_ref", str(sec / "base.role")),
("base_port", "25632"),
):
proc = subprocess.run(
["python3", str(man), str(env_c), "set", "--runtime", rt, k, str(v)],
cwd=str(self.root), env=self.env, text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30)
if k == "base_port":
self.assertNotEqual(proc.returncode, 0, proc.stdout)
self.assertIn("端口冲突", proc.stdout)
else:
self.assertEqual(proc.returncode, 0, proc.stdout)
def test_CS_U1_other_checkout_keeps_absolute_files_root(self):
"""另一 cwd/checkout 调用仍定位同一绝对 files_root。"""
self.seed_agent("cs6", 9478)
self.start_ok("cs6")
self.svc("cs6", "stop")
files = self.root / "files-cs6"
files.mkdir()
self._apply_container_bindings("cs6", "svc-cs6", ("25732", "25733", "25734"), files)
iid = self.manifest("cs6")["instance_id"]
fr = self.manifest("cs6")["identity"]["files_root"]
elsewhere = tempfile.mkdtemp(prefix="svc-cs-elsewhere-")
self.addCleanup(shutil.rmtree, elsewhere, True)
man = self.root / "scripts" / "svc_manifest.py"
proc = subprocess.run(
["python3", str(man), str(self.runtime("cs6")), "status"],
cwd=elsewhere, env=self.env, text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30)
self.assertEqual(proc.returncode, 0, proc.stdout)
payload = json.loads(proc.stdout.strip())
self.assertEqual(payload["instance_id"], iid)
self.assertEqual(payload["identity"]["files_root"], fr)
self.assertTrue(os.path.isabs(fr), fr)
class AtomicWriteTests(unittest.TestCase):
"""崩溃窗口单元级:os.replace 失败时原文件必须完好(I3 半写不可能)。"""
+38
View File
@@ -52,3 +52,41 @@ class LifecycleIsolationTests(tsr.SvcAutoRecoverTests):
self.assertEqual(again["identity"]["data_root"], data)
self.assertEqual(again["identity"].get("db_marker"), "established")
self.svc("iso2", "stop")
def test_caller_storage_env_does_not_reach_serve(self):
"""调用方 AGENT_SYSTEM_FILES_ROOT / ENV_CLASS / COMPOSE 不得进入 serve。"""
self.seed_agent("iso3", 9463)
env = dict(self.env)
env["AGENT_SYSTEM_FILES_ROOT"] = "/tmp/leak-files-root"
env["AGENT_SYSTEM_ENV_CLASS"] = "prod"
env["AGENT_SYSTEM_COMPOSE_PROJECT"] = "leak-compose"
env["AGENT_SYSTEM_BASE_DB_REF"] = "dbref:leak/base"
env["AGENT_SYSTEM_RDM_DB_REF"] = "dbref:leak/rdm"
env["AGENT_SYSTEM_KB_DB_REF"] = "dbref:leak/kb"
self.svc("iso3", "start", env=env)
pid = self.pid_of("iso3")
self.assertIsNotNone(pid)
raw = Path("/proc/%d/environ" % pid).read_bytes()
self.assertNotIn(b"/tmp/leak-files-root", raw)
self.assertNotIn(b"leak-compose", raw)
self.assertNotIn(b"dbref:leak/base", raw)
self.assertNotIn(b"dbref:leak/rdm", raw)
self.assertNotIn(b"dbref:leak/kb", raw)
serve_env = self.runtime("iso3") / "serve.env"
self.assertTrue(serve_env.is_file())
text = serve_env.read_text()
self.assertNotIn("AGENT_SYSTEM_FILES_ROOT", text)
self.assertNotIn("leak-files-root", text)
self.assertNotIn("leak-compose", text)
self.svc("iso3", "stop")
def test_env_config_resets_storage_globals(self):
"""棘轮:env_config 必须清空存储绑定全局与调用方身份环境。"""
import subprocess
out = subprocess.run(
["bash", "-c",
'source "$1"; declare -f env_config | grep -c \'AGENT_SYSTEM_FILES_ROOT\'',
"x", str(SOURCE / "scripts" / "svc.sh")],
capture_output=True, text=True, cwd=str(SOURCE), timeout=30)
self.assertEqual(out.returncode, 0, out.stderr)
self.assertGreaterEqual(int(out.stdout.strip()), 1)
+192
View File
@@ -246,6 +246,198 @@ class ResolverStateIsolation(unittest.TestCase):
)
self.assertEqual(out.strip(), "1")
def test_env_config_resets_storage_identity_env(self):
# 棘轮:env_config 必须剥离调用方 FILES_ROOT / ENV_CLASS / COMPOSE(容器存储 U1)。
out = _bash(
'source "$1"; declare -f env_config | '
'grep -c \'unset AGENT_SYSTEM_FILES_ROOT AGENT_SYSTEM_ENV_CLASS AGENT_SYSTEM_COMPOSE_PROJECT\''
)
self.assertEqual(out.strip(), "1")
class StorageBindingsPrecheck(unittest.TestCase):
"""容器存储 U1:resolve_storage_bindings 与冲突预检(不启真实服务)。"""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.rt = Path(self._tmp.name) / "runtime"
self.rt.mkdir()
self.sec = Path(self._tmp.name) / "secrets"
self.sec.mkdir()
self.files = Path(self._tmp.name) / "files"
self.files.mkdir()
for slot in ("base", "rdm", "kb"):
(self.sec / ("%s.dsn" % slot)).write_text("x\n")
(self.sec / ("%s.role" % slot)).write_text("y\n")
# 复制 manifest 脚本到可读路径(与仓内一致)
self.man = REPO / "scripts" / "svc_manifest.py"
def tearDown(self):
self._tmp.cleanup()
def _init_agent(self, name):
env = self.rt / name
data = env / "data"
data.mkdir(parents=True)
(env / "svc.env").write_text(
"kind=agent\nenv=%s\naddr=127.0.0.1:18080\n"
"exec=--addr 127.0.0.1:18080 --data %s\n" % (name, data),
encoding="utf-8",
)
proc = subprocess.run(
["python3", str(self.man), str(env), "migrate"],
capture_output=True, text=True, cwd=str(REPO), timeout=30)
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
return env
def _set(self, env, key, value):
proc = subprocess.run(
["python3", str(self.man), str(env), "set",
"--runtime", str(self.rt), key, str(value)],
capture_output=True, text=True, cwd=str(REPO), timeout=30)
return proc
def test_resolve_storage_bindings_legacy_ok(self):
env = self._init_agent("leg")
out = _bash(
'source "$1"; shift; RUNTIME="$1"; ENV_NAME=leg; ENV_DIR="$2"; '
'if resolve_storage_bindings; then echo rc=ok; else echo rc=fail; fi; '
'printf "MODE=%s\\n" "$STORAGE_MODE"',
str(self.rt), str(env),
)
self.assertIn("rc=ok", out)
self.assertIn("MODE=legacy", out)
def test_resolve_incomplete_container_refuses(self):
env = self._init_agent("inc")
# 只写 storage_mode 会被 manifest 拒绝;直接改 kv 模拟半套后 resolve 拒绝
proc = self._set(env, "env_class", "prod")
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
# 手工把 storage_mode 写成 container(绕过 mutate 完整校验)以测 resolve
import json
mp = env / "manifest.json"
m = json.loads(mp.read_text(encoding="utf-8"))
m["kv"]["storage_mode"] = "container"
m["identity"]["storage_mode"] = "container"
mp.write_text(json.dumps(m, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
(env / "svc.env").write_text(
(env / "svc.env").read_text(encoding="utf-8") + "storage_mode=container\n",
encoding="utf-8",
)
try:
_bash(
'source "$1"; shift; RUNTIME="$1"; ENV_NAME=inc; ENV_DIR="$2"; '
'resolve_storage_bindings',
str(self.rt), str(env),
)
self.fail("incomplete container must refuse")
except AssertionError as e:
self.assertIn("存储绑定不完整", str(e))
def test_relative_files_root_refused_on_set(self):
env = self._init_agent("rel")
proc = self._set(env, "files_root", "not/absolute")
self.assertNotEqual(proc.returncode, 0)
self.assertIn("绝对路径", proc.stdout + proc.stderr)
def test_compose_project_conflict_refused(self):
a = self._init_agent("ca")
b = self._init_agent("cb")
for k, v in (
("env_class", "prod"),
("files_root", str(self.files)),
("compose_project", "same-proj"),
("base_dsn_ref", str(self.sec / "base.dsn")),
("base_role_ref", str(self.sec / "base.role")),
("base_port", "26001"),
("rdm_dsn_ref", str(self.sec / "rdm.dsn")),
("rdm_role_ref", str(self.sec / "rdm.role")),
("rdm_port", "26002"),
("kb_dsn_ref", str(self.sec / "kb.dsn")),
("kb_role_ref", str(self.sec / "kb.role")),
("kb_port", "26003"),
("storage_mode", "container"),
):
proc = self._set(a, k, v)
self.assertEqual(proc.returncode, 0, "%s: %s" % (k, proc.stdout + proc.stderr))
(self.files / "b").mkdir(exist_ok=True)
proc = self._set(b, "env_class", "test")
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
proc = self._set(b, "files_root", str(self.files / "b"))
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
proc = self._set(b, "compose_project", "same-proj")
self.assertNotEqual(proc.returncode, 0)
self.assertIn("Compose 项目名冲突", proc.stdout + proc.stderr)
def test_storage_port_conflict_refused(self):
a = self._init_agent("pa")
b = self._init_agent("pb")
for k, v in (
("env_class", "prod"),
("files_root", str(self.files / "pa")),
("compose_project", "proj-pa"),
("base_dsn_ref", str(self.sec / "base.dsn")),
("base_role_ref", str(self.sec / "base.role")),
("base_port", "26101"),
("rdm_dsn_ref", str(self.sec / "rdm.dsn")),
("rdm_role_ref", str(self.sec / "rdm.role")),
("rdm_port", "26102"),
("kb_dsn_ref", str(self.sec / "kb.dsn")),
("kb_role_ref", str(self.sec / "kb.role")),
("kb_port", "26103"),
("storage_mode", "container"),
):
(self.files / "pa").mkdir(exist_ok=True)
proc = self._set(a, k, v)
self.assertEqual(proc.returncode, 0, "%s: %s" % (k, proc.stdout + proc.stderr))
(self.files / "pb").mkdir(exist_ok=True)
for k, v in (
("env_class", "test"),
("files_root", str(self.files / "pb")),
("compose_project", "proj-pb"),
("base_dsn_ref", str(self.sec / "base.dsn")),
("base_role_ref", str(self.sec / "base.role")),
("base_port", "26101"),
):
proc = self._set(b, k, v)
if k == "base_port":
self.assertNotEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertIn("端口冲突", proc.stdout + proc.stderr)
else:
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
def test_resolve_complete_container_ok(self):
env = self._init_agent("okc")
(self.files / "okc").mkdir(exist_ok=True)
for k, v in (
("env_class", "verify"),
("files_root", str(self.files / "okc")),
("compose_project", "proj-okc"),
("base_dsn_ref", str(self.sec / "base.dsn")),
("base_role_ref", str(self.sec / "base.role")),
("base_port", "26201"),
("rdm_dsn_ref", str(self.sec / "rdm.dsn")),
("rdm_role_ref", str(self.sec / "rdm.role")),
("rdm_port", "26202"),
("kb_dsn_ref", str(self.sec / "kb.dsn")),
("kb_role_ref", str(self.sec / "kb.role")),
("kb_port", "26203"),
("storage_mode", "container"),
):
proc = self._set(env, k, v)
self.assertEqual(proc.returncode, 0, "%s: %s" % (k, proc.stdout + proc.stderr))
out = _bash(
'source "$1"; shift; RUNTIME="$1"; ENV_NAME=okc; ENV_DIR="$2"; '
'if resolve_storage_bindings; then echo rc=ok; else echo rc=fail; fi; '
'printf "MODE=%s\\nENV=%s\\nFILES=%s\\nCOMPOSE=%s\\n" '
'"$STORAGE_MODE" "$ENV_CLASS" "$FILES_ROOT" "$COMPOSE_PROJECT"',
str(self.rt), str(env),
)
self.assertIn("rc=ok", out)
self.assertIn("MODE=container", out)
self.assertIn("ENV=verify", out)
self.assertIn("COMPOSE=proj-okc", out)
if __name__ == "__main__":
unittest.main()