Approval-First Baota Panel Control Plane — 一个自托管的、单操作者的宝塔服务器管理控制台。AI 提方案,人类做审批,daemon 为每一步操作留下不可篡改的证据。
你的本地项目 → 本地 daemon(只监听 127.0.0.1)→ 私有局域网/VPN → 宝塔面板目标
↑
控制台 UI / CLI / MCP —— 三个入口,同一套 API
- 这是什么
- 解决什么问题
- 当前能力与限制
- 系统架构
- 安全模型:五道闸
- 快速开始
- 项目结构
- CLI 使用手册
- MCP 工具手册
- REST API 参考
- 静态站点发布完整流程
- 宝塔 11.7.0 静态 staging 合约
- 测试指南
- 配置参考
- Docker 部署
- 开发指南
- 路线图
- FAQ
SiteOps 是一个本地运行的宝塔面板管理控制台,核心设计原则是:让 AI 安全地辅助部署,但绝不把服务器钥匙交给 AI 自动操作。
| 特点 | 说明 |
|---|---|
| 自托管 | daemon 只监听 127.0.0.1,不暴露公网,不依赖云服务 |
| 审批优先 | 每次变更操作都必须经过人类审批,审批绑定到精确的操作摘要 |
| 类型安全 | 所有操作都是类型化的(typed),在信任边界用 Zod 解析 |
| 证据链 | 只读追加的回执(receipt),诚实标注恢复等级 |
| 三端一致 | 控制台 UI、CLI、MCP 三个入口走同一套 daemon REST API |
| AI 受限 | AI 可以总结、诊断、起草方案,但不能执行任务、造审批、访问密钥库 |
- 运行时: Node.js 22, TypeScript 5.8 (strict mode)
- 包管理: pnpm 10 monorepo
- HTTP 服务: Fastify 5
- 前端: React 19 + Vite 7
- 校验: Zod 4(在每个信任边界解析)
- 测试: Vitest 3(528 个单测), Playwright 1.54(浏览器 E2E)
- 代码检查: ESLint 9 + Prettier 3
- 数据库: Node.js 内置 SQLite(
node:sqlite)
场景: 你有一些部署在宝塔面板上的网站,你想让 AI 辅助管理(部署、更新配置、监控),但你不敢:
- 把宝塔 API 密钥直接交给 AI
- 让 AI 自动执行任何变更操作
- 在没有人工确认的情况下修改生产服务器
SiteOps 通过一个五道闸管道解决这个问题——每次变更操作都必须依次通过:
能力校验 → 策略评估 → 审批绑定 → 幂等+互斥锁 → 不可变回执
AI 的角色: AI 可以分析证据、诊断问题、起草类型化的操作方案。
AI 不能做的事: 执行任务(jobs)、创建审批、访问加密密钥库、选择任意工具、推断不存在的图关系、执行任何变更操作。
| 能力 | 描述 |
|---|---|
| 模拟器发布管道 | 针对确定性 Baota HTTP 模拟器的完整发布流程:目标纳管、能力发现、签名清单、intake(本地/Git/制品/镜像)、staging、健康检查、promotion、恢复分类 |
| 静态站点 staging(宝塔 11.7.0) | 将校验过的静态 ZIP 发布到宝塔 11.7.0 目标上新建的、release 专属的目录。回执写明 staged only; traffic unchanged——不 promotion、不绑域名、不改生产根目录 |
| 站点设置更新 | 针对模拟器目标的可逆 site.settings.update 操作(draft → approve → execute) |
| 加密密钥库 | 口令加密的本地密钥库,密钥从不写入日志、回执或证据 |
| 连接器(可选) | Cloudflare DNS/WAF/CDN、Google Search Console、DNS/邮件姿态、OpenTelemetry——凭据缺失时只降级连接器本身 |
| 能力 | 状态 | 原因 |
|---|---|---|
| Node.js / PM2 部署 | 未开始 | 需要宝塔 Node 管理器的全新能力合约 |
| Docker / compose 部署 | 未开始 | 需要容器管理 API 的能力合约 |
| 域名绑定 / 建站 | 未开始 | 需要宝塔建站 API 的能力合约 |
| 生产 promotion / 流量切换 | 未开始 | 当前只有 staging,没有 promotion 能力 |
| 回滚 | 不声称 | 如果不支持回滚,回执会明确标注 irreversible |
| 公网监听 / 多租户 | 永不做 | 架构设计上禁止 |
┌──────────────────────────────────────────────────────────┐
│ 本地 daemon (loopback) │
│ Fastify, 端口 3210, 仅 127.0.0.1 │
│ │
│ ┌───────────┐ ┌───────────┐ ┌────────────────────┐ │
│ │ 策略引擎 │ │ 审批存储 │ │ 加密密钥库 │ │
│ │ Policy │ │ Approval │ │ Vault (口令加密) │ │
│ └─────┬─────┘ └─────┬─────┘ └─────────┬──────────┘ │
│ │ │ │ │
│ ┌─────┴───────────────┴───────────────────┴────────────┐ │
│ │ 发布编排器 (Release Orchestrator) │ │
│ │ fencing → claim → execute → receipt │ │
│ └────────────────────────┬─────────────────────────────┘ │
│ │ │
│ ┌────────────────────────┴─────────────────────────────┐ │
│ │ 宝塔适配器 / 客户端 (Baota Adapter) │ │
│ │ 签名 HTTP, 仅允许白名单内的 API 调用 │ │
│ └────────────────────────┬─────────────────────────────┘ │
└───────────────────────────┼──────────────────────────────┘
│ 私有局域网/VPN HTTPS
┌────────┴────────┐
│ 宝塔面板目标 │
│ Baota Target │
└─────────────────┘
三个入口,同一套 API:
控制台 UI (React) ──┐
CLI (commander) ──┼──→ daemon REST API (127.0.0.1:3210)
MCP (stdio) ──┘
- 操作员通过 UI/CLI/MCP 发起请求(如"发布这个静态网站")
- daemon 接收请求,首先做能力校验——确认目标服务器真的支持这个操作
- 策略引擎评估这个操作是否需要审批
- 操作员审批精确的操作摘要(改一个字节审批就失效)
- 发布编排器获取互斥锁,执行操作,每一步通过签名 HTTP 调用宝塔 API
- 回执以只读追加方式持久化,包含完整的证据链
每个变更操作(mutation)必须依次通过五道闸。任何一道闸失败,操作被拒绝,零副作用。
目标服务器必须主动证明它支持这个操作。不是"连得上就行",而是:
- 面板版本精确匹配(如静态 staging 要求精确
11.7.0) - 六个白名单 API 端点全部实测探测,确认存在且响应格式匹配
- 探测证据写入 HMAC-SHA-256 签名的本地 JSON 快照(文件权限
0600) - 绝不从连通性、用户标签或部分端点集推断支持
能力快照 (CapabilitySnapshot):
{
"targetId": "...",
"source": "linux",
"version": "11.7.0",
"accessMode": "read-write",
"capabilities": [
{ "capabilityId": "site.static.stage", "operationClass": "reversible", ... }
],
"signature": { "algorithm": "hmac-sha256", "digest": "..." }
}
策略引擎根据操作类型决定是否需要审批:
| 操作等级 | 是否需要审批 | 示例 |
|---|---|---|
read_only |
不需要 | 查询目标列表 |
reversible |
需要 | 静态 staging、站点设置更新 |
compensatable |
需要 + 补偿命令 | 数据库迁移(带补偿) |
snapshot_restorable |
需要 + 快照备份 | 数据库迁移(带快照) |
irreversible |
需要 + 类型化确认 | 不可逆迁移 |
你审批的是这个具体操作的这个具体摘要:
{
"operationId": "018f86d9-...",
"capability": "site.static.stage",
"payloadDigest": "sha256:abc123...",
"declaredImpact": { "digest": "sha256:abc123...", "summary": "stage static site" }
}审批有过期时间。操作摘要变了(比如换了文件),审批自动失效。
- 幂等键: 每次操作携带唯一的 idempotency key。重复提交同一个 key,返回原始回执,不重复执行
- 互斥锁: 同一目标的同一操作正在执行时,新请求被拒绝(
release_busy) - 崩溃恢复: 崩溃后标记
needs_recovery,绝不自动重试——超时/断连的结果分类为unknown_outcome,等待人工检查
操作完成后,写入只读追加的回执:
{
"outcome": "staged",
"wording": "staged only; traffic unchanged",
"releaseDigest": "sha256:...",
"artifactDigest": "sha256:...",
"capabilityGeneration": 1,
"capabilityDigest": "abc123...",
"endpointSequence": ["/data", "/files", "/files", "/files", "/files", "/files"],
"postconditionObservations": [...],
"redactedErrorCode": null
}回执表有数据库触发器,禁止 UPDATE 和 DELETE。
const OPERATION_CLASSES = [
"read_only", // 只读,无需回滚
"reversible", // 可逆,自动回滚
"compensatable", // 可补偿,执行补偿动作
"snapshot_restorable", // 快照可恢复,恢复快照
"irreversible", // 不可逆,只能手动恢复
] as const;如果回滚不可用,回执明确标注——绝不虚假承诺。
const RELEASE_CAPABILITIES = {
actorManage: "release.manage", // 操作员令牌能力
targetSimulatorExecute: "release.simulator.execute", // 模拟器发布
staticStage: "site.static.stage", // 静态 staging(已实现)
staticPromote: "site.static.promote", // 静态 promotion(保留,未实现)
} as const;- Node.js 22.x(引擎要求;24.x 可用但有警告)
- pnpm 10.x
- Docker(可选——仅浏览器 E2E 需要)
git clone https://github.com/cat9999aaa/baota-ai-siteops.git
cd baota-ai-siteops
pnpm install
pnpm verify预期输出:
✓ 124 个测试文件通过
✓ 528 个测试通过
✓ ESLint 通过
✓ Prettier 检查通过
✓ TypeScript 类型检查通过
这是最有代表性的端到端测试,30 秒内跑完整个发布闭环:
bash test/fixtures/e2e/run-static-stage.sh它自动完成:
- 启动模拟宝塔面板(loopback HTTP fixture)
- 把
test/fixtures/static-site/(HTML + CSS)构建成确定性 ZIP - 走完整流程:intake → 签名计划 → 审批 → staging
- 用 API / CLI / MCP 三个入口读回同一个 release
- 断言三个入口的摘要一致 +
staged only; traffic unchanged - 再跑一次故障场景(撤掉能力 → 三个入口一致报 blocked)
- trap 自动清理所有进程和临时目录
退出码 0 = 通过。证据输出在 .omo/evidence/baota-ai-ops-control-plane/task-32-e2e.log。
bash test/fixtures/e2e/run.sh在 Docker 容器中启动 daemon + 模拟器 + 前端控制台,运行 Playwright 浏览器测试。
Docker 方式:
docker build -t siteops .
docker run -d --name siteops \
-p 127.0.0.1:3210:3210 \
-v siteops-data:/var/lib/siteops \
-e SITEOPS_VAULT_PASSPHRASE="<你的口令>" \
siteops
# 验证
curl http://127.0.0.1:3210/healthz
# 预期: 200 OK本地方式:
SITEOPS_PORT=3210 \
SITEOPS_DATA_DIR=./data \
SITEOPS_VAULT_PASSPHRASE="<你的口令>" \
pnpm --filter @siteops/daemon start# 终端 1: daemon 已运行(上面)
# 终端 2:
cd apps/console
VITE_SITEOPS_API=http://127.0.0.1:3210 \
VITE_SITEOPS_TOKEN="<console-token>" \
pnpm dev
# 打开 http://127.0.0.1:5173baota-ai-siteops/
├── apps/ # 应用层
│ ├── daemon/ # 本地控制台 daemon (Fastify + SQLite)
│ │ ├── src/
│ │ │ ├── auth/ # 本地认证 + 令牌签发
│ │ │ ├── db/migrations/ # SQLite 迁移脚本 (001-009)
│ │ │ ├── http/ # REST API 路由
│ │ │ ├── jobs/ # 类型化任务执行器
│ │ │ ├── releases/ # 发布编排器 + 静态 staging 执行器
│ │ │ ├── targets/ # 目标纳管 + 能力协商 + 对账
│ │ │ ├── vault/ # 加密密钥库
│ │ │ └── runtime/ # 运行时组装
│ │ └── package.json
│ ├── console/ # 前端控制台 (React 19 + Vite)
│ │ ├── src/
│ │ │ ├── app.tsx # 主应用
│ │ │ ├── release-panel.tsx # 发布面板
│ │ │ ├── operation-panel.tsx# 操作面板
│ │ │ └── daemon-api.ts # daemon API 客户端
│ │ └── DESIGN.md # 设计系统文档
│ ├── cli/ # CLI 客户端 (commander)
│ │ └── src/
│ │ ├── program.ts # 命令注册
│ │ ├── release-commands.ts# 发布命令
│ │ └── daemon-api.ts # daemon API 客户端
│ ├── mcp/ # MCP 工具服务器 (stdio)
│ │ └── src/
│ │ ├── tools.catalog.ts # 工具清单定义
│ │ ├── tools.facade.ts # 工具门面
│ │ └── main.ts # stdio 入口
│ └── shared/ # 共享 E2E 基础设施
│
├── packages/ # 共享包
│ ├── contracts/ # 领域类型 + 能力词汇 + Zod schema
│ │ └── src/
│ │ ├── domain.ts # 实体 schema (Target, Site, Release...)
│ │ ├── security.ts # 操作等级 + 能力常量
│ │ ├── site-graph.ts # Site Graph 类型
│ │ └── static-stage.ts # 静态 staging 词汇 (版本/操作/原因)
│ ├── baota-adapter/ # 宝塔 HTTP 客户端
│ │ └── src/
│ │ ├── client.ts # 签名 HTTP 传输层
│ │ ├── capabilities.ts # 能力发现 + HMAC 签名快照
│ │ ├── static-stage-*.ts # 静态 staging 客户端 (6 个模块)
│ │ └── operations.ts # 模拟器操作适配器
│ ├── baota-testkit/ # 确定性宝塔 HTTP 模拟器
│ │ └── src/
│ │ ├── index.ts # 基线模拟器
│ │ └── static-stage-*.ts # 静态 staging fixture (12 个模块)
│ ├── release-manifest/ # 清单解析 + intake + ZIP 构建
│ │ └── src/
│ │ ├── schema.ts # baota.yaml Zod schema
│ │ ├── planner.ts # 签名发布计划
│ │ └── intake/
│ │ ├── static-archive*.ts # 确定性 ZIP 构建器
│ │ └── ...
│ ├── core/ # 策略引擎
│ ├── site-graph/ # Site Graph 类型
│ ├── ai-proposals/ # AI 提案层 (无执行能力)
│ └── connectors/ # 可选连接器
│ ├── cloudflare/ # Cloudflare DNS/WAF/CDN
│ ├── google/ # Google Search Console
│ ├── dns-mail/ # DNS/邮件姿态
│ └── sdk/ # 连接器 SDK
│
├── test/
│ ├── fixtures/
│ │ ├── e2e/ # E2E 测试脚本和 fixture
│ │ │ ├── run.sh # 浏览器 E2E (Docker)
│ │ │ ├── run-static-stage.sh# 静态 staging E2E (无 Docker)
│ │ │ ├── daemon.ts # 测试 daemon 启动器
│ │ │ ├── simulator.ts # 模拟宝塔面板
│ │ │ └── static-fixture.ts # 静态 staging fixture
│ │ ├── static-site/ # 静态测试项目 (HTML + CSS)
│ │ │ ├── index.html
│ │ │ ├── assets/app.css
│ │ │ └── baota.yaml # 示例清单
│ │ └── e2e.compose.yml # Docker Compose 配置
│ └── e2e/ # Playwright 浏览器测试
│
├── docs/ # 文档
│ ├── baota-static-staging.md # 静态 staging 合约 (机器可验证)
│ ├── baota-compatibility.md # 宝塔兼容性矩阵
│ └── operations/ # 运维文档
│ ├── self-hosting.md
│ └── static-staging.md
│
├── scripts/
│ ├── verify-plan.mjs # 计划结构校验器
│ └── verify-static-stage-contract.mjs # 静态 staging 合约校验器
│
├── Dockerfile # 生产镜像 (node:22-bookworm-slim)
├── package.json # monorepo 根配置
├── tsconfig.json # TypeScript strict 配置
├── eslint.config.mjs # ESLint 配置
└── pnpm-lock.yaml
export SITEOPS_API_URL=http://127.0.0.1:3210
export SITEOPS_TOKEN=<你的-cli-token>daemon 启动时会在运行时目录生成
auth.json,包含cli、console、mcp三个 channel 的 token。
pnpm --filter @siteops/cli dev -- targets list输出示例:
Target scope: fixture-a (11111111-1111-4111-8111-111111111111)
State: online (declared active)
Capabilities: inventory.read, site.list, release.simulator.execute, site.settings.update
Target scope: fixture-b (22222222-2222-4222-8222-222222222222)
State: online (declared active)
Capabilities: inventory.read, site.list, release.simulator.execute, site.settings.update
JSON 格式:
pnpm --filter @siteops/cli dev -- targets list --json{
"targets": [
{
"id": "11111111-1111-4111-8111-111111111111",
"name": "fixture-a",
"declaredState": "active",
"discoveredState": "online",
"capabilities": ["inventory.read", "site.list", "release.simulator.execute", "site.settings.update"]
}
]
}pnpm --filter @siteops/cli dev -- releases get --id <releaseId> --json返回不可变的静态 staging 回执,包含全部 13 个合约字段。
pnpm --filter @siteops/cli dev -- releases plan --manifest-yaml "$(cat baota.yaml)"pnpm --filter @siteops/cli dev -- releases prepare \
--plan-digest sha256:abc123... \
--staging-target 11111111-1111-4111-8111-111111111111 \
--intake-json '<intake 输出的 JSON>'pnpm --filter @siteops/cli dev -- jobs execute-site-setting \
--target 11111111-1111-4111-8111-111111111111 \
--site aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa \
--impact-digest sha256:aaa... \
--impact "更新站点备注为 'v2.0'" \
--idempotency-key deploy-20260726-001 \
--timeout-at 2026-07-26T23:59:59Z \
--approve输出:
Target scope: 11111111-1111-4111-8111-111111111111
Site scope: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa
Capability: site.settings.update
Operation class: reversible
Declared impact: 更新站点备注为 'v2.0'
Preflight: daemon policy evaluation
Approval: explicitly requested
Job: <jobId> (succeeded)
Receipt: <receiptId>
Outcome: succeeded
Receipt hash: sha256:...
export SITEOPS_API_URL=http://127.0.0.1:3210
export SITEOPS_TOKEN=<你的-mcp-token>| 工具名 | 类型 | 用途 |
|---|---|---|
list_targets |
只读 | 列出所有目标和能力快照 |
get_release |
只读 | 读取静态 staging 发布回执 |
get_job_receipt |
只读 | 读取不可变 job 回执 |
create_release_plan |
写入 | 创建签名发布计划 |
prepare_release |
写入 | 从计划 + intake 创建发布工作流 |
approve_release |
写入 | 绑定审批(stage / migration / promotion) |
stage_release |
变更⚡ | 执行 staging(幂等) |
promote_release |
变更⚡ | 执行 promotion(幂等,基线功能) |
propose_site_settings_update |
只读 | 起草站点设置操作(不执行) |
approve_site_settings_update |
写入 | 审批站点设置操作 |
execute_site_settings_update |
变更⚡ | 执行站点设置操作(幂等) |
列出目标:
pnpm --filter @siteops/mcp test:stdio -- \
--tool list_targets --input '{}'读取发布回执:
pnpm --filter @siteops/mcp test:stdio -- \
--tool get_release --input '{"releaseId":"018f86d9-1f4b-7a01-8d62-2df80a30e216"}'创建发布计划:
pnpm --filter @siteops/mcp test:stdio -- \
--tool create_release_plan \
--input '{"manifestYaml":"version: v1\nsource:\n type: artifact\n..."}'审批 + 执行 staging:
# 审批
pnpm --filter @siteops/mcp test:stdio -- \
--tool approve_release \
--input '{"releaseId":"<id>","kind":"stage","expiresAt":"2026-07-27T00:00:00Z","typedAcknowledgement":null}'
# 执行
pnpm --filter @siteops/mcp test:stdio -- \
--tool stage_release \
--input '{"releaseId":"<id>","stageApprovalId":"<approvalId>","migrationApprovalId":null}'站点设置更新(三步流程):
# 1. 起草(不执行任何操作)
pnpm --filter @siteops/mcp test:stdio -- \
--tool propose_site_settings_update \
--input '{"operationId":"<UUID>","targetId":"...","siteId":"...","impactDigest":"sha256:...","impactSummary":"改备注"}'
# 2. 审批
pnpm --filter @siteops/mcp test:stdio -- \
--tool approve_site_settings_update \
--input '{"operationId":"<UUID>","expiresAt":"2026-07-27T00:00:00Z"}'
# 3. 执行
pnpm --filter @siteops/mcp test:stdio -- \
--tool execute_site_settings_update \
--input '{"operationId":"<UUID>","approvalId":"<approvalId>","targetId":"...","siteId":"...","idempotencyKey":"key-1","timeoutAt":"2026-07-27T00:00:00Z"}'MCP 服务器从环境变量读取 SITEOPS_API_URL 和 SITEOPS_TOKEN。将你的 MCP 客户端指向 apps/mcp/src/main.ts(通过 tsx 运行)。
daemon 在回环端口(默认 3210)暴露 REST API。所有请求需要:
authorization: Bearer <token>
x-siteops-channel: cli|console|mcp
content-type: application/json
| 方法 | 路径 | 说明 |
|---|---|---|
POST |
/v1/targets |
纳管宝塔目标 |
GET |
/v1/targets |
列出目标和能力快照 |
GET |
/v1/targets/:id |
读取单个目标 |
POST |
/v1/targets/:id/reconcile |
对账目标状态和能力 |
纳管目标请求体(静态 staging):
{
"targetId": "33333333-3333-4333-8333-333333333333",
"name": "my-baota-server",
"endpoint": "https://192.168.1.100:8888",
"groupLabels": ["staging"],
"apiSecret": "<宝塔 API 密钥>",
"stagingRoot": "/var/lib/siteops/static-staging",
"siteId": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
"siteName": "my-static-site",
"observedPanelVersion": "11.7.0",
"runtime": "static",
"source": "linux",
"endpointEvidence": [
{ "action": "getData", "responseSchemaId": "baota.static-stage.getData.v1" },
{ "action": "CreateDir", "responseSchemaId": "baota.static-stage.CreateDir.v1" },
{ "action": "UploadFile", "responseSchemaId": "baota.static-stage.UploadFile.v1" },
{ "action": "mutil_unzip", "responseSchemaId": "baota.static-stage.mutil_unzip.v1" },
{ "action": "GetDir", "responseSchemaId": "baota.static-stage.GetDir.v1" },
{ "action": "test_path", "responseSchemaId": "baota.static-stage.test_path.v1" }
]
}| 方法 | 路径 | 说明 |
|---|---|---|
POST |
/v1/jobs |
起草类型化操作 |
POST |
/v1/jobs/:id/approve |
绑定审批 |
POST |
/v1/jobs/:id/execute |
执行(幂等键 + 超时) |
GET |
/v1/jobs/:id |
读取任务状态 |
GET |
/v1/jobs/:id/receipt |
读取不可变回执 |
| 方法 | 路径 | 说明 |
|---|---|---|
POST |
/v1/releases/intake |
接收源(静态目录 / git / 制品 / 镜像) |
POST |
/v1/releases/plans |
创建签名发布计划 |
POST |
/v1/releases/prepare |
准备发布工作流 |
POST |
/v1/releases/:id/approvals |
绑定审批 |
POST |
/v1/releases/:id/stage |
执行 staging(幂等) |
GET |
/v1/releases/:id |
读取发布回执 |
| 方法 | 路径 | 说明 |
|---|---|---|
GET |
/healthz |
存活探针 |
以下是从本地静态目录到宝塔服务器的完整 staging 流程(6 步):
curl -X POST http://127.0.0.1:3210/v1/releases/intake \
-H "authorization: Bearer $TOKEN" \
-H "x-siteops-channel: cli" \
-H "content-type: application/json" \
--data '{"kind":"static_directory","path":"/home/user/my-website"}'daemon 将:
- 用
O_NOFOLLOWfd 锁定遍历目录树(防 TOCTOU 符号链接攻击) - 检测硬链接(
nlink+dev:ino双重检测) - 构建确定性 ZIP(固定时间戳、排序条目、UTF-8 标志)
- 计算 SHA-256 摘要,生成不可变清单
- 压缩后再做一次完整 SHA-256 校验(防压缩窗口期篡改)
返回包含 artifactDigest 的 intake 结果。
准备 baota.yaml 清单:
version: v1
source:
type: artifact
uri: file:///tmp/site.zip
digest: sha256:<Step1 返回的 artifactDigest>
runtime:
kind: static
documentRoot: public
target:
targetId: 33333333-3333-4333-8333-333333333333
siteId: bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb
build:
kind: prebuilt
artifact: site.zip
environment: []
migration:
class: none
health:
- kind: http
path: /index.html
expectedStatus: 200
timeoutSeconds: 5
strategy:
kind: blue_green
recovery:
class: irreversible
action: manual_recovery_onlycurl -X POST http://127.0.0.1:3210/v1/releases/plans \
-H "authorization: Bearer $TOKEN" \
-H "x-siteops-channel: cli" \
-H "content-type: application/json" \
--data '{"manifestYaml":"<上面 baota.yaml 的内容>"}'返回包含 digest 的签名计划。
curl -X POST http://127.0.0.1:3210/v1/releases/prepare \
-H "authorization: Bearer $TOKEN" \
-H "x-siteops-channel: cli" \
-H "content-type: application/json" \
--data '{
"planDigest": "sha256:<Step2 的 digest>",
"stagingTargetId": "33333333-3333-4333-8333-333333333333",
"intake": <Step1 的 intake 输出>
}'返回 releaseId。
curl -X POST http://127.0.0.1:3210/v1/releases/<releaseId>/approvals \
-H "authorization: Bearer $TOKEN" \
-H "x-siteops-channel: cli" \
-H "content-type: application/json" \
--data '{
"kind": "stage",
"expiresAt": "2026-07-27T00:00:00Z",
"typedAcknowledgement": null
}'返回 approvalId。
curl -X POST http://127.0.0.1:3210/v1/releases/<releaseId>/stage \
-H "authorization: Bearer $TOKEN" \
-H "x-siteops-channel: cli" \
-H "content-type: application/json" \
--data '{
"stageApprovalId": "<Step4 的 approvalId>",
"migrationApprovalId": null
}'daemon 执行:
- fencing 校验:计划摘要 → 操作摘要 → 快照代次 → 快照签名 → 能力授予 → 制品摘要 → 审批绑定(全部匹配才继续,否则零副作用拒绝)
- 获取互斥锁(BEGIN IMMEDIATE 事务)
- 签名 HTTP 调用:
getData→CreateDir→UploadFile→mutil_unzip→GetDir→test_path - 后置条件验证:确认文件存在、摘要匹配
- 持久化回执:
staged only; traffic unchanged
# API
curl http://127.0.0.1:3210/v1/releases/<releaseId> \
-H "authorization: Bearer $TOKEN" -H "x-siteops-channel: cli"
# CLI
pnpm --filter @siteops/cli dev -- releases get --id <releaseId> --json
# MCP
pnpm --filter @siteops/mcp test:stdio -- \
--tool get_release --input '{"releaseId":"<releaseId>"}'三个入口返回完全相同的 release/receipt 摘要。
完整的操作员面向合约在 docs/baota-static-staging.md,包含机器可验证的 JSON 块。要点:
精确 11.7.0——不是 11.7.x,不是 11.8。官方文档只在此版本上验证过。
| 调用 | 编码 | 必需字段 |
|---|---|---|
POST /data action=getData table=sites type=-1 |
form | action, table, type |
POST /files action=CreateDir path |
form | action, path |
POST /files action=UploadFile path zunfile request_time request_token |
multipart | action, path, zunfile, request_time, request_token |
POST /files action=mutil_unzip sfile_list dfile coding=utf-8 type1=zip |
form | action, sfile_list, dfile, coding, type1 |
POST /files action=GetDir path |
form | action, path |
POST /files action=test_path path |
form | action, path |
不接受 shell 文本、未列出的端点、stagingRoot 之外的路径。
request_time = Unix 时间戳(秒)
request_token = MD5(request_time + MD5(api_sk))
每次请求都生成新的签名。不声称有 TTL 或重放保护。HTTPS + IP 白名单是 SiteOps 策略要求。
| 限制 | 值 |
|---|---|
| 归档类型 | 仅 ZIP |
| 压缩后大小 | ≤ 64 MiB |
| 解压后大小 | ≤ 256 MiB |
| 常规文件数 | ≤ 5,000 |
| 单个文件大小 | ≤ 32 MiB |
| 路径编码 | UTF-8 POSIX 相对路径 |
| 路径长度 | ≤ 240 字节 |
拒绝:绝对路径、..、反斜杠、NUL、重复规范化名称、重复大小写折叠名称、符号链接、硬链接、设备文件、FIFO、socket。
操作员配置一个绝对、专用的 stagingRoot。SiteOps 从 release UUID + 制品摘要派生一个子目录。调用者不能提供远程路径。
这是客户端侧的路径限制,不是宝塔侧的沙箱。不能防御被入侵的面板或 root 账户。
成功回执的措辞精确为 staged only; traffic unchanged。在所有 postcondition 验证完成后才发出。被拦截的 preflight 或 unknown_outcome 绝不收到这个成功措辞。
pnpm verify
# ESLint + Prettier + TypeScript + Vitest = 528 个测试pnpm --filter @siteops/contracts test # 领域类型 + 能力词汇 (14 个测试)
pnpm --filter @siteops/baota-adapter test # 客户端 + 能力协商 (50 个测试)
pnpm --filter @siteops/baota-testkit test # 模拟器 fixture (26 个测试)
pnpm --filter @siteops/release-manifest test # ZIP 构建 + intake (55 个测试)
pnpm --filter @siteops/daemon test # 执行器 + fencing + 恢复 (218 个测试)
pnpm --filter @siteops/cli test # CLI 命令
pnpm --filter @siteops/mcp test # MCP 工具# 静态 staging 切片(不需要 Docker)
bash test/fixtures/e2e/run-static-stage.sh
# 完整浏览器界面(需要 Docker)
bash test/fixtures/e2e/run.sh# 计划结构校验(32 个任务 + F1-F4)
node scripts/verify-plan.mjs .omo/plans/baota-ai-ops-control-plane.md
# 静态 staging 合约校验
node scripts/verify-static-stage-contract.mjs docs/baota-static-staging.md| 测试类别 | 覆盖内容 |
|---|---|
| TOCTOU 符号链接攻击 | fd 锁定遍历 + lstat/fstat 身份校验 |
| 压缩窗口期篡改 | ZIP 后完整 SHA-256 重扫 |
| 计划摘要绑换 | plan.digest 必须匹配 workflow + policy |
| 快照签名篡改 | HMAC 重验 + 签名摘要绑定 |
| 能力证据伪造 | 实测探测证据(不接受调用者声明) |
| 路径逃逸 | stagingRoot 客户端侧限制 |
| 审批过期/消费 | 审批有过期时间,消费后不可重用 |
| unknown_outcome 不重试 | 超时/崩溃后标记 needs_recovery,不自动重试 |
| 变量 | 默认值 | 说明 |
|---|---|---|
SITEOPS_BIND_HOST |
127.0.0.1 |
daemon 绑定地址(仅回环) |
SITEOPS_PORT |
3210 |
daemon 监听端口 |
SITEOPS_DATA_DIR |
/var/lib/siteops |
SQLite + 快照存储目录 |
SITEOPS_VAULT_PASSPHRASE |
必填 | 加密密钥库的口令 |
SITEOPS_API_URL |
http://127.0.0.1:3210 |
CLI/MCP 的 daemon URL |
SITEOPS_TOKEN |
必填 | CLI/MCP 的 Bearer token |
SITEOPS_TARGET_ID |
可选 | MCP expected-target scope |
daemon 启动时在运行时目录(SITEOPS_DATA_DIR 或 E2E 的 runtime 目录)生成 auth.json:
cat $SITEOPS_DATA_DIR/auth.json
# { "cli": "siteops_cli_...", "console": "siteops_console_...", "mcp": "siteops_mcp_..." }FROM node:22-bookworm-slim
# 完整配置见 Dockerfile
# 健康检查以无特权用户运行,无任何 capability# 构建
docker build -t siteops .
# 运行(仅回环)
docker run -d --name siteops \
-p 127.0.0.1:3210:3210 \
-v siteops-data:/var/lib/siteops \
-e SITEOPS_VAULT_PASSPHRASE="<口令>" \
siteops
# 健康检查
curl http://127.0.0.1:3210/healthz
# 停止和清理
docker stop siteops && docker rm siteops && docker volume rm siteops-data# 启动 daemon + 模拟器 + 前端(4 个容器)
docker compose -f test/fixtures/e2e.compose.yml up -d --wait
# 访问前端: http://127.0.0.1:4173
# daemon API: http://127.0.0.1:4123
# 清理
docker compose -f test/fixtures/e2e.compose.yml down --volumesConventional Commits(英文):
feat(scope): 简短描述
fix(scope): 简短描述
docs(scope): 简短描述
test(scope): 简短描述
refactor(scope): 简短描述
chore(scope): 简短描述
每个 commit 包含其测试,独立通过其验证命令。
每个 TypeScript 源文件保持在 250 纯代码行(非空、非注释)以内。接近上限的文件按职责拆分。
- TypeScript strict 模式(
noUncheckedIndexedAccess,exactOptionalPropertyTypes,verbatimModuleSyntax) - 禁止:
any,as unknown,@ts-ignore,@ts-expect-error, 非空断言!, 空 catch - 信任边界用 Zod 解析,内部用 plain type
- 判别联合(discriminated union)用
switch+assertNever穷尽匹配
完整实现计划在 .omo/plans/baota-ai-ops-control-plane.md(32 个任务 + F1-F4 最终验证波)。所有 32 个任务已完成并通过独立对抗性验证。
- 任务 1-25:基线控制台(目标、密钥库、策略、任务、发布、连接器、打包)
- 任务 26-32:有界宝塔 11.7.0 静态站点 staging 切片
- F1-F4:计划合规、安全审计、界面 QA、范围保真
| 能力 | 需要的工作 |
|---|---|
| Node.js 运行时部署 | 宝塔 Node 管理器 API 研究 → 能力合约 → fencing → 回执 |
| 域名/站点管理 | 宝塔建站 API → 域名绑定 → 证书管理 → 安全合约 |
| 生产 promotion | 原子文档根切换 → 流量割接 → 回滚语义 |
| 数据库迁移 | 类型化迁移命令 → 备份/恢复 |
| 更多宝塔版本 | 扩展兼容性矩阵(目前仅 11.7.0) |
目前不能。 静态 staging 切片只支持纯静态文件(HTML/CSS/JS)。Node.js 部署需要宝塔 Node 管理器的全新能力合约。这是未来波次的工作。
目前不能。 当前切片的回执写明 staged only; traffic unchanged。域名绑定和流量切换需要 promotion 能力,还未实现。
精确 11.7.0(不是 11.7.x)。官方文档只在此版本上验证过。其他版本的 API 行为没有证据支撑,系统会拒绝。
不需要。 daemon 通过私有局域网/VPN HTTPS 连接宝塔面板。只要网络可达且 IP 在宝塔 API 白名单里即可。
不会。 超时/断连/畸形响应被分类为 unknown_outcome,标记 needs_recovery,等待人工检查。绝不自动重试——宁可报"结果未知"也不冒险重复执行。
不能。 AI 只能总结证据、诊断问题、起草类型化方案。执行任务、创建审批、访问密钥库——这些 AI 一律不能做。
安全模型的核心是五道闸管道。每次变更都必须通过能力校验、策略评估、审批绑定、幂等+锁、不可变回执。系统不执行任意 shell,不抓取面板页面当 API,不存放明文密钥,不虚假声称回滚能力。
Private project.