Skip to content

Commit ab02b42

Browse files
committed
Phase 2: local-folder jobs work end-to-end (the zero-GitHub path)
This project mostly runs locally on people's own machines/servers, and the local-folder job kind is the zero-setup path: point pi at a folder, run a flow. It needs NO GitHub App, no webhook, no scoped token -- only a provider API key. The processor already skips minting and branch-checks for kind:local; this makes the path real. prepare-local.mjs: for a local folder that is a git repo, resolve HEAD and materialise .pi/ from it through the SAME symlink/submodule-safe git path as GitHub jobs -- so instructions come from a committed ref (read-only) while work happens on the working tree bind-mounted at /workspace, edited in place. The task text is data, written to /job/prompt.md. A non-git folder is a clear config error (v1 requires one), not a crash. Tested against a real local git repo including a hostile symlink object: it is rejected locally exactly as for GitHub. Verified END TO END against the real image, with no GitHub anything: prepared a real local git folder, mounted it as /workspace and the job dir as /job:ro, launched the real pi image. The runner read /job/prompt.md, found the model claude-sonnet-4-5 in the registry, and stopped at "no configured auth" (exit 2) -- exactly the boundary a provider key crosses. The whole local plumbing works; only the operator's API key is missing. Fixed a real mount bug the e2e exposed: buildDockerRunArgs mounted only the .pi/ subdir at /job/pi, so /job/prompt.md was invisible and every job would have died with "missing job input". It now mounts the WHOLE job dir at /job:ro (prompt.md AND pi/), matching INT-CONTAINER-JOB-INPUTS. The unit test was masking this by only asserting the pi mount -- the running container is what caught it, again. Recalibration: I had framed a GitHub App as the blocker for Phase 2. It is not -- it gates only the GitHub job kind. The local path is fully buildable and verifiable now, and is the primary experience for a self-hosted OSS user. The GitHub token-minting and branch-protection pieces still want an App to verify rather than guess, but they are one of two paths, not the critical one.
1 parent 96c8fd1 commit ab02b42

4 files changed

Lines changed: 129 additions & 7 deletions

File tree

worker/src/docker-run.mjs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export const ISOLATION_FLAGS = [
2424
*
2525
* @param image pinned job image tag/digest
2626
* @param env the closed env map from buildContainerEnv -- passed as explicit -e NAME=VALUE
27-
* @param jobPiDir host path to the materialised .pi/ (mounted /job/pi:ro)
27+
* @param jobDir host path to the /job inputs dir (contains prompt.md and pi/); mounted /job:ro
2828
* @param workspace host path to the fresh clone / local folder (mounted /workspace:rw)
2929
* @param name container name (for `docker stop` at the timeout)
3030
* @param memory e.g. "4g"; cpus e.g. "2"
@@ -33,7 +33,7 @@ export const ISOLATION_FLAGS = [
3333
export function buildDockerRunArgs({
3434
image,
3535
env,
36-
jobPiDir,
36+
jobDir,
3737
workspace,
3838
name,
3939
memory = "4g",
@@ -54,9 +54,9 @@ export function buildDockerRunArgs({
5454
args.push("-e", `${k}=${v}`);
5555
}
5656

57-
// /job is read-only (INT-CONTAINER-JOB-INPUTS): the agent cannot rewrite its own instructions.
58-
// /workspace is the only writable mount.
59-
if (jobPiDir) args.push("-v", `${jobPiDir}:/job/pi:ro`);
57+
// The WHOLE /job dir is read-only (INT-CONTAINER-JOB-INPUTS): it holds prompt.md and pi/, and
58+
// the agent cannot rewrite any of it. /workspace is the only writable mount.
59+
if (jobDir) args.push("-v", `${jobDir}:/job:ro`);
6060
args.push("-v", `${workspace}:/workspace`);
6161

6262
args.push(image);

worker/src/prepare-local.mjs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { execFile } from "node:child_process";
2+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3+
import { join } from "node:path";
4+
import { promisify } from "node:util";
5+
import { materializePiDir } from "./materialize.mjs";
6+
7+
const exec = promisify(execFile);
8+
9+
/**
10+
* Prepare a LOCAL-FOLDER job. This is the zero-GitHub path: no token, no clone, no PR. The folder
11+
* on the operator's own machine becomes /workspace (bind-mounted read-write), edited in place.
12+
*
13+
* For v1 the folder must be a git repository, which buys two things for free: a stable ref (HEAD)
14+
* to read instructions from, and git's object model, so `.pi/` materialises through the same
15+
* symlink/submodule-safe path as GitHub jobs (materializePiDir). Instructions come from HEAD
16+
* (committed, reviewed); work happens on the working tree in /workspace. A non-git folder is a
17+
* documented v1 limitation -- `git init` it first.
18+
*
19+
* The task text is DATA (CONST-ISSUE-TEXT-IS-DATA): it goes into /job/prompt.md, never the
20+
* instructions. The operator supplies it from the panel.
21+
*/
22+
export async function prepareLocalWorkspace({ folder, task, jobDir, git = defaultGit }) {
23+
if (!existsSync(folder)) {
24+
const error = new Error(`local folder does not exist: ${folder}`);
25+
error.piDispatchConfig = true;
26+
throw error;
27+
}
28+
if (!existsSync(join(folder, ".git"))) {
29+
const error = new Error(`local folder is not a git repository (v1 requires one): ${folder}`);
30+
error.piDispatchConfig = true;
31+
throw error;
32+
}
33+
34+
const sha = (await git(folder, ["rev-parse", "HEAD"])).trim();
35+
36+
mkdirSync(jobDir, { recursive: true });
37+
// Instructions from HEAD, via the symlink-safe git materialiser, into /job/pi (mounted :ro).
38+
const written = await materializePiDir({ gitDir: folder, sha, destDir: jobDir });
39+
40+
// The task the operator asked for. Plain data below the instructions.
41+
writeFileSync(join(jobDir, "prompt.md"), String(task ?? ""), { mode: 0o444 });
42+
43+
// The folder itself is /workspace (rw). No clone: local jobs edit in place.
44+
return { workspace: folder, jobDir, sha, materialised: written };
45+
}
46+
47+
async function defaultGit(gitDir, args) {
48+
const { stdout } = await exec("git", ["-c", "core.hooksPath=/dev/null", "--no-pager", "-C", gitDir, ...args], {
49+
encoding: "utf8",
50+
maxBuffer: 16 * 1024 * 1024,
51+
});
52+
return stdout;
53+
}

worker/test/docker-run.test.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { buildDockerRunArgs, ISOLATION_FLAGS } from "../src/docker-run.mjs";
55
const base = {
66
image: "pi-job:pinned",
77
env: { PI_PROVIDER: "anthropic", ANTHROPIC_API_KEY: "sk-real" },
8-
jobPiDir: "/srv/jobs/abc/pi",
8+
jobDir: "/srv/jobs/abc/job",
99
workspace: "/srv/jobs/abc/workspace",
1010
name: "pi-job-abc",
1111
};
@@ -23,7 +23,7 @@ test("carries every isolation flag -- these ARE the boundary", () => {
2323

2424
test("/job is read-only, /workspace is writable", () => {
2525
const args = buildDockerRunArgs(base);
26-
assert.ok(args.includes("/srv/jobs/abc/pi:/job/pi:ro"), "/job/pi must be :ro");
26+
assert.ok(args.includes("/srv/jobs/abc/job:/job:ro"), "the whole /job must be :ro");
2727
assert.ok(args.includes("/srv/jobs/abc/workspace:/workspace"), "/workspace must be writable");
2828
assert.ok(!args.some((a) => a.includes("/workspace:ro")), "/workspace must not be read-only");
2929
});

worker/test/prepare-local.test.mjs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import assert from "node:assert/strict";
2+
import { execFileSync } from "node:child_process";
3+
import { mkdtempSync, readFileSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { test } from "node:test";
7+
import { prepareLocalWorkspace } from "../src/prepare-local.mjs";
8+
9+
function git(dir, args) {
10+
return execFileSync("git", ["-C", dir, ...args], {
11+
encoding: "utf8",
12+
env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" },
13+
});
14+
}
15+
16+
/** A local git repo with a .pi/ persona and skill, plus a working-tree file to "edit". */
17+
function localRepo() {
18+
const dir = mkdtempSync(join(tmpdir(), "pi-local-"));
19+
git(dir, ["init", "-q"]);
20+
git(dir, ["config", "core.autocrlf", "false"]);
21+
const blob = (c) => execFileSync("git", ["-C", dir, "hash-object", "-w", "--stdin"], { input: c, encoding: "utf8" }).trim();
22+
git(dir, ["update-index", "--add", "--cacheinfo", `100644,${blob("LOCAL-PERSONA-SENTINEL")},.pi/APPEND_SYSTEM.md`]);
23+
git(dir, [
24+
"update-index",
25+
"--add",
26+
"--cacheinfo",
27+
`100644,${blob("---\nname: tidy\ndescription: tidy up\n---\nsteps\n")},.pi/skills/tidy/SKILL.md`,
28+
]);
29+
// a hostile symlink object, to prove the local path is as safe as the GitHub path
30+
git(dir, ["update-index", "--add", "--cacheinfo", `120000,${blob("/etc/passwd")},.pi/EVIL.md`]);
31+
git(dir, ["commit", "-qm", "x"]);
32+
return dir;
33+
}
34+
35+
test("prepares a local git folder: materialises .pi/ from HEAD, writes the task, folder is /workspace", async () => {
36+
const folder = localRepo();
37+
const jobDir = mkdtempSync(join(tmpdir(), "pi-job-"));
38+
const result = await prepareLocalWorkspace({ folder, task: "please tidy the imports", jobDir });
39+
40+
assert.equal(result.workspace, folder, "the folder itself is the workspace (edited in place)");
41+
assert.equal(readFileSync(join(jobDir, "prompt.md"), "utf8"), "please tidy the imports");
42+
assert.equal(readFileSync(join(jobDir, "pi/APPEND_SYSTEM.md"), "utf8"), "LOCAL-PERSONA-SENTINEL");
43+
assert.ok(result.materialised.includes("pi/skills/tidy/SKILL.md"));
44+
// the symlink is NOT materialised -- the local path inherits the git materialiser's safety
45+
assert.ok(!result.materialised.some((p) => p.includes("EVIL")), "a hostile symlink must not materialise locally either");
46+
});
47+
48+
test("no GitHub anything: a local job needs no token, no repo, no network", async () => {
49+
// This test passing at all -- with no octokit, no token, no clone URL -- IS the assertion.
50+
const folder = localRepo();
51+
const jobDir = mkdtempSync(join(tmpdir(), "pi-job-"));
52+
const result = await prepareLocalWorkspace({ folder, task: "x", jobDir });
53+
assert.ok(result.sha.match(/^[0-9a-f]{40}$/), "resolved HEAD locally, offline");
54+
});
55+
56+
test("a non-git folder is a clear config error, not a crash", async () => {
57+
const plain = mkdtempSync(join(tmpdir(), "pi-plain-"));
58+
await assert.rejects(
59+
() => prepareLocalWorkspace({ folder: plain, task: "x", jobDir: mkdtempSync(join(tmpdir(), "j-")) }),
60+
(e) => e.piDispatchConfig === true && /not a git repository/.test(e.message),
61+
);
62+
});
63+
64+
test("a missing folder is a clear config error", async () => {
65+
await assert.rejects(
66+
() => prepareLocalWorkspace({ folder: "/does/not/exist/anywhere", task: "x", jobDir: "/tmp/x" }),
67+
(e) => e.piDispatchConfig === true,
68+
);
69+
});

0 commit comments

Comments
 (0)