Harden svc.sh identity isolation and deploy candidate gate

Why: batch update leaked AGENT_SYSTEM_INSTANCE_ID/DBREF from one service
to the next, and RDM artifacts without the identity CLI were stopped and
overwritten before their startup identity refusal surfaced, leaving
rdm1/rdmcand in a mixed registration-vs-artifact state (2026-10-12).
Evidence: make test-governance 217 OK; tests.test_svc_identity and
tests.test_svc_precheck green; rows recorded in the task card.
Impact: env_config resets identity env per service; capability probe is
timeout-guarded and rejects serve-shaped output; identity ensure moves
to adoption/init (bare start is verify-only); deploy builds and gates
candidates before stopping the old service (refuses identityless
candidates, completes missing identity rows via the candidate); update
runs svc_manifest_migrate like every other lifecycle entry.
Refs: .task/feature-svc-identity-u1.md; user root-cause report 2026-10-12.
This commit is contained in:
2026-10-12 12:56:37 +00:00
parent 05c3d050b4
commit 55ed5899fb
5 changed files with 352 additions and 23 deletions
+24 -11
View File
@@ -7,33 +7,45 @@
- 权威仓:ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Dev.git;目标分支:main;R0(initial_base_sha)= d8a269ab2fd20e4783ba0cde6a4794a702ad7571 - 权威仓:ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Dev.git;目标分支:main;R0(initial_base_sha)= d8a269ab2fd20e4783ba0cde6a4794a702ad7571
- 工作区:/home/weichen/projects/Agent-System-Dev-wt-feature-svc-identity-u1(worktree);分支:feature/svc-identity-u1 - 工作区:/home/weichen/projects/Agent-System-Dev-wt-feature-svc-identity-u1(worktree);分支:feature/svc-identity-u1
- 授权与交付终点:<本次允许的动作与终点;未授权事项写明> - 授权与交付终点:用户 2026-10-12 交付根因诊断与 U1/U2/U3 增量方案(会话记录:rdm1/rdmcand 批量更新三连败定位);本轮范围 = U1 的 Dev 仓切片(svc.sh + 测试);交付终点 = 本 PR 可合入(实施与自检产物 + 局部测试,人工评审后合入)。不自行实施:服务重启/现场恢复、U2 失败恢复、U3 真实组合门禁、Backend/RDM 跨仓改动。
- 交付模式:manual(逐阶段人工评审);明确授权后可记为 authorized-continuous - 交付模式:manual(逐阶段人工评审);明确授权后可记为 authorized-continuous
- 自动推进授权:无;授权范围:无;授权来源:无;授权终点:无;停止条件:无(未授权) - 自动推进授权:无;授权范围:无;授权来源:无;授权终点:无;停止条件:无(未授权)
- 当前阶段:需求拆解;当前增量:I1(待定义) - 当前阶段:实施与自检(产物已备,待人工评审);当前增量:I1(svc.sh 身份隔离+统一入口+候选门禁)+ I2(回归测试)
- 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位) - 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位)
## 目标与非目标 ## 目标与非目标
<预期结果、受影响入口/模块/契约;明确不做什么。> 目标:消除 svc.sh 批量生命周期中实例身份串用与「停服后才发现身份不可用」两类事故根因(2026-10-12 rdm1/rdmcand 各 3 次更新失败、登记 0aab6c3 ≠ 磁盘 894df2b):
1. `env_config` 按实例重置 `AGENT_SYSTEM_INSTANCE_ID/DBREF`(批内串用根因);
2. `svc_bin_has_identity` 探测无副作用化:timeout 护栏 + serve 痕迹否决(RDM 产物 identity 未知位置参数落入服务路径、门禁报文恰含 "identity ensure" 字样造成旧 grep 误报);
3. 身份建立收敛:ensure 只在领养(svc_manifest_migrate)与显式 init;普通启动 verify-only,缺行即事件;
4. `agent_do_deploy` 构建前置 + 候选身份门禁 `svc_deploy_identity_gate`:无 identity 能力的候选在**停旧服务之前**拒绝(旧服务/制品/登记不变);有能力候选停服前幂等补齐缺失身份行(外宾行 fail-closed);
5. `cmd_update` 批内接入 `svc_manifest_migrate`(update 与单实例生命周期同入口,rdm1 多次更新后仍无 manifest 的根因)。
非目标:U2(制品不可变版本目录、切换持久化、自动回退);U3(真实 Dev/RDM 组合 CI 门禁);Backend 仓 identity CLI 公共化(runIdentity 在 cmd/package main,RDM 无法复用)与 RDM 仓 main.go 接线(跨仓任务);rdm1/rdmcand 现场恢复(依赖 U1 验证 + RDM 接口,属 #457 R2 后续,按诊断报告须先核对 rdmcand 历史数据归属)。
## 关联方向与规划 ## 关联方向与规划
- 适用状态:待核对(无适用规划时说明原因) - 适用状态:已有主记录,保留原批准;本轮为其 U1 增量的 Dev 仓切片
- 主记录路径/版本:待核对(路径按仓库根目录定位,归档后不变;只引用唯一正文) - 主记录路径/版本:modules/documents/docs/proposals/2026-10-12-svc-unified-deploy-and-login-key.md r2(统一部署)与 2026-10-12-service-restart-data-recovery.md r3(恢复);诊断报告(会话交付,2026-10-12)
- 对应里程碑:待核对 - 对应里程碑:统一部署 U1(实例身份流程统一)
## 需求分析 ## 需求分析
| ID | 描述 | 项目符合度及依据 | 优先级 | 验收条件 | 依赖 | 结论 | | ID | 描述 | 项目符合度及依据 | 优先级 | 验收条件 | 依赖 | 结论 |
|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|
| R1 | 待填写 | 待评估 | 待定 | 待定义 | 待确认 | 待评审 | | U1-1 | 批量 update 身份环境不串用 | 符合:env_config 已重置 SVC_*(#457)但漏 AGENT_SYSTEM_*;rdm1/rdmcand svc.log 2026-10-12 实况为 dev 身份泄漏 | P0 | 棘轮:env_config unset 身份变量;update 后 serve 只含本服务身份(回归断言) | #457 | 已实现 |
| U1-2 | 能力探测无副作用 | 符合:旧实现无 timeout、grep ensure 被 RDM 门禁报文误报(本轮实测复现) | P0 | 探测单测:报文含 ensure 不误报;identity 挂死有界返回无能力 | 无 | 已实现 |
| U1-3 | 启动只验证不建身份 | 符合:诊断裁定「缺行即事件」;现状每次启动 ensure 掩盖身份丢失(rdmcand) | P0 | 缺行裸 start 拒绝且不建空行;显式 init 补齐后恢复 | 无 | 已实现 |
| U1-4 | 候选身份门禁在停服前 | 符合:事故时序 = 停服→覆盖→启动即拒→混合状态无恢复路径 | P0 | 无能力候选:拒绝且旧进程/制品/登记逐项不变;有能力候选:停服前补齐身份行后完整更新成功 | 无 | 已实现 |
| U1-5 | update 接入统一身份入口 | 符合:update 直入 agent_do_deploy 绕过 svc_manifest_migrate(rdm1 多次更新无 manifest) | P0 | update 路径 manifest 迁移生效(回归经「候选补行后完整更新」断言) | 无 | 已实现 |
## 方案与计划 ## 方案与计划
- 方案引用:待填写(含对象版本) - 方案引用:用户诊断报告 U1 增量(2026-10-12)+ docs/recovery/u1-identity-manifest.md §5(身份建立仅显式 init/受控领养、能力探测须无副作用)
- 实施计划引用:待填写(含增量与需求 ID) - 实施计划:I1 = svc.sh 五点改动(scripts/svc.sh);I2 = 测试(tests/test_svc_identity.py U1 回归组、tests/test_svc_precheck.py 棘轮、tests/test_svc_recover.py 桩 serve 身份环境记录)
- 测试计划引用:待填写(含需求 ID 与验收条件) - 测试计划:U1-1→棘轮+update 回归;U1-2→探测单测;U1-3→缺行即事件+init 恢复;U1-4→候选拒绝保现场+候选补行完整更新;U1-5→同 U1-4 路径;全套 `make test-governance` + tests.test_svc 显式复跑(rdm 部署端到端零回归)
## 阶段评审 ## 阶段评审
@@ -54,7 +66,8 @@
| 增量/试验 | 假设 | 范围 | 判断和停止条件 | 批准依据 | 结果 | 变更影响 | 需重审补测 | | 增量/试验 | 假设 | 范围 | 判断和停止条件 | 批准依据 | 结果 | 变更影响 | 需重审补测 |
|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|
| 待定义 | — | — | — | 未批准 | NOT_RUN | 待评估 | 待评估 | | I2 夹具:noid 桩形态 | exec 转发包装可模拟 RDM 无 identity 形态 | tests/test_svc_identity.py 夹具 | 判据=换装后 start_ok 成功;失败即重造夹具,不改产品 | 阶段内自检(policy 反馈循环) | 发现 exec 转发使 serve cmdline 失去实例 bin 路径、被 pidcheck 误判死;修正为自包含桩(identity 分支替换)后三测转绿 | 仅测试夹具 | 无 |
| I1 探测语义验证 | RDM 门禁报文含 "identity ensure" 会误报能力 | svc.sh svc_bin_has_identity + 探测单测 | 单测三态(可用/误报否决/挂死有界)全绿即成立 | 阶段内自检 | 通过(含手工复现取证:set -euo pipefail 下探针 shell 须用 if 条件上下文) | 无产品变更外溢 | 无 |
## 同步记录 ## 同步记录
+106 -12
View File
@@ -1055,6 +1055,12 @@ svc_manifest_migrate() {
out="$(python3 "$MANIFEST_PY" "$ENV_DIR" migrate 2>&1)" || rc=$? out="$(python3 "$MANIFEST_PY" "$ENV_DIR" migrate 2>&1)" || rc=$?
if [ "$rc" -eq 0 ]; then if [ "$rc" -eq 0 ]; then
c_ok "[$ENV_NAME] manifest:$(printf '%s' "$out" | tail -1)" c_ok "[$ENV_NAME] manifest:$(printf '%s' "$out" | tail -1)"
# 领养即受控建身份:DB marker 只在领养(此处)与显式 init 建立;普通启动只
# 验证不补行——缺行即事件(2026-10-12 裁定,rdmcand 实况:每次启动静默
# ensure 会掩盖身份丢失)。领养时旧产物可能无 identity 能力而跳过补行,由
# 部署候选门禁 svc_deploy_identity_gate 在下次换产物前用候选补齐。
resolve_db_config 2>/dev/null || true
svc_db_identity_ensure || return 1
return 0 return 0
fi fi
if [ "$rc" -eq 4 ]; then if [ "$rc" -eq 4 ]; then
@@ -1068,7 +1074,9 @@ svc_manifest_migrate() {
} }
# svc_manifest_verify:启动前身份校验——数据 marker 存在且 instance_id 匹配; # svc_manifest_verify:启动前身份校验——数据 marker 存在且 instance_id 匹配;
# 生效 --data / 数据库绑定与冻结锚点一致。失败即拒绝启动(KTD1/KTD2,I4)。 # 生效 --data / 数据库绑定与冻结锚点一致。失败即拒绝启动(KTD1/KTD2,I4)。
# 顺序:文件 marker → DB ensure(空表幂等补)→ DB verify(缺/错即拦)。 # 顺序:文件 marker → DB verify(缺/错即拦)。ensure 只在领养(svc_manifest_migrate)
# 与显式 init——普通启动不建身份,缺行即事件(修复路径:svc init,或换具备
# identity 能力的产物更新时由候选门禁补齐)。
svc_manifest_verify() { svc_manifest_verify() {
manifest_ok || return 0 manifest_ok || return 0
local out local out
@@ -1078,8 +1086,7 @@ svc_manifest_verify() {
c_err " 数据根/数据库绑定是冻结身份;换根/重绑须显式迁移或登记命令(docs/recovery/u1-identity-manifest.md §5)" c_err " 数据根/数据库绑定是冻结身份;换根/重绑须显式迁移或登记命令(docs/recovery/u1-identity-manifest.md §5)"
return 1 return 1
fi fi
# U1 I3:文件校验通过后再碰库——避免损坏 manifest 时 ensure 先 traceback。 # U1 I3:文件校验通过后再碰库——避免损坏 manifest 时身份 CLI 先 traceback。
svc_db_identity_ensure || return 1
svc_db_identity_verify || return 1 svc_db_identity_verify || return 1
return 0 return 0
} }
@@ -1105,20 +1112,33 @@ print("%s\t%s\t%s" % (m["instance_id"], dbref, data))
PY PY
} }
# svc_bin_has_identity [BIN]:产物是否具备 identity 管理子命令(ensure/verify)。
# 探测必须无副作用、有界:timeout 护栏(未知子命令直落服务态的产物会挂死或占口);
# serve 痕迹与身份门禁报错文本不算能力证据——2026-10-12 rdm1/rdmcand 事故中 RDM
# 产物对 identity 未知位置参数落入服务启动路径,报错恰好含「identity ensure」字样,
# 旧实现 grep ensure 误报为有能力。仅 usage 形态(同时含 identity 与 ensure、
# 无服务态痕迹、未超时)才认可。
svc_bin_has_identity() { svc_bin_has_identity() {
[ -x "${ENV_BIN:-}" ] || return 1 local bin="${1:-${ENV_BIN:-}}"
local out [ -x "$bin" ] || return 1
out="$("$ENV_BIN" identity 2>&1 || true)" local out rc=0
printf '%s' "$out" | grep -q ensure out="$(timeout 5 "$bin" identity 2>&1)" || rc=$?
[ "$rc" -eq 124 ] && return 1
case "$out" in
*serving*|*"agent-system starting"*|*"identity missing"*|*"listen"*) return 1 ;;
esac
printf '%s' "$out" | grep -q identity && printf '%s' "$out" | grep -q ensure
} }
svc_db_identity_ensure() { svc_db_identity_ensure() {
# 显式建立 / I2→I3 引导:表空插入;一致幂等;不一致 fail-closed。 # 显式建立 / 领养补行(I2→I3 引导):表空插入;一致幂等;不一致 fail-closed。
# 非 agent 或无 identity 子命令的旧产物:跳过(不硬断 I2)。 # 非 agent 或无 identity 子命令的旧产物:跳过(不硬断 I2)。
# 可选参数 BIN:用指定产物(如部署候选)执行——默认当前安装产物。
local bin="${1:-${ENV_BIN:-}}"
manifest_ok || return 0 manifest_ok || return 0
local kind; kind="$(svc_get kind)" local kind; kind="$(svc_get kind)"
[ "$kind" = agent ] || return 0 [ "$kind" = agent ] || return 0
svc_bin_has_identity || return 0 svc_bin_has_identity "$bin" || return 0
local line iid dbref data local line iid dbref data
line="$(svc_db_identity_args)" || return 1 line="$(svc_db_identity_args)" || return 1
IFS=$'\t' read -r iid dbref data <<<"$line" IFS=$'\t' read -r iid dbref data <<<"$line"
@@ -1127,7 +1147,7 @@ svc_db_identity_ensure() {
args+=(--dsn "$DB_DSN") args+=(--dsn "$DB_DSN")
fi fi
local out rc=0 local out rc=0
out="$("$ENV_BIN" "${args[@]}" 2>&1)" || rc=$? out="$("$bin" "${args[@]}" 2>&1)" || rc=$?
if [ "$rc" -ne 0 ]; then if [ "$rc" -ne 0 ]; then
c_err "[$ENV_NAME] DB 身份 ensure 失败:$out" c_err "[$ENV_NAME] DB 身份 ensure 失败:$out"
return 1 return 1
@@ -1161,6 +1181,35 @@ svc_db_identity_verify() {
return 0 return 0
} }
# svc_deploy_identity_gate SRCBIN:部署候选的身份门禁——在停旧服务、覆盖制品之前
# 执行(2026-10-12 rdm1/rdmcand 事故:停服→覆盖→启动即被身份门禁拒绝,只剩
# 「登记版本≠磁盘制品」的混合状态且无恢复路径)。规则:
# 1) 未入 manifest 治理(全新实例,无 manifest.json):放行——首次部署无旧状态可保;
# 2) 非 agent 类型:放行;
# 3) 候选产物无 identity 管理能力:拒绝——装上即启动被基座身份门禁拒绝,
# 登记与旧制品/旧进程保持不变(下游组合仓接齐 identity 接口前不可更新);
# 4) 候选有能力:先以候选幂等补齐 DB 身份行(领养时旧产物可能无能力而未补;
# 外宾行/不一致 fail-closed),成功才放行——把「缺行」在停服前治好,
# 避免换完制品才发现 start 起不来。
svc_deploy_identity_gate() {
local srcbin="$1"
manifest_ok || return 0
local kind; kind="$(svc_get kind)"
[ "$kind" = agent ] || return 0
if ! svc_bin_has_identity "$srcbin"; then
c_err "[$ENV_NAME] 候选产物不支持 identity 管理:$srcbin"
c_err " manifest 治理实例拒绝安装(装上即被启动身份校验拒绝);旧服务/旧制品/登记保持不变"
c_err " 处理:换具备 identity ensure/verify 的版本部署,或 scripts/svc.sh rm $ENV_NAME --purge 后放弃治理重新部署"
return 1
fi
resolve_db_config 2>/dev/null || true
svc_db_identity_ensure "$srcbin" || {
c_err "[$ENV_NAME] 候选补齐 DB 身份失败(外宾行/不一致 fail-closed);旧服务/旧制品/登记保持不变"
return 1
}
return 0
}
# svc_mark_autostart / svc_clear_autostart:期望运行态登记(成功 start 置 running, # svc_mark_autostart / svc_clear_autostart:期望运行态登记(成功 start 置 running,
# 显式 stop 置 stopped;进程自行死亡不动——recover 据 svc.env 派生的 autostart 判断)。 # 显式 stop 置 stopped;进程自行死亡不动——recover 据 svc.env 派生的 autostart 判断)。
svc_mark_autostart() { manifest_ok && python3 "$MANIFEST_PY" "$ENV_DIR" set-desired running >/dev/null 2>&1 && return 0; svc_set_key autostart 1 >/dev/null 2>&1 || true; } svc_mark_autostart() { manifest_ok && python3 "$MANIFEST_PY" "$ENV_DIR" set-desired running >/dev/null 2>&1 && return 0; svc_set_key autostart 1 >/dev/null 2>&1 || true; }
@@ -1212,6 +1261,12 @@ env_config() {
# update --all:dev 成功后 genui/rdm1/rdmcand 端口仍被孤儿占用)。 # update --all:dev 成功后 genui/rdm1/rdmcand 端口仍被孤儿占用)。
SVC_DB_MODE=""; SVC_DB_SOURCE=""; SVC_DB_REF=""; SVC_DB_DSN_SAVE="" SVC_DB_MODE=""; SVC_DB_SOURCE=""; SVC_DB_REF=""; SVC_DB_DSN_SAVE=""
SVC_PIDCHECK=""; SVC_EXEC=""; SVC_VERSION=""; SVC_SOURCE="" SVC_PIDCHECK=""; SVC_EXEC=""; SVC_VERSION=""; SVC_SOURCE=""
# U1:身份环境同样按实例重置——上一服务 verify 成功后导出的
# AGENT_SYSTEM_INSTANCE_ID/DBREF 不得串给下一服务(批量 update 单进程逐服务解析;
# 2026-10-12 实况:dev 的身份漏给 rdm1/rdmcand,叠加 RDM 产物无 identity 子命令
# 跳过校验,serve 带外宾身份即被基座门禁拒绝)。serve 只允许拿到本服务 verify
# 写入的身份。
unset AGENT_SYSTEM_INSTANCE_ID AGENT_SYSTEM_DBREF
local registered="" local registered=""
[ -f "$ENV_DIR/svc.env" ] && registered="$(svc_kind "$name")" [ -f "$ENV_DIR/svc.env" ] && registered="$(svc_kind "$name")"
SVC_KIND="${registered:-agent}" SVC_KIND="${registered:-agent}"
@@ -1813,23 +1868,44 @@ agent_do_deploy() {
return 1 return 1
fi fi
hist_event deploy begin "$(read_pid)" "from=$SRC_LABEL" hist_event deploy begin "$(read_pid)" "from=$SRC_LABEL"
do_stop_quiet # U1/U2(构建前置):先构建/物化候选并过身份门禁,再停旧服务——构建失败或候选
# 不合格时旧服务继续运行、旧制品与登记不动(2026-10-12 rdm1/rdmcand 事故:停服
# →覆盖→启动即拒,只留「登记版本≠磁盘制品」的混合状态且无恢复路径)。
local wt RDM_UI_OK=0 local wt RDM_UI_OK=0
case "$SRC_KINDSRC" in case "$SRC_KINDSRC" in
local) local)
c_info "[$ENV_NAME] 构建当前工作区(make build)——唯一写主检出构建产物的路径" c_info "[$ENV_NAME] 构建当前工作区(make build)——唯一写主检出构建产物的路径"
make -C "$ROOT" build || { hist_event deploy fail "$(read_pid)" "构建失败($SRC_LABEL)"; return 1; } make -C "$ROOT" build || { hist_event deploy fail "$(read_pid)" "构建失败($SRC_LABEL)"; return 1; }
svc_deploy_identity_gate "$BIN" \
|| { hist_event deploy fail "$(read_pid)" "候选身份门禁拒绝($SRC_LABEL)"; return 1; }
do_stop_quiet
install_artifacts "$BIN" "$ROOT/plugins" \ install_artifacts "$BIN" "$ROOT/plugins" \
|| { hist_event deploy fail "$(read_pid)" "产物复制失败"; return 1; } || { hist_event deploy fail "$(read_pid)" "产物复制失败"; return 1; }
;; ;;
dev) dev)
wt="$(materialize_dev_commit "$SRC_FULL" "$SRC_SHA" build)" \ wt="$(materialize_dev_commit "$SRC_FULL" "$SRC_SHA" build)" \
|| { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; } || { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; }
if ! svc_deploy_identity_gate "$wt/bin/agent-system"; then
hist_event deploy fail "$(read_pid)" "候选身份门禁拒绝($SRC_LABEL)"
recycle_worktree "$SRC_SHA"
return 1
fi
do_stop_quiet
install_artifacts "$wt/bin/agent-system" "$wt/plugins" \ install_artifacts "$wt/bin/agent-system" "$wt/plugins" \
|| { hist_event deploy fail "$(read_pid)" "产物复制失败"; return 1; } || { hist_event deploy fail "$(read_pid)" "产物复制失败"; return 1; }
recycle_worktree "$SRC_SHA" recycle_worktree "$SRC_SHA"
;; ;;
release) release)
local relbin="$REL_ROOT/build/$REL_VERSION/agent-system"
if [ ! -f "$relbin" ]; then
c_err "[$ENV_NAME] Releases 仓无制品:$relbin"
c_err "先在 Releases 仓构建:scripts/build-release.sh $REL_VERSION(或换版本:scripts/svc.sh commits release)"
hist_event deploy fail "$(read_pid)" "制品缺失($SRC_LABEL)"
return 1
fi
svc_deploy_identity_gate "$relbin" \
|| { hist_event deploy fail "$(read_pid)" "候选身份门禁拒绝($SRC_LABEL)"; return 1; }
do_stop_quiet
materialize_release || { hist_event deploy fail "$(read_pid)" "物化/校验失败($SRC_LABEL)"; return 1; } materialize_release || { hist_event deploy fail "$(read_pid)" "物化/校验失败($SRC_LABEL)"; return 1; }
;; ;;
rdm) rdm)
@@ -1837,6 +1913,12 @@ agent_do_deploy() {
|| { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; } || { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; }
# RDM 组合插件编译进二进制(provides 无声明式资产)——插件目录按空集安装。 # RDM 组合插件编译进二进制(provides 无声明式资产)——插件目录按空集安装。
mkdir -p "$wt/.svc-empty-plugins" mkdir -p "$wt/.svc-empty-plugins"
if ! svc_deploy_identity_gate "$wt/bin/agent-system-rdm"; then
hist_event deploy fail "$(read_pid)" "候选身份门禁拒绝($SRC_LABEL)"
recycle_worktree "rdm-$SRC_SHA" "$RDM_SRC_DIR"
return 1
fi
do_stop_quiet
install_artifacts "$wt/bin/agent-system-rdm" "$wt/.svc-empty-plugins" \ install_artifacts "$wt/bin/agent-system-rdm" "$wt/.svc-empty-plugins" \
|| { hist_event deploy fail "$(read_pid)" "产物复制失败"; return 1; } || { hist_event deploy fail "$(read_pid)" "产物复制失败"; return 1; }
# RDM 自带零构建工作台(web/)落 htdocs/;收尾自动部署伴生 ${name}web。 # RDM 自带零构建工作台(web/)落 htdocs/;收尾自动部署伴生 ${name}web。
@@ -2326,7 +2408,8 @@ agent_do_start() {
hosts="$(env_var "$ENV_NAME" ALLOWED_HOSTS)" hosts="$(env_var "$ENV_NAME" ALLOWED_HOSTS)"
[ -n "$insecure" ] && guard_env+=("AGENT_SYSTEM_INSECURE_LISTEN=$insecure") [ -n "$insecure" ] && guard_env+=("AGENT_SYSTEM_INSECURE_LISTEN=$insecure")
[ -n "$hosts" ] && guard_env+=("AGENT_SYSTEM_ALLOWED_HOSTS=$hosts") [ -n "$hosts" ] && guard_env+=("AGENT_SYSTEM_ALLOWED_HOSTS=$hosts")
# U1 身份校验:文件 marker + DB marker(ensure 幂等补空表;不一致 fail-closed)。 # U1 身份校验:文件 marker + DB verify(不一致 fail-closed;ensure 只在领养与
# 显式 init,普通启动不补行——缺行即事件,见 svc_manifest_verify)。
svc_manifest_verify || { hist_event start fail "" "manifest 身份校验失败"; return 1; } svc_manifest_verify || { hist_event start fail "" "manifest 身份校验失败"; return 1; }
# 预检(快速拦截):非回环地址且认证判定为关时提前拒绝,报错可操作—— # 预检(快速拦截):非回环地址且认证判定为关时提前拒绝,报错可操作——
# 不等二进制 listen guard 以"进程即退"暴露;二进制守卫仍是权威。 # 不等二进制 listen guard 以"进程即退"暴露;二进制守卫仍是权威。
@@ -4311,6 +4394,17 @@ cmd_update() {
hist_event update begin "$(read_pid)" "from=$from to=$tov" hist_event update begin "$(read_pid)" "from=$from to=$tov"
CLI_FROM="$deploy_from" CLI_FROM="$deploy_from"
svc_lock wait svc_lock wait
# U1:批量更新与单实例生命周期同入口——锁内 manifest 迁移(领养/身份治理;
# 2026-10-12 rdm1 实况:update 直入 agent_do_deploy 绕过迁移,多次更新后仍无
# manifest)。归属冲突按该服务更新失败计,其余服务继续。
if ! svc_manifest_migrate; then
hist_event update fail "$(read_pid)" "from=$from to=$tov(manifest 迁移中止:身份治理冲突)"
c_err "[$name] 更新失败(manifest 迁移中止),其余服务继续"
svc_unlock
CLI_FROM=""
failed=$((failed + 1))
continue
fi
if agent_do_deploy; then if agent_do_deploy; then
hist_event update ok "$(read_pid)" "from=$from to=$tov" hist_event update ok "$(read_pid)" "from=$from to=$tov"
c_ok "[$name] 已更新:$from → $tov" c_ok "[$name] 已更新:$from → $tov"
+207
View File
@@ -210,6 +210,213 @@ class IdentityTests(tsr.SvcAutoRecoverTests):
con.close() con.close()
return rows return rows
# ---- U1 批量隔离、候选门禁与启动语义(2026-10-12 rdm1/rdmcand 事故回归) ----
def noid_stub(self):
"""自包含「无 identity」桩:复用 recover STUB,把 identity 分支换成 2026-10-12
RDM 组合产物形态——identity 子命令落入服务路径、被身份门禁拒绝,报错文本
恰含「identity ensure」字样(旧探测 grep ensure 的误报源)。不 exec 转发:
serve 进程 cmdline 必须保留实例 bin 路径(pidcheck 契约与真实产物一致)。"""
p = self.root / "stub-agent-system-noid"
if not p.exists():
stub = tsr.STUB
head, sep, tail = stub.partition(" identity)")
assert sep, "STUB 缺 identity 分支锚点"
_, sep2, tail2 = tail.partition(" exit $? ;;")
assert sep2, "STUB 缺 identity 分支结尾锚点"
noid_case = (
" identity)\n"
" echo \"system identity: system identity missing: agent_system_identity 无行"
"(先 svc init / identity ensure,禁止静默建空身份)\" >&2\n"
" exit 1 ;;")
p.write_text(head + noid_case + tail2)
p.chmod(0o755)
return p
def install_noid_bin(self, name):
"""把实例磁盘产物换成无 identity 形态(rdm1/rdmcand 事故现场的制品状态)。"""
shutil.copy2(self.noid_stub(), self.runtime(name) / "bin" / "agent-system")
def set_kv(self, name, key, value):
f = self.runtime(name) / "svc.env"
lines = f.read_text().splitlines()
out, hit = [], False
for l in lines:
if l.startswith(key + "="):
out.append("%s=%s" % (key, value)); hit = True
else:
out.append(l)
if not hit:
out.append("%s=%s" % (key, value))
f.write_text("\n".join(out) + "\n")
def serve_record(self):
f = self.trace / "serve.txt"
return f.read_text() if f.exists() else ""
def clear_serve_record(self):
(self.trace / "serve.txt").unlink(missing_ok=True)
def git_ok(self, cwd, *args):
proc = subprocess.run(["git", *args], cwd=cwd, env=self.env, text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60)
self.assertEqual(proc.returncode, 0, proc.stdout)
def git_sha(self, cwd, rev):
proc = subprocess.run(["git", "rev-parse", "--short", rev], cwd=cwd,
env=self.env, text=True,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=30)
return proc.stdout.strip()
def _make_track_repo(self, artifact, target, with_web=False):
"""双提交源仓夹具(旧/新两版):Makefile 产物 = artifact 复制为 target。"""
remote, work = self.root / "trackremote.git", self.root / "trackwork"
for p in (remote, work):
if p.exists():
shutil.rmtree(p)
self.git_ok(self.root, "init", "--bare", "--initial-branch=main", str(remote))
self.git_ok(self.root, "init", "--initial-branch=main", str(work))
(work / "Makefile").write_text(
"build:\n\tmkdir -p bin\n\tcp %s bin/%s\n" % (artifact, target))
if with_web:
(work / "web").mkdir()
(work / "web" / "index.html").write_text("<html>rdm</html>\n")
self.git_ok(work, "add", ".")
self.git_ok(work, "commit", "-m", "track v1")
self.git_ok(work, "remote", "add", "origin", str(remote))
self.git_ok(work, "push", "-u", "origin", "main")
(work / "v2.marker").write_text("v2\n")
self.git_ok(work, "add", ".")
self.git_ok(work, "commit", "-m", "track v2")
self.git_ok(work, "push")
return remote, self.git_sha(work, "HEAD~1"), self.git_sha(work, "HEAD")
def make_src_repo(self):
"""dev 轨道:产物 = 可用 identity 桩;预克隆进 SVC_SRC_DIR(srccache)。"""
remote, old, new = self._make_track_repo(self.stub, "agent-system")
cache = self.root / "srccache"
if cache.exists():
shutil.rmtree(cache)
self.git_ok(self.root, "clone", str(remote), str(cache))
return old, new
def make_rdm_repo(self, artifact):
"""rdm 轨道:产物 = 指定桩(可注入无 identity 形态)。"""
return self._make_track_repo(artifact, "agent-system-rdm", with_web=True)
def rdm_env(self, remote):
env = dict(self.env)
env.update({"SVC_RDM_SRC_DIR": str(self.root / "rdmcache"),
"SVC_RDM_ORIGIN": str(remote)})
return env
def test_U1_update_refuses_identityless_candidate_keeps_old_state(self):
"""manifest 治理实例 × 无 identity 候选(RDM 旧形态):停服前拒绝——
旧服务继续运行、旧制品与登记不变(2026-10-12 rdm1/rdmcand 反例:停服→
覆盖→启动即拒,只剩混合状态)。"""
remote, old_sha, new_sha = self.make_rdm_repo(self.noid_stub())
self.seed_agent("rdx", 9451)
self.start_ok("rdx") # 领养:manifest + DB 行
self.svc("rdx", "stop")
self.install_noid_bin("rdx") # 磁盘产物退化为无 identity(事故现场)
self.set_kv("rdx", "source", "rdm@%s" % old_sha)
self.start_ok("rdx") # 校验跳过,照常运行
pid = self.pid_of("rdx")
bin_before = (self.runtime("rdx") / "bin" / "agent-system").read_bytes()
result = self.svc("update", "rdx", check=False, env=self.rdm_env(remote))
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("候选产物不支持 identity 管理", result.stdout)
# 拒绝发生在停旧服务之前:进程、制品、登记全部保持
self.assertTrue(self.running("rdx"), "拒绝不得停掉可用旧服务")
self.assertEqual(self.pid_of("rdx"), pid)
self.assertEqual((self.runtime("rdx") / "bin" / "agent-system").read_bytes(),
bin_before, "旧制品不得被覆盖")
self.assertEqual(self.kv("rdx", "source"), "rdm@%s" % old_sha)
hist = (self.runtime("rdx") / "history.jsonl").read_text()
self.assertIn('"event":"update"', hist)
self.assertIn('"result":"fail"', hist)
self.svc("rdx", "stop")
def test_U1_update_ensures_missing_row_before_stop(self):
"""领养时未能补行的实例(旧产物无 identity):候选有能力时,update 在
停服前用候选补齐 DB 身份行——完整更新成功;serve 拿到的是本服务自己的
身份(非批内残留)。"""
old_sha, new_sha = self.make_src_repo()
self.seed_agent("rowx", 9452)
self.start_ok("rowx")
self.svc("rowx", "stop")
self.install_noid_bin("rowx")
self.set_kv("rowx", "source", "dev@%s" % old_sha)
self.start_ok("rowx")
# 模拟领养时未补行(rdm1 现状):清空 DB 身份行
db = Path(self.manifest("rowx")["identity"]["data_root"]) / "agent-system.db"
con = sqlite3.connect(str(db))
con.execute("DELETE FROM agent_system_identity")
con.commit()
con.close()
self.clear_serve_record()
result = self.svc("update", "rowx")
self.assertIn("已更新", result.stdout)
self.assertEqual(self.kv("rowx", "source"), "dev@%s" % new_sha)
iid = self.manifest("rowx")["instance_id"]
self.assertEqual(self.db_identity_row("rowx"), [(iid, "sqlite")],
"候选应在停服前补齐身份行")
self.assertTrue(self.running("rowx"))
self.assertIn("AGENT_SYSTEM_INSTANCE_ID=%s" % iid, self.serve_record(),
"serve 只允许拿到本服务自己的身份")
self.svc("rowx", "stop")
def test_U1_bare_start_missing_row_is_event_init_recovers(self):
"""普通启动只验证不补行:DB 行缺失 → 拒绝且不建空行(缺行即事件);
显式 init 补齐后恢复。"""
self.seed_agent("rowm", 9453)
self.start_ok("rowm")
iid = self.manifest("rowm")["instance_id"]
self.svc("rowm", "stop")
db = Path(self.manifest("rowm")["identity"]["data_root"]) / "agent-system.db"
con = sqlite3.connect(str(db))
con.execute("DELETE FROM agent_system_identity")
con.commit()
con.close()
result = self.svc("rowm", "start", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("DB 身份校验失败", result.stdout)
self.assertFalse(self.running("rowm"))
self.assertEqual(self.db_identity_row("rowm"), [], "缺行不得被 start 静默补建")
self.svc("rowm", "init") # 修复路径:显式 ensure
self.assertEqual(self.db_identity_row("rowm"), [(iid, "sqlite")])
self.start_ok("rowm")
self.svc("rowm", "stop")
def test_U1_capability_probe_no_false_positive_no_hang(self):
"""能力探测无副作用:含 ensure 字样的服务态门禁报错不算能力(旧实现
误报点);identity 即挂死的产物探测有界返回无能力。"""
def probe(binpath):
# svc.sh 顶层启用 errexit:source 后裸调用失败函数会杀掉探针 shell,
# 用 if 条件上下文(errexit 豁免)取回真实返回码。
return subprocess.run(
["bash", "-c",
'source "%s" >/dev/null 2>&1; ENV_BIN="%s"; '
'if svc_bin_has_identity; then echo rc=0; else echo rc=$?; fi'
% (self.root / "scripts" / "svc.sh", binpath)],
cwd=self.root, env=self.env, text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=30).stdout.strip()
self.assertIn("rc=0", probe(self.stub), "identity 桩应判有能力")
self.assertIn("rc=1", probe(self.noid_stub()),
"报错文本含 ensure 不得误报为有能力")
hang = self.root / "stub-hang"
hang.write_text(
'#!/usr/bin/env bash\ncase "${1:-}" in identity) sleep 30 ;; '
'*) exec "%s" "$@" ;; esac\n' % self.stub)
hang.chmod(0o755)
self.assertIn("rc=1", probe(hang), "探测挂死须有界返回无能力")
def test_I3_start_ensures_sqlite_identity(self): def test_I3_start_ensures_sqlite_identity(self):
self.seed_agent("idb1", 9441) self.seed_agent("idb1", 9441)
self.start_ok("idb1") self.start_ok("idb1")
+10
View File
@@ -236,6 +236,16 @@ class ResolverStateIsolation(unittest.TestCase):
) )
self.assertEqual(out.strip(), "1") self.assertEqual(out.strip(), "1")
def test_env_config_resets_identity_env(self):
# 棘轮:env_config 必须重置 AGENT_SYSTEM_INSTANCE_ID/DBREF(删除即回归
# 「批量 update 前一服务 verify 导出的身份串给下一服务」——2026-10-12
# rdm1/rdmcand:dev 身份漏给 RDM,serve 带外宾身份被门禁拒绝)。
out = _bash(
'source "$1"; declare -f env_config | '
'grep -c \'unset AGENT_SYSTEM_INSTANCE_ID AGENT_SYSTEM_DBREF\''
)
self.assertEqual(out.strip(), "1")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+5
View File
@@ -111,6 +111,11 @@ PYID
exit 0 ;; exit 0 ;;
esac esac
printf 'args: %s\n' "$*" > "$trace_dir/serve.txt" printf 'args: %s\n' "$*" > "$trace_dir/serve.txt"
# 身份环境记录(U1 批量隔离回归观测点):serve 实际拿到谁的实例身份。
if [ -n "${AGENT_SYSTEM_INSTANCE_ID:-}" ]; then
printf 'AGENT_SYSTEM_INSTANCE_ID=%s\nAGENT_SYSTEM_DBREF=%s\n' \
"$AGENT_SYSTEM_INSTANCE_ID" "${AGENT_SYSTEM_DBREF:-sqlite}" >> "$trace_dir/serve.txt"
fi
addr=""; data="./data" addr=""; data="./data"
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in --addr) addr="$2"; shift 2 ;; --data) data="$2"; shift 2 ;; *) shift ;; esac case "$1" in --addr) addr="$2"; shift 2 ;; --data) data="$2"; shift 2 ;; *) shift ;; esac