Bind svc user CLI to instance storage and reject lock bypass

Why: U1 requires account ops to hit manifest data_root/DSN with OpenExisting and refuse missing flock or default ./data.
Evidence: python3 -m unittest tests.test_svc_user_binding (T1–T5) + test_svc_identity
Impact: svc <env> user … calls ENV_BIN with --open-existing; deploy/user fail closed without flock.
Refs: docs/svc-deploy/u1-instance-account-binding.md; Backend OpenExisting (PR #171)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-12 03:55:24 +00:00
co-authored by Cursor
parent d978de4715
commit 2a03dd58f9
9 changed files with 531 additions and 19 deletions
+13 -10
View File
@@ -7,33 +7,36 @@
- 权威仓:ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Dev.git;目标分支:main;R0(initial_base_sha)= 9a9665b458916179df6d94066dfb9aa657a13d60
- 工作区:/home/weichen/projects/Agent-System-Dev-wt-feature-svc-deploy-u1-account-binding(worktree);分支:feature/svc-deploy-u1-account-binding
- 授权与交付终点:<本次允许的动作与终点;未授权事项写明>
- 授权与交付终点:2026-10-12 用户批准提案 r1 并授权 U1 实施;允许推送与 PR;不合入;非目标 U2/U3
- 交付模式:manual(逐阶段人工评审);明确授权后可记为 authorized-continuous
- 自动推进授权:无;授权范围:无;授权来源:无;授权终点:无;停止条件:无(未授权)
- 当前阶段:需求拆解;当前增量:I1(待定义)
- 当前阶段:实施与自检;当前增量:U1 flock 收紧 + svc user 绑定
- 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位)
## 目标与非目标
<预期结果、受影响入口/模块/契约;明确不做什么。>
目标:账户 CLI 绑定实例 manifest/OpenExisting;无 flock 拒绝冲突操作;不破坏 recovery U1。
非目标:U2 下载、U3 doctor、U4 Key 语义、U5 页面。
## 关联方向与规划
- 适用状态:待核对(无适用规划时说明原因)
- 主记录路径/版本:待核对(路径按仓库根目录定位,归档后不变;只引用唯一正文)
- 对应里程碑:待核对
- 适用状态:适用
- 主记录路径/版本:`modules/documents/docs/proposals/2026-10-12-svc-unified-deploy-and-login-key.md` r1 §7 U1
- 对应里程碑:svc 统一部署 U1
## 需求分析
| ID | 描述 | 项目符合度及依据 | 优先级 | 验收条件 | 依赖 | 结论 |
|---|---|---|---|---|---|---|
| R1 | 待填写 | 待评估 | 待定 | 待定义 | 待确认 | 待评审 |
| R1 | flock 不可用拒绝冲突操作 | 规格 B4 | 高 | T4 | — | 纳入 |
| R2 | user 绑定 manifest data/DSN + OpenExisting | 规格 B1–B3 | 高 | T1–T3 | U4 OpenExisting | 纳入 |
| R3 | recovery 身份回归 | 规格 T5 | 高 | test_svc_identity | #415 | 纳入 |
## 方案与计划
- 方案引用:待填写(含对象版本)
- 实施计划引用:待填写(含增量与需求 ID)
- 测试计划引用:待填写(含需求 ID 与验收条件)
- 方案引用:`docs/svc-deploy/u1-instance-account-binding.md` v0.1
- 实施计划引用:计划「批准后推进:文档 PR + U1/U4 实施」D 节
- 测试计划引用:规格 §6 T1–T5
## 阶段评审
@@ -0,0 +1,78 @@
# U1 实例—账户绑定补齐(规格 v0.1)
状态:已启动实施(分支 `feature/svc-deploy-u1-account-binding`)。主记录:`modules/documents/docs/proposals/2026-10-12-svc-unified-deploy-and-login-key.md` §7 U1。
前置:恢复方案 recovery U1 已合入 main(Dev #415)——`docs/recovery/u1-identity-manifest.md`、`scripts/svc_manifest.py`、启动身份校验与 `svc init`。
本增量**不重做** manifest 层;只补齐账户操作与安装入口对既有身份合同的消费缺口。
基线:任务卡 R0;实施中。真实 PG 绑定专项缺环境标 NOT_RUN。
## 1. 目标与非目标
**目标:** 同一实例的启停、升级、开户、签发/撤销登录 Key 始终命中同一存储与数据根;缺失、不符或锁不可用时拒绝;为后续 `install` / doctor 提供稳定的实例解析入口。
非目标:不实现二进制下载(U2)、不实现 doctor/env prepare 全量(U3)、不实现一次性 Key 语义(U4)、不改登录页(U5)。
## 2. 不变量(相对 recovery U1)
| ID | 不变量 | 相对 #415 |
|---|---|---|
| B1 | 账户 CLI 只接受「实例名 → manifest 解析出的 data_root / db / secrets 引用」 | 新增消费方 |
| B2 | 禁止回落默认 `./data` 或未声明 DSN | 新增硬拒绝 |
| B3 | OpenExisting:目标不存在则失败;可选禁止自动 migrate | Backend 新增打开模式 |
| B4 | 无 `flock` / 锁获取失败 → 拒绝冲突操作(deploy/start/stop/user/*) | 收紧现状「直通」 |
| B5 | `install` / 兼容 `--from` 写入同一 manifest 身份字段,不另起配置根 | 入口骨架 |
## 3. 触点与文件
| 文件 | 变更 |
|---|---|
| `scripts/svc.sh` | `user` 子命令骨架:解析 `ENV_NAME` → 读 manifest/`svc_manifest.py` → 组装 `--data`/`AGENT_SYSTEM_DSN`/`--open-existing` 调 `$ENV_BIN`;锁检查收紧;`install` 可先做 dry-run 计划打印(完整 binary/source 属 U2) |
| `scripts/svc_manifest.py` | 如需:导出「账户操作所需最小字段」查询(data_root、db mode/ref、auth);不改 schema=1 语义 |
| `modules/backend/cmd/agent-system/main.go` | `user` 子命令增加 `--open-existing`(或全局 store 打开标志);缺库/缺目录失败 |
| `modules/backend/internal/store/store.go` | `OpenExistingSQLite` / PG 等价:不 `MkdirAll` 创建新数据根;可选 `MigrateMode=requireCompatible\|forbid` |
| `tests/test_svc_identity.py` 或新 `tests/test_svc_user_binding.py` | 错误 data、缺 manifest、无 flock 拒绝;正确绑定命中 |
## 4. OpenExisting 合同
```
OpenExisting(path|dsn, opts):
if storage path/parent missing → error (do not create)
if SQLite file missing → error
open DB
if opts.migrate == forbid → read schema version only; mismatch → error
if opts.migrate == requireCompatible → existing migrate(); reject unknown newer schema (keep current migrate gate)
never create empty business DB as side effect of account recovery
```
CLI 默认:账户运维命令使用 `OpenExisting` + `requireCompatible`。
`svc init` / 首次服务启动仍走现有 Open(可创建)路径,与 recovery U1 一致。
## 5. 锁合同
- 复用 `runtime/<name>/.start.lock`。
- `flock` 不可用或超时:退出非 0,写 hist_event,**不得**执行 deploy/start/stop/user。
- 账户操作与生命周期互斥(同一锁)。
## 6. 验收用例(U1)
| # | 场景 | 判据 |
|---|---|---|
| T1 | 实例有合法 manifest;`svc <name> user list` | 使用 manifest data_root/DSN;列表与该库一致 |
| T2 | 故意传错误 `--data` 或未绑定实例 | 拒绝;不创建目录;不写默认 `./data` |
| T3 | 删除 data 根后再 user create | OpenExisting 失败;无新空库 |
| T4 | 隐藏 `flock`(PATH 去掉)后 deploy/user | 拒绝;旧服务若在跑则仍在 |
| T5 | recovery U1 既有 T1–T6 回归 | 全绿(不破坏 identity) |
真实 PG:绑定 dbref 与 marker 一致才允许 user 操作;缺环境标 NOT_RUN。
## 7. 完成门槛与依赖
- 完成条件见主提案 §7 U1 行。
- 依赖:main 已含 recovery U1(#415)。
- 被依赖:U4 CLI 签发、U5 `svc user` 接线、U2 `install` 写身份。
## 8. 任务卡建议(待授权后 `task-start`)
- 分支名:`feature/svc-deploy-u1-account-binding`
- R0:与本提案合入后的 `origin/main`(或与 U4 兄弟共享批准时的同一 R0)
- 交付模式:manual;实施需主提案阶段批准后再授权
+4
View File
@@ -0,0 +1,4 @@
# 草稿:feature/svc-deploy-u1-account-binding
状态:**已启动** → 任务卡 `.task/feature-svc-deploy-u1-account-binding.md`;工作区
`/home/weichen/projects/Agent-System-Dev-wt-feature-svc-deploy-u1-account-binding`。
+160
View File
@@ -0,0 +1,160 @@
# U4 一次性登录凭据与认证限制(规格 v0.1)
状态:设计候选,随主提案 r1 评审。主记录:`modules/documents/docs/proposals/2026-10-12-svc-unified-deploy-and-login-key.md` §7 U4 / §8。
本增量在 **Backend**(及必要迁移/CLI)落地;Frontend 兑换与设密页属 **U5**,但 U4 须提供稳定 HTTP 合同供 U5 接线。
基线(写作时 SELF):Backend 随 Dev `af9c2de` gitlink;关键现状见主提案 §2。业务实施 **NOT_RUN**。
## 1. 目标与非目标
**目标:** 无初始密码开户与已有账户恢复均通过一次性登录 Key;Key 只能经专用接口原子兑换为「仅可设密」的短期会话;服务端拒绝受限会话与待改密会话访问业务 API;设密在同一事务内完成且成功后强制重新密码登录。
非目标:不改长期 `api_keys` 的集成语义(除失陷显式撤销);不实现 svc 接线与登录页(U5);不做 doctor/部署(U1–U3)。
## 2. 现状锚点(必须改掉的行为)
| 现状 | 出处 | U4 动作 |
|---|---|---|
| CLI 省略密码 → `RandomPassword()` + 明文打印 | `app.CreateUserAccount` | 默认无密码 + 签发 activate Key;随机密码可保留为显式 `--password` / `--generate-password` |
| `api_keys` 可重复使用、无 TTL | `store/auth.go` | **不改**长期 Key;新增独立表 |
| Middleware 不看 `must_change` | `server/auth.go` | 统一拦截 |
| `changePassword` 强制 `current` | `server/accounts.go` | 分型:受限会话走 set-initial / set-recovered |
| `SetUserPassword` 总置 `must_change=1` | `store/auth.go` | 设密成功路径一次写清,避免半完成 |
| Open 必 MkdirAll + migrate | `store/store.go` | 账户运维走 OpenExisting(与 U1 合同一致;可本增量先做 Backend API) |
## 3. 数据模型
### 3.1 `login_keys`(名称可微调,勿混入 `api_keys`)
| 列 | 说明 |
|---|---|
| `id` | 稳定凭据编号(对外审计/撤销用,非明文) |
| `owner` | 目标用户 |
| `purpose` | `activate` \| `recover` |
| `key_hash` | SHA-256(或更强);明文前缀建议 `alk_`(activation/login key) |
| `status` | `unused` \| `consumed` \| `revoked` \| `expired` |
| `expires_at` | 默认 now+30m;签发时可配置(夹紧 5–120m) |
| `consumed_at` | 可空 |
| `created_at` / `created_by` | 审计(CLI 操作者或 `system`) |
| `instance_id` | 可选;与部署身份关联时填写 |
唯一性:同一明文只能对应一行;未使用 Key 可按 (owner, purpose) 允许多条,但签发新 Key 时建议撤销同 purpose 的未使用旧 Key(规格默认:**签发即撤销同 owner+purpose 的 unused**)。
### 3.2 `users` 变更
- `password_hash`:允许 NULL / 空,表示未设置。
- 保留 `must_change_password`:用于「已有随机密码首登」迁移路径;无密码账户用「无 hash」+ activate 流,不依赖假密码。
- 角色:首期仍映射 `is_admin`;API 可收 `role=admin|user`,未知角色 **拒绝**(不静默降级)。
### 3.3 `sessions` 变更
增加 `scope`(或等价):`full` \| `set_password_only`。
受限会话 TTL 默认 10 分钟。
`CreateSession(owner, scope, ttl)`。
## 4. CLI 合同
```
agent-system user create --owner <id> --role user|admin [--open-existing]
→ 无 --password:password 未设置 + 同事务签发 activate Key;stdout 明文一次
→ --password / --generate-password:兼容旧路径(must_change=1)
agent-system user issue-login-key --owner <id> --purpose activate|recover [--ttl 30m] [--open-existing]
→ 精确命中已有用户;不改 password_hash;不吊销 full 会话;不改业务数据
→ recover 不删除重建
agent-system user revoke-login-key --id <id> [--open-existing]
agent-system user list # 含是否已设密、未使用登录 Key 计数(无明文)
```
首次管理员:
```
agent-system user bootstrap-admin --owner <id> [--open-existing]
```
仅当:库已存在且 **无任何 users 行** 且 **无历史业务标记**(规格:至少 `projects`/`messages` 等核心表为空或实例 init marker 声明 fresh)。并发用事务/`UNIQUE` 保证单胜。有数据却无管理员 → **拒绝自动接管**,要求显式运维流程(本增量可只返回明确错误)。
失陷恢复:
```
... issue-login-key --purpose recover --revoke-all-credentials
```
默认 **不**撤销长期 `api_keys`;加旗标才撤销全部 api_keys + 可选吊销 full 会话。
## 5. HTTP 合同
| 方法 | 路径 | 认证 | 行为 |
|---|---|---|---|
| POST | `/api/auth/login-key/redeem` | 无(body: `{ "key": "alk_..." }`) | 事务:校验 unused+未过期 → 条件更新 consumed → 建 `set_password_only` 会话 → 设 cookie → 提交 |
| POST | `/api/auth/password` | session | `scope=full`:要求 `current`(现行为);`scope=set_password_only`:忽略 current,要求 `next`+确认由客户端做,服务端校验强度 |
| POST | `/api/auth/logout` | session | 允许受限会话 |
| GET | `/api/me` | session | 允许受限会话;响应含 `sessionScope`、`mustChangePassword`、`passwordSet` |
| 其他 `/api/*` | — | — | `set_password_only` **或** (`must_change` 且非上述白名单) → **403** |
禁止:用 Bearer / `?key=` 提交一次性登录 Key;redeem 外任何路径接受 `alk_`。
兑换并发:`UPDATE ... WHERE id=? AND status='unused' AND expires_at>now` 影响行数必须为 1;否则失败。消费后建会话失败 → **整事务回滚**(Key 仍为 unused)。
设密成功事务(受限或兼容 must_change):
1. 写 password_hash
2. 清 must_change;标记已设密
3. 作废该 owner 全部 unused/本 purpose 登录 Key
4. 删除该 owner 全部会话(含当前受限会话)
5. 清 cookie / 不发新 full 会话
6. 响应指示 `reloginRequired: true`
半完成态禁止:密码已变但旧 Key 仍 unused;或 Key 已 consumed 但无会话且密码未变(兑换回滚已覆盖后者)。
## 6. 中间件伪代码
```
after AuthOwner/SessionOwner:
if session.scope == set_password_only:
if path not in {GET /api/me, POST /api/auth/password, POST /api/auth/logout}:
403
else if user.must_change_password or not user.password_set:
if path not in same allowlist:
403
// API key (long-lived): if must_change or not password_set → 403 on business routes
// (key cannot complete password change)
```
## 7. 测试计划(Backend)
| # | 场景 | 判据 |
|---|---|---|
| T1 | create 无密码 | DB hash 空;一条 unused activate Key;stdout 有明文一次 |
| T2 | 同名 create | 冲突;原账户与 Key 不变 |
| T3 | redeem 成功 | Key consumed;cookie 为 set_password_only;/api/me 200;/api/projects 403 |
| T4 | 同 Key 并发 100 次 redeem | 恰好 1 成功 |
| T5 | 过期 / 撤销 / 重放 | 均失败 |
| T6 | 设密成功 | hash 在;Key 作废;会话全删;再带旧 cookie → 401;密码登录 success 且 scope=full |
| T7 | 设密中途 DB 失败(注入) | 无「已改密但仍 unused Key」 |
| T8 | redeem 后 CreateSession 失败 | Key 仍 unused |
| T9 | recover 签发 | 不改 hash;不删业务行;可选保留 api_keys |
| T10 | must_change 旧路径 + 中间件 | 业务 API 403;设密后可访问 |
| T11 | OpenExisting 缺库 | CLI 失败不建目录 |
SQLite 与真实 PG 双跑;缺 PG 环境标 NOT_RUN。
## 8. 安全与交付
- 明文只在签发时交付;日志/evidence 只记 `id`/purpose/结果。
- 不把 Key 放 URL、命令行参数名以外的 argv 常驻、或 localStorage(U5 约束)。
- 对齐 OWASP Forgot Password / Session Management;保留现有 CSRF。
## 9. 完成门槛与依赖
- 完成条件见主提案 §7 U4 行。
- 依赖:U1 OpenExisting 可并行由 Backend 先实现最小集,再与 svc 接线合并。
- 被依赖:U5 登录页兑换与设密分型。
## 10. 任务卡建议(待授权后 `task-start`)
- 分支名:`feature/svc-deploy-u4-login-key`
- 组件:Backend feature 分支 + Dev gitlink;Frontend 不在本增量强制改动(契约测试可用 httptest)
- R0:与 U1 兄弟共享批准时的同一 `origin/main`
- 交付模式:manual
+21
View File
@@ -0,0 +1,21 @@
# 草稿:feature/svc-deploy-u4-login-key 任务卡
状态:**待授权**。主提案批准并获实施授权后,在共享主检出执行:
```bash
RECEIPT=/tmp/precheck-svc-deploy-u4.json CMD="make precheck" make evidence
make task-start NAME=feature/svc-deploy-u4-login-key PRECHECK_RECEIPT=/tmp/precheck-svc-deploy-u4.json
```
将本草稿字段拷入新卡并删除本文件或改为「已启动」链接。
## 建议填写
- 主记录:`modules/documents/docs/proposals/2026-10-12-svc-unified-deploy-and-login-key.md` r1 §7 U4
- 规格:`docs/svc-deploy/u4-one-time-login-key.md` v0.1
- 目标:独立 login_keys;原子兑换;受限会话中间件;设密事务;无密码开户/恢复签发;OpenExisting 最小集
- 非目标:svc 账户接线与登录页(U5);部署来源(U2/U3)
- 组件:Backend 为主;Dev 仅 gitlink / 文档引用;Frontend 不强制本增量改动
- 兄弟任务:可与 `feature/svc-deploy-u1-account-binding` 共用同一 R0
- 验收:规格 §7 T1–T11;SQLite+PG;并发兑换;治理/Backend 测试
- 启动授权:未授权(须主提案阶段批准 + 明确实施授权)
+74 -6
View File
@@ -871,18 +871,25 @@ port_owner() {
# ---- 生命周期互斥锁与期望运行态(autostart;见 help recovery)----
# svc_lock MODE:对当前服务取 .start.lock 互斥锁。MODE=wait 阻塞(最长 300s),
# MODE=-n 非阻塞(抢不到返回 1)。flock 缺失时降级为无锁直通(尽力而为)。
# MODE=-n 非阻塞(抢不到返回 1)。flock 缺失或等待超时 → 拒绝(不得直通冲突操作)。
# 锁随进程退出自动释放(fd 关闭);长驻循环(recover)用 svc_unlock 逐服务显式释放。
SVC_LOCK_HELD=0
svc_lock() {
[ "$SVC_LOCK_HELD" = 1 ] && return 0
command -v flock >/dev/null 2>&1 || { SVC_LOCK_HELD=1; return 0; }
if ! command -v flock >/dev/null 2>&1; then
c_err "[$ENV_NAME] 缺少 flock:拒绝生命周期/账户冲突操作(安装 util-linux 后重试)"
return 1
fi
mkdir -p "$ENV_DIR" 2>/dev/null || return 1
exec 9>>"$ENV_DIR/.start.lock" || return 1
if [ "$1" = "-n" ]; then
flock -n 9 || { exec 9>&-; return 1; }
else
flock -w 300 9 || c_warn "[$ENV_NAME] 生命周期锁等待超时(继续执行,注意并发)"
if ! flock -w 300 9; then
exec 9>&-
c_err "[$ENV_NAME] 生命周期锁等待超时:拒绝继续(避免并发冲突)"
return 1
fi
fi
SVC_LOCK_HELD=1
return 0
@@ -1384,6 +1391,66 @@ materialize_release() {
install_artifacts "$bin" "$art/plugins" || return 1
}
# ---- agent:账户 CLI(绑定实例存储;禁止回落默认 ./data)----
# 用法:scripts/svc.sh <env> user <create|list|...> [--owner …]
# 从实例最终配置 / manifest identity 解析 data/DSN,强制 --open-existing,
# 调用 ENV_BIN 对应版本。拒绝调用方覆盖 --data/--dsn/--open-existing。
agent_do_user() {
local a
for a in "$@"; do
case "$a" in
--data|--dsn|--open-existing|--data=*|--dsn=*|--open-existing=*)
c_err "[$ENV_NAME] 账户操作禁止覆盖存储绑定(勿传 --data/--dsn/--open-existing;由实例 manifest 决定)"
return 2 ;;
esac
done
svc_lock wait || { c_err "[$ENV_NAME] 无法获取生命周期锁(账户操作与启停互斥)"; return 1; }
# 有 manifest 时以 identity.data_root 为权威,拒绝 ENV_DATA 漂移到默认 ./data。
if [ -f "$ENV_DIR/manifest.json" ] && command -v python3 >/dev/null 2>&1; then
local mf_root
mf_root="$(python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); print(m["identity"]["data_root"])' \
"$ENV_DIR/manifest.json" 2>/dev/null || true)"
if [ -n "$mf_root" ]; then
ENV_DATA="$mf_root"
fi
fi
case "${ENV_DATA:-}" in
""|./data|data)
c_err "[$ENV_NAME] 未绑定数据目录:拒绝账户操作(不回落默认 ./data)"
return 1 ;;
esac
resolve_db_config || {
c_err "[$ENV_NAME] 数据库绑定无效:拒绝账户操作(不回落默认 ./data)"
return 1
}
if [ "$DB_MODE" = mock ]; then
c_err "[$ENV_NAME] mock 档不支持账户操作"
return 1
fi
if [ ! -x "${ENV_BIN:-}" ]; then
c_err "[$ENV_NAME] 实例二进制不存在:$ENV_BIN(先 deploy)"
return 1
fi
if [ "$DB_MODE" = pg ] && [ -z "${DB_DSN:-}" ] && [ "${DB_SOURCE:-}" != conffile ]; then
c_err "[$ENV_NAME] PostgreSQL 档缺少 DSN:拒绝账户操作"
return 1
fi
# 嵌入式档:数据文件必须已存在(OpenExisting);PG 档由 --open-existing 在后端核验。
if [ "$DB_MODE" != pg ]; then
if [ ! -d "$ENV_DATA" ] || [ ! -f "$ENV_DATA/agent-system.db" ]; then
c_err "[$ENV_NAME] 既有库不存在:$ENV_DATA/agent-system.db(OpenExisting:拒绝创建空库)"
return 1
fi
fi
local bin_args=(--open-existing --data "$ENV_DATA")
if [ "$DB_MODE" = pg ] && [ -n "${DB_DSN:-}" ]; then
bin_args+=(--dsn "$DB_DSN")
fi
c_info "[$ENV_NAME] 账户 CLI → $ENV_BIN user $* (data=$ENV_DATA open-existing)"
# 子命令参数在前,绑定 flags 在后(Go flag 不区分顺序)。
AGENT_SYSTEM_DSN="${DB_DSN:-}" "$ENV_BIN" user "$@" "${bin_args[@]}"
}
# ---- agent:部署与启动 ----
agent_do_deploy() {
@@ -3954,17 +4021,18 @@ main() {
# 动作词可省略(省略 = deploy);flags 可直接跟随服务名。
local action="deploy"
case "${1:-}" in
deploy|stop|start|restart|status|logs|db|config) action="$1"; shift ;;
deploy|stop|start|restart|status|logs|db|config|init|user) action="$1"; shift ;;
esac
if [ "$action" = logs ] && [ "${1:-}" != "" ] && [[ "$1" =~ ^[0-9]+$ ]]; then
CLI_LOGS_N="$1"; shift
fi
# db / config 子命令:自带参数解析(位置参数不走 parse_args;未知 flags 由其自行拒绝)。
if [ "$action" = db ] || [ "$action" = config ]; then
# db / config / user 子命令:自带参数解析(位置参数不走 parse_args)。
if [ "$action" = db ] || [ "$action" = config ] || [ "$action" = user ]; then
env_config "$ENV_NAME"
case "$action" in
db) svc_db_bind "$@" || exit $? ;;
config) svc_config "$@" || exit $? ;;
user) agent_do_user "$@" || exit $? ;;
esac
exit 0
fi
+9 -2
View File
@@ -95,6 +95,13 @@ PYSRV
child=$!
wait "$child"
;;
user)
printf 'user %s\n' "$*" >> "$trace_dir/cli.txt"
case "${2:-}" in
list) echo '[{"owner":"op","name":"op","isAdmin":true,"mustChangePassword":false}]' ;;
create) echo '{"owner":"new","issued":true}' ;;
esac
exit 0 ;;
*)
exit 0 ;;
esac
@@ -156,10 +163,10 @@ class SvcAutoRecoverTests(unittest.TestCase):
# ---- 夹具 ----
def svc(self, *args, check=True):
def svc(self, *args, check=True, env=None):
proc = subprocess.run(
["bash", str(self.root / "scripts" / "svc.sh"), *map(str, args)],
cwd=self.root, env=self.env, text=True,
cwd=self.root, env=env if env is not None else self.env, text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=120)
if check:
self.assertEqual(proc.returncode, 0, proc.stdout)
+171
View File
@@ -0,0 +1,171 @@
"""svc.sh 账户 CLI 实例绑定(U1 规格 §6 T1–T5)。
覆盖:合法 manifest 下 user list 命中 data_root+open-existing(T1)、禁止覆盖
--data(T2)、删除 data 根后 OpenExisting 拒绝且不建空库(T3)、无 flock 拒绝
user/deploy(T4)。T5 身份回归由 test_svc_identity 同发现执行;此处仅冒烟。
桩与夹具复用 tests/test_svc_recover.py;manifest 辅助对齐 test_svc_identity。
"""
import importlib.util
import json
import os
import shutil
import unittest
from pathlib import Path
SOURCE = Path(__file__).resolve().parents[1]
_spec = importlib.util.spec_from_file_location("tsr", SOURCE / "tests" / "test_svc_recover.py")
tsr = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(tsr)
class UserBindingTests(tsr.SvcAutoRecoverTests):
def setUp(self):
super().setUp()
shutil.copy2(SOURCE / "scripts" / "svc_manifest.py",
self.root / "scripts" / "svc_manifest.py")
self.trace.mkdir(exist_ok=True)
def manifest(self, name):
p = self.runtime(name) / "manifest.json"
return json.loads(p.read_text(encoding="utf-8")) if p.exists() else None
def running(self, name):
pid = self.pid_of(name)
if not pid:
return False
try:
os.kill(pid, 0)
return True
except OSError:
return False
def touch_db(self, name):
data = Path(self.manifest(name)["identity"]["data_root"])
data.mkdir(parents=True, exist_ok=True)
(data / "agent-system.db").write_bytes(b"sqlite-stub")
def cli_trace(self):
f = self.trace / "cli.txt"
return f.read_text(encoding="utf-8") if f.exists() else ""
def clear_cli_trace(self):
f = self.trace / "cli.txt"
if f.exists():
f.unlink()
# ---- T1 合法 manifest:user list 绑定 data_root + --open-existing ----
def test_T1_user_list_binds_manifest_data(self):
self.seed_agent("ub1", 9441)
self.start_ok("ub1")
self.svc("ub1", "stop")
root = self.manifest("ub1")["identity"]["data_root"]
self.touch_db("ub1")
self.clear_cli_trace()
result = self.svc("ub1", "user", "list")
self.assertIn("op", result.stdout)
trace = self.cli_trace()
self.assertIn("user list", trace)
self.assertIn("--open-existing", trace)
self.assertIn("--data %s" % root, trace)
self.assertNotIn("./data", trace)
# ---- T2 禁止覆盖 --data;未绑定默认目录拒绝 ----
def test_T2_reject_override_data_flag(self):
self.seed_agent("ub2", 9442)
self.start_ok("ub2")
self.svc("ub2", "stop")
self.touch_db("ub2")
other = self.root / "evil-data"
other.mkdir()
(other / "agent-system.db").write_bytes(b"x")
result = self.svc("ub2", "user", "list", "--data", str(other), check=False)
self.assertNotEqual(result.returncode, 0)
self.assertTrue(any(k in result.stdout for k in ("禁止覆盖", "勿传")), result.stdout)
self.assertFalse((self.trace / "cli.txt").exists(), "拒绝后不得调用二进制")
def test_T2b_reject_literal_default_data(self):
d = self.runtime("ub2b")
(d / "bin").mkdir(parents=True)
shutil.copy2(self.stub, d / "bin" / "agent-system")
(d / "svc.env").write_text("".join("%s=%s\n" % kv for kv in [
("kind", "agent"), ("env", "ub2b"), ("pid", ""),
("addr", "127.0.0.1:9443"), ("tags", ""),
("source", "local@deadbee"), ("version", "stub"), ("started", ""),
("pidcheck", str(d / "bin" / "agent-system")),
("exec", "--addr 127.0.0.1:9443 --data ./data --plugins %s" % (d / "plugins")),
]))
env = dict(self.env, SVC_UB2B_DATA="./data")
result = self.svc("ub2b", "user", "list", check=False, env=env)
self.assertNotEqual(result.returncode, 0)
self.assertTrue(any(k in result.stdout for k in ("未绑定数据目录", "不回落")),
result.stdout)
# ---- T3 删除 data 根:OpenExisting 失败,不建空库 ----
def test_T3_missing_data_root_rejected(self):
self.seed_agent("ub3", 9444)
self.start_ok("ub3")
self.svc("ub3", "stop")
root = Path(self.manifest("ub3")["identity"]["data_root"])
self.touch_db("ub3")
shutil.rmtree(root)
self.assertFalse(root.exists())
result = self.svc("ub3", "user", "create", "--owner", "x", "--name", "x",
check=False)
self.assertNotEqual(result.returncode, 0)
self.assertTrue(any(k in result.stdout for k in ("既有库不存在", "OpenExisting")),
result.stdout)
self.assertFalse(root.exists(), "拒绝后不得重建数据根")
self.assertFalse((root / "agent-system.db").exists())
# ---- T4 隐藏 flock:user/deploy 拒绝;已运行服务仍在 ----
def test_T4_no_flock_rejects_user_and_deploy(self):
self.seed_agent("ub4", 9445)
self.start_ok("ub4")
self.assertTrue(self.running("ub4"))
self.touch_db("ub4")
# 构造不含 flock 的 PATH:链入常用外部命令,刻意排除 flock。
bin_dir = self.root / "nobin"
bin_dir.mkdir()
for name in (
"bash", "python3", "python", "dirname", "basename", "tr", "cksum",
"cut", "mkdir", "cat", "sed", "grep", "head", "rm", "mv", "cp",
"ls", "chmod", "uname", "date", "sleep", "kill", "ps", "awk",
"sort", "uniq", "env", "printf", "realpath", "readlink", "stat",
"tee", "wc", "find", "xargs", "touch", "ln", "mktemp", "id",
"git", "make", "tar", "gzip", "which", "command",
):
src = shutil.which(name)
if src:
dest = bin_dir / name
if not dest.exists():
dest.symlink_to(src)
self.assertIsNone(shutil.which("flock", path=str(bin_dir)))
env = dict(self.env, PATH=str(bin_dir))
r_user = self.svc("ub4", "user", "list", check=False, env=env)
self.assertNotEqual(r_user.returncode, 0)
self.assertIn("缺少 flock", r_user.stdout)
r_dep = self.svc("ub4", "deploy", "--dry-run", check=False, env=env)
self.assertNotEqual(r_dep.returncode, 0, r_dep.stdout)
self.assertIn("缺少 flock", r_dep.stdout)
self.assertTrue(self.running("ub4"), "拒绝后旧服务应仍在跑")
self.svc("ub4", "stop")
# ---- T5 recovery U1 身份冒烟(完整套件见 test_svc_identity)----
def test_T5_identity_regression_smoke(self):
self.seed_agent("ub5", 9446)
self.start_ok("ub5")
iid = self.manifest("ub5")["instance_id"]
self.svc("ub5", "stop")
self.start_ok("ub5")
self.assertEqual(self.manifest("ub5")["instance_id"], iid)
self.svc("ub5", "stop")
if __name__ == "__main__":
unittest.main()