Skip to content

执行器设计与沙箱评估

状态:🔶 设计先行(T607,2026-08-08)——契约与威胁建模落盘,供 T603 实现 ContainerExecutor;独立沙箱容器实现留 M7。 背景决策见 todo-archive.md §M6 规划决策(Q1 执行深度 A/B 双模式、Q4 沙箱=A、Q5 Action 触发=B)。 安全评估(2026-08-14):评估结论、治理决议与不可简化的安全基线见 沙箱与恶意依赖防护治理;本文档 §2.2 的 M6 缓解——USER 降权已修复(C38,2026-08-14)、外联日志已实现(C40/T805,2026-08-14),登记 backlog C38/C40A 模式 push + PR 闭环(2026-08-20,C53 实施):A 模式 fix / fix-and-pr 完成后新增推送修复分支到远程 + 创建 PR 两条链路,结束了 M6 阶段"修复结果仅在本地临时目录"问题;新的状态机 dispatched 语义(PR 失败但分支已推)、runUrl 兜底为 branch URL、workDir 保留 24h 供诊断 由本文档 §8 记录。


1. 定位

平台 = 控制面(触发器/调度器 + 结果展示);修复执行 = 数据面(真实 clone、改文件、跑质量门)。 数据面通过 Executor 抽象与平台解耦,执行后端可插拔:

执行后端隔离级别工具链来源M6 状态M7 状态
平台容器内子进程(ContainerExecutor进程级(容器即沙箱)平台镜像内置 git/node/pnpm✅ 实现(T603)保留
独立沙箱容器(SandboxExecutor容器级(每任务/每仓库容器)同一镜像或精简镜像🔶 设计(本文档)实现(backlog C26)
GitHub Action(ActionTriggerExecutorGitHub 托管环境目标仓库自带(action.yml 引用)✅ 触发实现(T607)+ 结果回填(C25)保留
本地临时目录(LocalExecutor无隔离(开发调试用)宿主工具链—(仅开发)

2. 威胁建模:恶意依赖升级

2.1 风险面

dependfix 的核心动作是升级第三方依赖,本质是"拉取并执行不可信代码":

攻击面场景影响
install scripts / postinstall恶意包在 pnpm install 时执行 preinstall/install/postinstall 脚本(供应链投毒主流路径,如 event-stream、ua-parser-js 案例)容器内任意代码执行
构建链投毒依赖被攻破后,构建脚本(prepare/build 钩子)篡改产物产物不可信,污染后续依赖方
凭据泄露恶意脚本读取进程环境(GITHUB_TOKENENCRYPTION_KEYAUTH_SECRET 等)或工作目录中的凭据文件,外传凭据泄露 → 仓库/平台被接管
网络外联恶意脚本访问外部网络(回传数据、下载第二阶段 payload)数据泄露、横向扩散
文件系统破坏恶意脚本删除/篡改工作目录或平台文件平台数据损坏
资源耗尽无限循环、磁盘写满(依赖下载/日志膨胀)DoS,平台不可用
提权逃逸容器内进程以 root 运行且宿主机无防护时尝试逃逸宿主接管

2.2 风险定级与缓解

风险等级M6 缓解(ContainerExecutor 必做)M7 增强(SandboxExecutor)
install scripts 代码执行(本工具必然触发)① 非 root 用户运行(镜像 USER 降权)② 独立临时工作目录 ③ 超时/资源上限 ④ 执行结果白名单回传独立容器 + 网络出站限制(默认 deny,白名单 registry 域名)
凭据泄露(执行环境持有平台密钥)① 凭据仅解密到执行进程内存,绝不落盘 ② 环境变量最小集注入(只传本仓库所需 token)③ 平台密钥(ENCRYPTION_KEY/AUTH_SECRET)不传入执行子进程每任务独立密钥、无宿主 env 继承
网络外联记录执行期外联日志(备查);M6 容器内默认放行(registry 需要)出站白名单(npm/pnpm registry + GitHub API)
文件系统破坏工作目录限定在平台数据卷下的 runs/{runId}/ 临时目录,执行后清理只读根文件系统 + tmpfs 工作目录
资源耗尽子进程超时(默认 30 分钟可配)+ 磁盘配额随数据卷cgroup 内存/CPU 限制
提权逃逸低(单租户自托管)非 root + 不挂载 docker.sock + 容器只读部分独立容器 + seccomp/apparmor 加固

M6 结论:平台容器即沙箱(进程级隔离)可接受——单租户自托管场景下威胁模型以"恶意依赖脚本"为主,通过非 root + 临时目录 + 凭据最小化 + 超时四项缓解即可达安全基线;更高隔离(网络出站限制、每任务容器)登记 backlog C26,M7 随 BullMQ worker 模型实现。

⚠️ 2026-08-14 评估修正:M6 四项缓解中的"非 root 运行(镜像 USER 降权)"已修复(C38,2026-08-14)——entrypoint 降权方案(dependfix 用户 uid 100 + chown 数据卷 + su-exec),本地实证通过;"记录执行期外联日志"未实现——登记 C40。C26 独立沙箱提级为 M7 前置(并发共享容器交叉污染,见 治理文档 §3 路径 D)。实证补充:容器内 git/pnpm 工具链从未安装(本文档声称"平台镜像内置 git/node/pnpm"与实际不符,仅 node 存在)——已修复(C45/T801,2026-08-14):git + pnpm 11.18.0 + workspace node_modules 打包,容器内 fix 全链路实证通过。


3. Executor 接口契约

执行后端可插拔的统一接口(T603 ContainerExecutor 与 T607 ActionTriggerExecutor 均实现此契约):

typescript
// apps/platform/server/services/executor/types.ts(T603 落地)

/** 执行器类型标识 */
export type ExecutorKind = 'container' | 'sandbox' | 'github-action' | 'local'

/** 执行上下文:平台侧组装,不携带任何平台密钥(凭据单独解密传递) */
export interface ScanExecutorContext {
    /** 平台侧 runId(对应 ScanRun.id) */
    runId: string
    /** 目标仓库(Repository 实体切片) */
    repository: {
        owner: string
        name: string
        defaultBranch: string
        packageManager?: 'pnpm' | 'npm' | 'yarn'
        /** ActionTriggerExecutor 使用:目标 workflow 文件名(仓库内路径,如 `.github/workflows/security-auto-fix.yml`) */
        actionWorkflowFile?: string
    }
    /** 复用 cli 的 RuntimeConfig(mode/severityThreshold/repositories 等) */
    config: RuntimeConfig
    /** 解密后的凭据(仅本次执行内存中持有,用后即弃) */
    credential?: { token: string }
    /** 工作目录:容器执行 = 数据卷下 runs/{runId}/;action 触发 = 不需要 */
    workDir: string
}

/** 执行结果:结构化回传(T603 落库 ScanRun/ScanResult 的数据源) */
export interface ScanExecutorResult {
    exitCode: number
    /** cli 的 RunResult(repositories/alerts/actions/errors/summary) */
    result?: RunResult
    /** 执行级失败(非业务失败:超时/环境缺失/触发失败等) */
    error?: { code: string; message: string }
    startedAt: string
    finishedAt: string
}

/** 执行后端统一契约:平台 scan-orchestrator 只依赖此接口,不感知具体实现 */
export interface ScanExecutor {
    readonly kind: ExecutorKind
    /** 执行前可用性探测(如容器内工具链存在性 / action 仓库权限校验) */
    isAvailable(): Promise<boolean>
    execute(ctx: ScanExecutorContext): Promise<ScanExecutorResult>
}

契约要点

  1. 凭据最小化credential 由平台 credential service 在调用 execute 前解密,仅注入本次执行;ScanExecutorContext 不携带平台级密钥(ENCRYPTION_KEY/AUTH_SECRET 永不进入执行进程)。凭据来源单一:复用 RuntimeConfig 时,其 githubToken/alertsToken 字段必须由 credential service 解密结果填充,禁止从平台存储二次读取。
  2. 结果结构对齐 cliresult 直接复用 RunResult@dependfix/core 类型),T603 落库无需二次映射。
  3. 执行失败与业务失败分离exitCode + result 表示业务结果(扫描/修复产出);error 表示执行基础设施失败(环境缺失、超时、触发被拒),T603 据此置 ScanRun.status = 'failed'
  4. 可插拔路由:平台按 Repository 配置(executorKind 字段,M6 默认 container;配置了 actionWorkflowFile 且显式选择时路由 github-action)选择执行器。

4. ActionTriggerExecutor(T607 实现)

4.1 触发流程

平台(B 模式)→ POST /api/repos/{id}/scan { executor: 'github-action' }
  → credential 解密(需 actions: write 权限)
  → POST /repos/{owner}/{name}/actions/workflows/{workflowFile}/dispatches
      { ref: defaultBranch, inputs: { mode, severity-threshold, ... } }
  → 返回 { ok: true, dispatchId, runUrl }(GitHub 不返回 run id,需轮询 run 列表定位)
  → ScanRun.status = 'dispatched'(触发成功但结果未就绪;结果回填见 C25:轮询 run 完成 → 下载 artifact 解析落库)

4.2 权限要求

要求
GitHub 凭据actions: write 权限(classic PAT 勾选 workflow scope;fine-grained PAT 配 Actions: write
目标 workflow仓库内已存在(actionWorkflowFile 声明),且 on: workflow_dispatch 已声明
触发输入与 action.yml inputs 对齐:mode(默认 fix-and-pr)、severity-threshold(默认 high)、reposmax-alerts-per-repository 等,最大 10 个字符串输入(GitHub 限制)

4.3 触发结果判定

GitHub dispatches API 成功返回 204 即触发受理,但不返回 run id;实现需在触发后轮询 /repos/{owner}/{name}/actions/runs?event=workflow_dispatch(带短退避,如 5s×3)定位本次 run 并返回 runUrl(供 UI 展示跳转)。轮询失败不视为扫描失败(仅 runUrl 缺失),ScanRun 保持 dispatched 状态。

4.4 错误处理

场景行为
凭据无 actions: write 权限触发返回 403 → error.code = 'trigger_forbidden',ScanRun → failed
workflow 文件不存在404 → error.code = 'workflow_not_found',提示在仓库配置中修正 actionWorkflowFile
目标仓库未配置该 workflow预检(触发前 GET workflow 确认存在),缺失即拒绝,避免无谓 404

5. B 模式(GitHub Action 降级)接入评估

决策背景:M6 规划 Q5=B——平台对已配置 action 的仓库触发 workflow_dispatch,作为服务器配置较低时的降级路径。

5.1 使用方式

  1. 目标仓库添加依赖:在其 .github/workflows/security-auto-fix.ymluses: dependfix/dependfix@v1(复用根 action.yml,M2 已落地),on: workflow_dispatch 声明。
  2. 平台仓库配置:actionWorkflowFile 填入该文件路径,凭据选择具备 actions: write 的 Credential。
  3. 用户触发:平台 Web UI 点击扫描(执行后端选择 GitHub Action)→ 平台触发 dispatch → 用户跳转目标仓库 Actions 页面查看执行

5.2 体验评估

维度评估
优点平台零工具链依赖(服务器配置低也无需内置 git/node/pnpm);执行环境由 GitHub 托管、隔离性好(恶意依赖脚本跑在 GitHub runner 上);无需维护执行镜像
缺点结果回填缺失(已实现:ActionResultFetcher 轮询 run 完成 + 下载 dependfix-report-{runId} artifact 解析回填);执行延迟高(runner 排队 + checkout + install,通常分钟级);依赖目标仓库已配置 workflow(接入前置成本)
触发可靠性workflow_dispatch 无排队保障,rate limit 5000/h 内足够;对私有仓库需 token 有该仓库访问权 + actions: write

5.3 成本评估

A 模式(平台容器)B 模式(GitHub Action)
计算成本平台服务器(已部署则边际成本≈0)目标仓库 Actions minutes(私有仓库计费;公共仓库免费)
工具链维护平台镜像内置(Dockerfile 已含 git/pnpm)无(GitHub runner 自带)
结果获取直接回填(T603)自动拉取(C25 已实现:artifact 下载)或人工查看

结论:B 模式作为降级路径保留——适合"平台服务器资源受限 + 目标仓库已配置 action"场景;M6 实现触发(ActionTriggerExecutor)与结果回填(ActionResultFetcher,C25 已实现);默认路径仍是 A 模式 ContainerExecutor(T603)。

同步等待边界:M6 同步执行模型下,B 模式结果回填的 fetcher 轮询最长 30 分钟(runTimeoutMs 可配)——HTTP 请求会同步挂起至该上限。反向代理默认超时(如 Nginx 60s)可能先断开,用户侧表现为"扫描无响应";此时已有降级路径(result_fetch_faileddispatched + runUrl 提示跳转查看,action 实际已在目标仓库运行)。M7 T702 队列化后 B 模式改为异步回填,消除同步阻塞。


6. 相关文档


7. Sandbox 执行器设计

状态:🔶 设计落盘(M10,2026-08-19 决策会议 / 2026-08-20 收口归档)——T1001-T1004 实施规划已在 todo-archive-phases-m10-c53-c59c61.md §M10 落地;本文档定义接口契约与部署形态,详细任务拆解见 todo-archive 实施规划。 决策依据:Docker rootless mode + 应用层白名单代理 + cgroup v2 双层;Executor 抽象不与 rootless 强绑定;自托管 docker-compose 优先;与 ContainerExecutor 并存保留单机场景。一手调研依据见 todo-archive-phases-m10-c53-c59c61.md §M10 决策依据

7.1 抽象边界(不强绑定 Docker rootless)

SandboxExecutor 通过 §3 接口契约实现,不与具体 OCI runtime 强绑定——Runtime 形态作为配置项(SANDBOX_RUNTIME / Repository 字段)注入,避免今后切 Sysbox(--runtime=sysbox-runc)、Kata(--runtime=kata-runtime)等需要重写业务代码:

text
Repository.executorKind = 'sandbox'              → SandboxExecutor 路由

      scan-orchestrator 解析 → SandboxExecutor.execute(ctx)

      SandboxRuntimeAdapter (interface, DI)
                  ├─ runtime=DockerRootless  → docker run --user=100:100 --memory=... --cpus=... sandbox-image:tag
                  ├─ runtime=Sysbox          → docker run --runtime=sysbox-runc ...
                  └─ runtime=Kata            → docker run --runtime=kata-runtime ...(backlog 登记,非 M10 目标)

接口预览(T1001 实施时落定):

typescript
// apps/platform/server/services/executor/sandbox-runtime-adapter.ts(新建)
export interface SandboxRuntimeAdapter {
    /** 启动 sandbox 容器并返回 wait/stop 接口 */
    spawn(opts: SandboxSpawnOpts): Promise<SandboxHandle>
    /** 探测当前 runtime 可用性(启动期自检用) */
    isAvailable(): Promise<boolean>
}

export interface SandboxSpawnOpts {
    image: string                        // 复用平台镜像 tags(T1001-1)
    user: string                         // '100:100'(T1001)
    cgroupLimits?: { memoryMb: number; cpu: number }   // 透传 Repository.sandboxLimits
    workDirBindMount: string             // /tmp/runs/{runId} → /workspace
    networkEgressPolicy: 'allowlist'     // 白名单拦截代理对接(T1002)
    envSubset: NodeJS.ProcessEnv          // 仅解密后的 exec token(T1002 域名校验前置)
}

export interface SandboxHandle {
    containerId: string
    stop(signal?: NodeJS.Signals): Promise<void>
    waitForExit(): Promise<{ exitCode: number; stdout: string; stderr: string }>
}

RuntimeAdapter 不变量:业务侧只依赖 SandboxRuntimeAdapter 接口,与 docker run / podman run / ctr run(containerd CLI)解耦。当前默认实现为 DockerRootlessAdapter,对应 --user=100:100 --memory=2g --cpus=1.0。切 Sysbox 路径仅替换 adapter 实现。

7.2 镜像策略

复用 apps/platform/Dockerfile runtime 阶段(T801 已落地 git + pnpm 11.18.0 工具链;C45 修复),不维护双镜像。Sandbox 容器启动命令与平台容器内执行 DependfixApp.run() 等价,差异仅在 UID/cgroup/网络隔离边界。镜像 tag 通过 apps/platform/docker tag 复用(与 C30 Publish Docker CI 链路解耦——CI 发布的镜像不可被 sandbox 直接拉,本场景使用平台内置镜像)。

7.3 部署形态

自托管 docker-compose(M10 目标,唯一交付形态):

  • apps/platform/docker-compose.yml 增加 sandbox-daemon 服务(rootless Docker daemon 容器,挂载 data/runs 共享卷,映射 unix socket 给 platform 容器)
  • apps/platform/Dockerfile 不变(T801/C38 已落地,非 root + 工具链)
  • platform 容器通过 DOCKER_HOST=unix:///var/run/docker.sock(容器内 socket 路径,与 rootless daemon 共享)

反模式登记(绝对不可用):

K8s + Helm Chart:仅在本节末子目登记为 backlog(不属 M10 范围)——见 §7.5。

7.4 与 ContainerExecutor 并存

todo-archive-phases-m10-c53-c59c61.md §M10 D6 决策(Q6 并存):两 Executor 同时注册,默认 container(向后兼容单机场景不破坏):

触发条件走向备注
Repository.executorKind = undefinedContainerExecutorM6 默认,单机/无 rootless 场景仍可用
Repository.executorKind = 'container'ContainerExecutor显式声明,与 M6 一致
Repository.executorKind = 'sandbox' + SandboxRuntimeAdapter 可用SandboxExecutorM10 目标
Repository.executorKind = 'sandbox' + adapter 启动时不可用(isAvailable() 返回 false)ContainerExecutor 降级执行 → degraded 状态A 场景(配置层降级):业务结果完整,UI info 提示「未启用 rootless,已自动使用平台容器」
Repository.executorKind = 'sandbox' + adapter 启动可用execute() 抛 errno(ENOENT/ENOTCONN/EACCES/ECONNREFUSED)不静默降级 → error.code = 'sandbox_unavailable'failed 状态B 场景(环境中途变化):业务未完成,UI warn 告警「沙箱运行时不可用,环境配置可能已变化」
Repository.executorKind = 'github-action'ActionTriggerExecutorM6 已有

CLI 启动时(@dependfix/cli entrypoint)探测 SandboxRuntimeAdapter 可用性;不可用时输出 [sandbox] warn 提示管理员启动 rootless daemon,但不阻断运行(旧路径仍可用)。

A/B 场景语义差异详见下文 §7.8 降级状态机契约——核心是「启动时降级(配置偏离)→ degraded + info」与「运行时降级(环境异常)→ failed + warn」的边界区分。

7.5 K8s + Helm 部署预留(非 M10 范围)

状态:🔶 backlogging,待真实 K8s 部署需求出现时评估(用户 2026-08-19 决策:"仅做规划,等真有需求时再实现")。

触发条件

  1. 真实多租户/企业部署需要 K8s 编排
  2. 至少 1 个外部用户提出 K8s 部署请求
  3. dependfix 1.0.0 正式发布前后纳入发行矩阵

预留接口SandboxRuntimeAdapter 抽象兼容 K8s(通过 Kubernetes RuntimeClass + Pod sandbox securityContext 实现,无需 runc/dockerd 依赖)。Repository.executorKind='sandbox' 在 K8s 场景下走 KubernetesRuntimeAdapter(未来 TBD),接口签名保持 §7.1 不变。

Helm Chart 留 backlog:需 values.yaml(sandbox resource limits 默认 / RBAC 不挂 docker.sock / PodSecurityContext 非 root)/ templates/deployment.yaml(rootless daemon sidecar)/ templates/servicemonitor.yamlsandbox-security-governance.md §7 验收持续治理)。

7.6 验收对照(链接权威条款)

实施时按 sandbox-security-governance.md §4 安全基线安全规范 §5.3 逐项核验:

  • 非 root 执行 → SandboxRuntimeAdapter 注入 --user=100:100(C38 路径延续)
  • 超时兜底 → T802 单命令超时(已落地)+ SandboxHandle.waitForExit 透传外层 30 分钟超时
  • 资源与网络 → T1002 白名单拦截代理 + T1003 cgroup v2
  • 工作目录隔离runs/{runId}/ 临时目录 + bind-mount + 执行后 cleanup
  • 新执行后端威胁建模评审sandbox-security-governance.md §4.4 已要求;T1001 提交 Review Gate 时同节点触发 Code Auditor 复核
  • 规范单点声明 → 不在本节重复 security.md §5.3 条款,仅挂引用

7.7 设计反例(绝对不可行)

反例风险登记
SandboxRuntimeAdapter 内部硬编码 docker run(而非参数化 runtime)强绑定 docker;切 Sysbox/Kata 需重写T1001 Review Gate 必查
SandboxExecutor 工作目录 bind-mount 宿主路径(非 run-scoped tmp)跨 run 数据残留T1001 Review Gate 必查
Sandbox 镜像走 caomeiyouren/dependfix:latest(CI 发布镜像)sandbox 与平台二进制版本漂移风险T1001 镜像策略段禁止
默认 executorKind='sandbox'单机场景破坏T1001 路由默认 'container'

7.8 降级状态机契约(degraded vs failed)

状态:🔶 M11 阶段登记(2026-08-20),T1005-C 实施中。背景:sandbox 路由在「启动时不可用」与「运行时不可用」两种场景下的语义边界——前者是配置偏离(业务完整),后者是真实异常(业务未完成)。统一归为 failed 会丢失降级路径的成功信息,统一归为 degraded 会掩盖运行时异常,故引入独立状态分流。

7.8.1 两种降级场景的语义边界

场景触发条件业务结果状态UI 严重度
A. 启动时降级(配置偏离)executorKind === 'sandbox' + sandbox.isAvailable() === false✅ ContainerExecutor 跑成功,summaryJson / runUrl 完整degradedinfo(蓝色提示)
B. 运行时降级(环境异常)executorKind === 'sandbox' + sandbox.isAvailable() === true + sandbox.execute() 内部抛 errno❌ 不降级避免掩盖真实错误,result 为 undefinedfailederror.code = 'sandbox_unavailable'warn(黄色告警)

核心区别

  • failed = 「你让我做的事,没做成」(业务结果为空)
  • degraded = 「你让我做的事,做成了,但走的路不是你想要的那条」(业务结果完整,仅路径偏离)

为什么 B 场景不静默降级回 ContainerExecutor? ——避免掩盖「环境容器中途变化」的真实异常。降级会让管理员错过 docker daemon / cgroup / user namespace 状态变化的告警信号,违背 sandbox 治理的「环境异常必须可观测」原则。

7.8.2 状态机决策函数契约

scan-run-state.tsresolveScanRunState 在原签名基础上新增 degradedReason? 参数:

typescript
export const resolveScanRunState = (
    executorKind: 'container' | 'github-action' | 'sandbox',
    error: { code: string, message: string } | undefined,
    result: RunResult | undefined,
    /** A 场景降级信号(仅 sandbox 路由启动时不可用触发) */
    degradedReason?: { code: string, message: string },
): ScanRunStateDecision

ScanRunStateDecision.status union 新增 'degraded'

typescript
export interface ScanRunStateDecision {
    status: 'completed' | 'failed' | 'dispatched' | 'degraded'
    errorJson?: { code: string, message: string } | null
}

A 模式块新增分支(必须在 pr_creation_failed 分支之后、其他错误分支之前):

typescript
// A 模式块:启动时降级 → degraded(业务完整 + 路径偏离)
if (result && degradedReason) {
    return { status: 'degraded', errorJson: degradedReason }
}
// 其他错误(含 sandbox_unavailable 运行时失败)→ failed
if (error && !result) {
    return { status: 'failed' }
}

7.8.3 orchestrator 降级信号传递

scan-orchestrator.service.ts 在 sandbox 路由块维护 degradedReason 内部变量:

typescript
if (executorKind === 'sandbox') {
    let degradedReason: { code: string, message: string } | undefined
    const sandbox = new SandboxExecutor({ ... })
    if (await sandbox.isAvailable()) {
        // 启动可用 → 走 sandbox(可能 B 场景:execute 抛 errno → catch → sandbox_unavailable)
        const execResult = await sandbox.execute(ctx)
        result = execResult.result
        error = execResult.error
    } else {
        // A 场景:启动时降级 → 记录降级原因 + 走 ContainerExecutor
        degradedReason = {
            code: 'sandbox_unavailable',
            message: '沙箱执行器启动时不可用(无 rootless daemon / user namespace 受限),已自动降级到平台容器',
        }
        const executor = new ContainerExecutor({ ... })
        const execResult = await executor.execute(ctx)
        result = execResult.result
        error = execResult.error
        runUrl = execResult.runUrl ?? null
    }
    // 状态机决策透传 degradedReason
    const decision = resolveScanRunState(executorKind, error, result, degradedReason)
    // 新增 degraded 分支写 errorJson + summaryJson + runUrl
}

7.8.4 ScanRun 状态机扩展

ScanRunStatus enum 新增 'degraded' 终态(与 completed / failed / dispatched 并列):

状态业务结果落库字段UI 严重度聚合计入
completed完整summaryJson + runUrlsuccesscompletedCount + alertsTotal + fixedCount
dispatched主要副作用已落库runUrl + errorJsoninfofinishedCount
degraded完整(路径偏离)summaryJson + runUrl + errorJson(sandbox_unavailable 降级原因)info(蓝色)degradedCount(独立计)+ alertsTotal + fixedCount
failederrorJsondanger(红)failedCount

关键设计决策

  • degraded 的 ScanResult 参与 severityCounts 统计(业务结果完整,与 completed 等价口径)
  • batch-aggregate.ts 新增 degradedCount 字段,与 failedCount 独立计数
  • TERMINAL_STATUSES 加入 'degraded'(聚合判定「终态」)

7.8.5 已知 backlog

  • 环境容器变化告警(已登记 backlog,未实施):B 场景(运行时失败)当前仅 UI warn 提示 + 平台日志 stderr;未来若引入 audit log 设计 + 通知渠道(邮件 / Slack / Webhook),可推送「沙箱执行器运行时不可用」告警给管理员。当前阶段仅 stderr + UI 提示,登记 backlog 详见 backlog.md §C-ENV-CHANGE-ALERT

8. A 模式 push + PR 推送机制

状态:✅ 设计落盘(C53,2026-08-20 实施)——A 模式(ContainerExecutor)fix / fix-and-pr 完成后新增推送修复分支到远程 + 创建 PR 两条链路,落盘 commit 83ec736 / 46b7c15 / 3ed8303

2026-09-04 修订(M25 事故修复):C53 原始设计假定 app.run() 内部只做本地修复 + commit,push / PR 由平台承担。但实际上引擎的 fix-and-pr 模式自带 createFixBranch → pushBranch → createPullRequest 完整链路,且 pushBranch 走裸 git push 不带凭据——容器内 git -c http.extraheader=... clone 也不会把 extraheader 写入 .git/config(实测 git -c 是 git 级 flag,非 clone 子命令),导致 push 必然缺凭据失败。

修复后:引擎降级为 mode: 'fix' + commit: true(仅本地修复+commit),push + PR 全部走平台 [platform-delivery] 模块,保留引擎的 dedup / supersede 决策。详细根因与方案见 archive/todo-archive-phases-m25.md §M25(M25 已 2026-09-08 归档)。

8.1 流程变更(C53 → M25 修复后差异)

C53 阶段(修复前)

ContainerExecutor.execute()

  fix-and-pr 模式 + app.run() 成功(exitCode === 0)

  [引擎内部] createFixBranch + pushBranch + createPullRequest  ← 推 push 必然失败(容器无凭据)

  平台 fallback: pushFixBranch(branch, workDir, token)  ← exitCode≠0 整段被跳过

  status = completed(误报)

M25 阶段(修复后)

ContainerExecutor.execute()

  clone + preRunHead = rev-parse HEAD

  app.run() in mode='fix' + commit=true        ← 引擎仅做本地修复+commit

  hasNewCommit = (postRunHead !== preRunHead)  ← 严格判定(no-op 扫描不产生空 push)

  fix 模式:pushFixBranch(current branch, workDir, token)  → runUrl = branch URL

  fix-and-pr 模式:
    ├─ planFixAndPrDelivery(octokit, ...)  ← 复用引擎 computeFixFingerprint + findDependfixOpenPR + computeFixAndPrPlan
    │   ├─ skip: 同指纹 PR 已存在 → runUrl = existing PR URL(幂等交付)
    │   └─ create: 推进 push + create + close supersede

    createFixBranch(plan.branchName, workDir)

    pushFixBranchWithCredential(plan.branchName, workDir, token)  ← http.extraheader 注入 token

    createPullRequest(...) → runUrl = PR.htmlUrl

    closeSupersededPRs(...)  ← best-effort;失败仅 warn

    失败兜底(结构化 PlatformDeliveryError):
      ├─ push_failed: failed + workDir 立即清理
      └─ pr_creation_failed: dispatched + workDir 保留 24h 供诊断

8.2 状态机扩展(与 B 模式 dispatched 语义对齐 + M25 引擎交付类识别)

C53 引入 A 模式 dispatched 三分支(scan-run-state.ts),M25 新增引擎交付类 category 识别:

场景error.coderesult.errors categorystatusrunUrlworkDir 处理
修复成功 + push 成功 + PR 成功completedPR URLfinally 立即清理
修复成功 + push 成功 + PR 失败pr_creation_faileddispatchedbranch URL(兜底)moveToPending 保留 24h
修复成功 + push 失败push_failedfailednullfinally 立即清理
引擎内部交付失败(commit / verify / rollback / FATAL)engine_delivery_failedCOMMIT_FAILED / VERIFICATION_FAILED / ROLLBACK_FAILED / FATALfailednullfinally 立即清理
引擎进程级 exitCode=2 + result 存在engine_exit_2failednullfinally 立即清理
修复成功 + 修复动作执行失败execution_failedfailednullfinally 立即清理
执行超时execution_timeoutfailednullfinally 立即清理
report-onlycompletednullfinally 立即清理

错误码 pr_creation_failed 命名与 B 模式已有的 result_fetch_failed / run_url_not_resolved 对齐——表达"副作用已落库(分支已推)+ 最终操作未完成"。 错误码 engine_delivery_failed 是 M25 新增的进程内识别——引擎在 fix / fix-and-pr 流程走到交付阶段失败(commit / PR / verify / rollback / FATAL),result 仍可能存在(部分仓库成功 + 部分失败)但远程未完整落地。原行为 result 存在即 completed 会让"已修复 8 但无 PR"误报(见 2026-09-04 03:28 AM 事故)。

8.3 关键代码点

函数 / 模块位置职责
extractBranchName(workDir)container-executor.tsgit rev-parse --abbrev-ref HEAD;detached HEAD 抛错(仅 fix 模式推默认分支时使用)
pushFixBranch(branch, workDir, token?)container-executor.tsgit push origin <branch>,token 走 http.extraheader(base64 basic auth),避免进 argv/URL(仅 fix 模式使用)
createFixBranch(branchName, workDir)@dependfix/enginegit checkout -b 创建/切换修复分支(fix-and-pr 模式从 HEAD 拉新分支,引擎导出复用)
readHeadSha(workDir)container-executor.tsgit rev-parse HEAD,返回 SHA 或 null(用于 hasNewCommit 判定)
checkHasNewCommit(workDir, preRunHead)container-executor.ts比较修复前后 HEAD SHA,引擎 commit 后才返回 true
platform-delivery 模块executor/platform-delivery.tsM25 新增:平台侧 fix-and-pr 完整交付单元(plan / push / create / close supersede)
planFixAndPrDelivery(octokit, owner, repo, result)platform-delivery.ts复用引擎 computeFixFingerprint + findDependfixOpenPR + computeFixAndPrPlan,返回 skip / create 决策
pushFixBranchWithCredential(branch, workDir, token)platform-delivery.tspushFixBranch 等价,独立导出便于单测;失败抛 PlatformDeliveryError(push_failed)
deliverFixAndPr(ctx)platform-delivery.ts编排 push + create + close supersede;失败抛 PlatformDeliveryError(pr_creation_failed / supersede_failed)
PlatformDeliveryErrorplatform-delivery.tscode + branchPushed 状态的结构化错误(区分 push_failed vs pr_creation_failed)
moveToPending(workDir, runId, pendingRoot, retentionMs)container-executor.ts移动 workDir 到 _pending/{runId}/ + 写 .meta.json(含 expiresAt
cleanupRemoteBranch(branch, workDir, token?)container-executor.tsbest-effort 远程分支清理;当前 不主动调用(保留远程分支供用户手动开 PR)

8.4 凭据权限阶(重要安全考量)

C53 引入 A 模式 fix-and-pr 链路完成的关键代价是 凭据权限面扩大

模式所需 Token 权限
A 模式 report-onlysecurity-events: read(拉告警)
A 模式 fix(仅 commit)contents: write(push 到默认分支 / 修复分支)
A 模式 fix-and-prcontents: write + pull-requests: write(开 PR)
B 模式(GitHub Action)actions: read + write(仅触发 workflow + 拉结果)

A 模式 fix-and-pr 要求 wide-scope PAT(classic PAT 勾选 repo 或 fine-grained PAT 显式授权 Contents: write + Pull requests: write),与 B 模式的窄权限形成鲜明对比。

安全优势(M25 修复后)

  • 修复前:引擎在容器内裸 git push 无凭据 → 失败 + workDir 保留 24h(含 .git/config 未持久化 extraheader,但其他 token 落盘风险存在)
  • 修复后:平台 pushFixBranchWithCredentialgit -c http.extraheader=... 一次性注入,token 不写 .git/config;即使 pr_creation_failed workDir 保留 24h,无 token 落盘

安全建议

  • 默认推荐 B 模式(目标仓库已配置 action 时,自动降级为 actions: read + write,权限面最窄)
  • A 模式 fix-and-pr 适用于"自托管平台 + 强可控 PAT"场景(如专用 CI 账户)
  • 平台 UI 触发时显式提示当前所选凭据的权限范围(待 C28 设计落地)

完整凭据安全条款见 security.md §5.3 修复执行安全(凭据基线 + 权限阶)。

8.5 后续 backlog(依赖追踪)

  • stale-cleanup 任务:moveToPending 写入的 _pending/{runId}/ 当前无定时清理机制;登记后续阶段(M11 后段或 M12),按 .meta.jsonexpiresAt 字段扫描删除
  • sanitizeErrorMessage 补充 Authorization: token xxx 模式:当前实现不覆盖 GitHub REST API 实际形态;C53-2 RG-W2 登记后续 patch
  • A 模式 dispatched UI 提示:用户看到 dispatched 状态需明确"PR 创建失败,分支已推,可手动开 PR"——当前 UI 通用 dispatched 提示,需后续优化
  • 录入 M11 阶段:C53 + T1005 sandbox 路由 + C28 security.md 凭据设计 共同组成 M11(业务可见性 + 沙箱落地 + 安全文档)阶段

Released under the MIT License.