Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,不再把"跳过验证"当作"验证通过"。
Expand Down
4 changes: 3 additions & 1 deletion agent-runner/src/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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. */
Expand Down
67 changes: 58 additions & 9 deletions agent-runner/src/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -31,6 +31,8 @@ export interface VerifySpec {
fingerprint: string;
/** Content hash of scripts the command invokes (e.g. `bash scripts/check.sh`). */
gateFiles?: Record<string, string>;
/** Docker image the verification must run in (profile `verifyContainer`). */
container?: string;
}

export interface VerifyResult {
Expand Down Expand Up @@ -156,14 +158,47 @@ function hashedFiles(workDir: string, command: string | null): Record<string, st
export async function captureVerifySpec(workDir: string, profile?: RepoProfile): Promise<VerifySpec> {
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 };
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions agent-runner/test/profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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/"],
});
Expand Down
34 changes: 33 additions & 1 deletion agent-runner/test/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -13,6 +13,32 @@ function writePackage(scripts: Record<string, string>): 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-"));
Expand Down Expand Up @@ -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");
Expand Down
19 changes: 19 additions & 0 deletions docs/portability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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 <worktree>:/work -w /work --entrypoint bash <image> -c "<cmd>"`。

- 镜像必须自带 `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`
Expand Down
Loading