diff --git a/CHANGELOG.md b/CHANGELOG.md index d4f196f..f98d1f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,12 @@ - 新增 `docs/portability.md`:通用化手册、字段与优先级、验证命令的运行环境约束、新项目接入清单。 - OrzMCApp(Swift)画像落地:Ubuntu 可跑的 `check-platform-split.sh` + Swift 目录的受保护路径 + 经验文件指向 `docs/iteration-flow.md`。 +### Added (verifyContainer) + +- 仓库画像支持 `verifyContainer`:验证命令在指定 Docker 镜像内执行(Swift / .NET / 特定 JDK 等 + 宿主没有的工具链),工作区挂载到 `/work`;容器模式下不在宿主预装依赖;Docker 不可用时明确失败。 +- 验证闸门同时冻结 `verifyContainer` 与命令引用脚本的内容哈希。 + ### Changed - 检查命令在 agent 运行前冻结;若检查配置被改动,执行冻结版本并标记 `ai-verify-changed`,不再把"跳过验证"当作"验证通过"。 diff --git a/agent-runner/src/profile.ts b/agent-runner/src/profile.ts index 154f550..6d00ab7 100644 --- a/agent-runner/src/profile.ts +++ b/agent-runner/src/profile.ts @@ -21,6 +21,8 @@ export interface RepoProfile { /** verification commands, run in order joined by ` && ` */ verify?: string[]; verifyTimeoutMs?: number; + /** Docker image used to run verification (e.g. "swift:6.0", "mcr.microsoft.com/dotnet/sdk:8.0") */ + verifyContainer?: string; /** path prefixes that make a change high-risk (draft PR + code owner) */ protectedPaths?: string[]; protectedFiles?: string[]; @@ -85,7 +87,7 @@ const LIST_KEYS = [ "ignoredPaths", "dependencyFiles", ] as const; -const STRING_KEYS = ["learningsFile", "agent", "model"] as const; +const STRING_KEYS = ["learningsFile", "agent", "model", "verifyContainer"] as const; const NUMBER_KEYS = ["verifyTimeoutMs", "maxAttempts", "maxPrsPerDay"] as const; /** Drop unknown keys / wrong types so a typo cannot silently misconfigure a repo. */ diff --git a/agent-runner/src/verify.ts b/agent-runner/src/verify.ts index a3bf1c6..acfc882 100644 --- a/agent-runner/src/verify.ts +++ b/agent-runner/src/verify.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; -import { runAsync } from "./util/exec.ts"; +import { runAsync, runSync } from "./util/exec.ts"; import { verifyCommandFromProfile, type RepoProfile } from "./profile.ts"; /** @@ -31,6 +31,8 @@ export interface VerifySpec { fingerprint: string; /** Content hash of scripts the command invokes (e.g. `bash scripts/check.sh`). */ gateFiles?: Record; + /** Docker image the verification must run in (profile `verifyContainer`). */ + container?: string; } export interface VerifyResult { @@ -156,14 +158,47 @@ function hashedFiles(workDir: string, command: string | null): Record { const gate = await resolveGate(workDir, profile); const gateFiles = hashedFiles(workDir, gate.command); + const container = profile?.verifyContainer; return { command: gate.command, scriptBody: gate.scriptBody, - fingerprint: `${await gateFingerprint(workDir, gate.command)}|scripts:${JSON.stringify(gateFiles)}`, + fingerprint: `${await gateFingerprint(workDir, gate.command)}|scripts:${JSON.stringify(gateFiles)}|container:${container ?? ""}`, gateFiles, + ...(container ? { container } : {}), }; } +export interface VerifyInvocation { + binary: string; + args: string[]; + /** human-readable form for logs / failure comments */ + display: string; +} + +function hasBinary(name: string): boolean { + return runSync("bash", ["-c", `command -v ${name}`]).status === 0; +} + +/** + * How the frozen gate is executed. + * + * A profile can pin a Docker image for toolchains that the runner does not + * have (Swift, .NET, a specific JDK, …). The worktree is mounted read-write so + * dependency managers can write caches into it. + */ +export function buildVerifyInvocation(spec: VerifySpec, tampered: boolean, workDir: string): VerifyInvocation { + const body = + tampered && spec.scriptBody ? `export PATH="$PWD/node_modules/.bin:$PATH"; ${spec.scriptBody}` : (spec.command ?? ""); + if (spec.container) { + return { + binary: "docker", + args: ["run", "--rm", "-v", `${workDir}:/work`, "-w", "/work", "--entrypoint", "bash", spec.container, "-c", body], + display: `docker run --rm -v ${workDir}:/work -w /work --entrypoint bash ${spec.container} -c ${JSON.stringify(body)}`, + }; + } + return { binary: "bash", args: ["-c", body], display: body }; +} + async function ensureDeps(workDir: string, timeoutMs: number): Promise<{ command: string | null; output: string; ok: boolean }> { if (existsSync(join(workDir, "node_modules"))) return { command: null, output: "", ok: true }; if (!existsSync(join(workDir, "package.json"))) return { command: null, output: "", ok: true }; @@ -205,10 +240,26 @@ export async function runVerify( } const budgetMs = clampTimeout(timeoutMs); - const fingerprintNow = `${await gateFingerprint(workDir, spec.command)}|scripts:${JSON.stringify(hashedFiles(workDir, spec.command))}`; + const fingerprintNow = `${await gateFingerprint(workDir, spec.command)}|scripts:${JSON.stringify(hashedFiles(workDir, spec.command))}|container:${spec.container ?? ""}`; const tampered = fingerprintNow !== spec.fingerprint; - const deps = await ensureDeps(workDir, budgetMs); + if (spec.container && !hasBinary("docker")) { + return { + ok: false, + skipped: false, + verified: false, + tampered, + command: spec.command, + output: `verifyContainer is set (${spec.container}) but docker is not available on this runner`, + timedOut: false, + }; + } + + // In a container the image owns the toolchain; installing npm deps on the + // host first would be wrong (binaries may not match). + const deps = spec.container + ? { command: null as string | null, output: "", ok: true } + : await ensureDeps(workDir, budgetMs); if (!deps.ok) { return { ok: false, @@ -223,15 +274,13 @@ export async function runVerify( // Trusted target: normally the frozen command. If the gate was modified, run // the script body captured before the agent ran (npm's local bin on PATH). - const runBody = tampered && spec.scriptBody !== null; - const target = runBody ? spec.scriptBody as string : spec.command; - const shellCommand = runBody ? `export PATH="$PWD/node_modules/.bin:$PATH"; ${target}` : target; + const invocation = buildVerifyInvocation(spec, tampered, workDir); let output = deps.output ? `$ ${deps.command}\n${deps.output}\n` : ""; - output += `$ ${runBody ? `(frozen) ${target}` : target}\n`; + output += `$ ${invocation.display}\n`; if (tampered) output += "(gate configuration changed during the run; executing the frozen version)\n"; - const res = await runAsync("bash", ["-c", shellCommand], { + const res = await runAsync(invocation.binary, invocation.args, { cwd: workDir, timeoutMs: budgetMs, onStdout: (s) => (output += s), diff --git a/agent-runner/test/profile.test.ts b/agent-runner/test/profile.test.ts index a94bc9f..79ae5d7 100644 --- a/agent-runner/test/profile.test.ts +++ b/agent-runner/test/profile.test.ts @@ -7,6 +7,7 @@ describe("profile: sanitizeProfile", () => { const cleaned = sanitizeProfile({ verify: ["npm test", " ", ""], model: " deepseek-v4-pro ", + verifyContainer: " swift:6.0 ", maxAttempts: 2, protectedPaths: ["src/generated/"], unknownKey: "ignored", @@ -16,6 +17,7 @@ describe("profile: sanitizeProfile", () => { assert.deepEqual(cleaned, { verify: ["npm test"], model: "deepseek-v4-pro", + verifyContainer: "swift:6.0", maxAttempts: 2, protectedPaths: ["src/generated/"], }); diff --git a/agent-runner/test/verify.test.ts b/agent-runner/test/verify.test.ts index 61c5045..48b046d 100644 --- a/agent-runner/test/verify.test.ts +++ b/agent-runner/test/verify.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { captureVerifySpec, runVerify } from "../src/verify.ts"; +import { buildVerifyInvocation, captureVerifySpec, runVerify } from "../src/verify.ts"; let dir = ""; const savedVerifyCmd = process.env.VERIFY_CMD; @@ -13,6 +13,32 @@ function writePackage(scripts: Record): void { writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "fixture", scripts }, null, 2)); } +describe("verify invocation", () => { + it("runs on the host by default", () => { + const inv = buildVerifyInvocation({ command: "npm test", scriptBody: null, fingerprint: "" }, false, "/w"); + assert.equal(inv.binary, "bash"); + assert.deepEqual(inv.args, ["-c", "npm test"]); + }); + + it("uses docker when the profile pins an image", () => { + const inv = buildVerifyInvocation( + { command: "swift test", scriptBody: null, fingerprint: "", container: "swift:6.0" }, + false, + "/w", + ); + assert.equal(inv.binary, "docker"); + assert.ok(inv.args.includes("swift:6.0")); + assert.ok(inv.args.includes("/w:/work")); + assert.ok(inv.args.includes("/work")); + }); + + it("runs the frozen npm script body when the gate was tampered", () => { + const inv = buildVerifyInvocation({ command: "npm run check", scriptBody: "tsc --noEmit", fingerprint: "" }, true, "/w"); + assert.match(String(inv.args[1]), /tsc --noEmit/); + assert.match(String(inv.args[1]), /node_modules\/\.bin/); + }); +}); + describe("verify gate", () => { beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "easygh-verify-")); @@ -84,6 +110,12 @@ describe("verify gate", () => { assert.equal(spec.command, "swift test"); }); + it("captures the container image into the spec", async () => { + const spec = await captureVerifySpec(dir, { verify: ["true"], verifyContainer: "alpine:3" }); + assert.equal(spec.command, "true"); + assert.equal(spec.container, "alpine:3"); + }); + it("detects a weakened verification script (self-reference hole)", async () => { mkdirSync(join(dir, "scripts"), { recursive: true }); writeFileSync(join(dir, "scripts", "check.sh"), "#!/usr/bin/env bash\nexit 0\n"); diff --git a/docs/portability.md b/docs/portability.md index 3121973..3bb8f1d 100644 --- a/docs/portability.md +++ b/docs/portability.md @@ -40,6 +40,7 @@ EasyGithub 的设计目标是**项目无关**:同一套 runner 服务 Node / R |---|---|---| | `verify` | 验证命令数组,按顺序用 ` && ` 连接;**在 agent 启动前冻结** | 自动探测 | | `verifyTimeoutMs` | 单次验证超时 | 600000(10 分钟) | +| `verifyContainer` | 在指定 Docker 镜像里执行验证(Swift/.NET/特定 JDK…) | 宿主直接执行 | | `protectedPaths` / `protectedFiles` | 改到这些路径 → **high 风险**(草稿 PR + code owner) | `.github/`、`agent-runner/`、`scripts/`、`eval/`、`LICENSE` | | `blockedPaths` / `blockedFiles` | 直接拒绝提交(运行失败) | `.github/workflows/`、`.github/actions/`、`LICENSE` | | `sensitivePaths` / `sensitiveFiles` | 允许提交但打 `ai-core` 标签,必须人工审查 | `agent-runner/`、`.github/`、`scripts/`、`package.json` | @@ -72,6 +73,24 @@ EasyGithub 的设计目标是**项目无关**:同一套 runner 服务 Node / R - 需要私有依赖/密钥的构建 → 在 hub 加 secret,或把命令收窄到静态检查。 - 没有可跑的检查时,结果会被标记为**未验证**(high 风险 → 草稿 PR),不会被当成"通过"。 +### 容器化验证(`verifyContainer`) + +宿主 runner 没有的工具链(Swift、.NET、特定 JDK、Flutter…)可以用镜像补齐: + +```json +"org/app": { "verify": ["swift test"], "verifyContainer": "swift:6.0" } +``` + +实际执行:`docker run --rm -v :/work -w /work --entrypoint bash -c ""`。 + +- 镜像必须自带 `bash` 与工具链;工作区可写(依赖缓存会写进去)。 +- 容器模式下**不会**在宿主预装 npm 依赖(避免二进制不匹配),依赖由镜像/命令负责。 +- 每次运行都会拉镜像(hosted runner 是临时的);大镜像更慢,按需开启。 +- Docker 不可用时**明确失败**(不会假装"已验证")。 +- **Agent 仍在宿主执行**,容器只负责"裁判"这一段。若 agent 必须编译才能推理,走 Mode 1/2 + 自托管 runner。 +- macOS/Xcode 没有官方镜像 → 用 Mode 1/2 的 caller workflow 指定自托管 `runs-on: macos-*`, + 或像 OrzMCApp 那样给一个 Ubuntu 可跑的轻量门禁(`check-platform-split.sh`)。 + ### 自动探测顺序(没有 profile 时) 1. `package.json` 的 `check` → `verify` → `test` → `lint`