seat: row 199 publish and publishRemote; the tip and identity checks under the working repo's lock, then ls-remote and a push by oid to refs/heads/<user>/<branch> on the agent's fork; the token reaches git on fd 3 through a one-shot helper for the fork's exact host and port; no redirects, no stored remote or credential; a TLS fake Gitea for the tests (#1697)

This commit is contained in:
2026-10-20 17:55:12 -05:00
parent c80a238534
commit b415d4e765
4 changed files with 376 additions and 4 deletions
+7 -3
View File
@@ -101,8 +101,10 @@ export function gitEnv(env = process.env) {
// consequence: a Ctrl-C at the terminal of a host run by hand no longer
// reaches its git, though a systemd stop still does (it kills the cgroup).
// `stdin` names a file git reads as its input (row 137: patch-id); without
// it git gets none.
export function runGit(argv, env, limits = GIT_LIMITS, { stdin = null } = {}) {
// it git gets none. `pass` (row 199, publish) is written once to a pipe on
// fd 3, which git and what it starts inherit; publish's credential helper
// reads its token there, so the token is in no argv, env, URL or file.
export function runGit(argv, env, limits = GIT_LIMITS, { stdin = null, pass = null } = {}) {
return new Promise((resolve) => {
let input = "ignore";
try {
@@ -110,8 +112,10 @@ export function runGit(argv, env, limits = GIT_LIMITS, { stdin = null } = {}) {
} catch (e) {
return resolve({ ok: false, out: "", err: "", why: e.code ?? e.message });
}
const child = spawn("git", argv, { env, detached: true, stdio: [input, "pipe", "pipe"] });
const child = spawn("git", argv, { env, detached: true, stdio: [input, "pipe", "pipe", ...(pass === null ? [] : ["pipe"])] });
if (typeof input === "number") closeSync(input);
// A git that never asked for it closes the pipe unread: EPIPE is no fault.
if (pass !== null) child.stdio[3].on("error", () => {}).end(pass);
let out = "";
let err = "";
let signalled = false;
+89 -1
View File
@@ -63,6 +63,22 @@
// compare and swap (base-moved), then record() runs; when it throws, main
// goes back, and a main that can't go back refuses rollback-failed.
// dropLand removes the worktree; the caller runs it on every path.
//
// publish (row 199, #1697, design D9 to D9b) sends a recorded work commit to
// the agent's own fork. It runs in two processes, because the gitea token
// lives in the bus process and the lock in the host's:
// publish, in the host, under the lock: the seed; the work ref is at the
// source commit (ref-moved); every commit in main..source has author and
// committer email equal to the agent's (identity-mismatch); then
// remote(), which the caller wires to publishRemote in the bus process.
// publishRemote, in the bus process, with the token on fd 3 only:
// `ls-remote --symref <fork> HEAD <dst>`; a non-zero exit refuses
// fork-unavailable. The fork's HEAD must be a commit this repository has
// and an ancestor of main (remote-unrelated, an empty fork included).
// Then `push <fork> <source>:<dst>` by oid, with no `+`. Only https
// crosses (protocol.https.allow), redirects are refused, and output
// holding the token refuses credential-leak. Git's stderr is never
// returned: it can carry the URL.
import { randomBytes } from "node:crypto";
import { closeSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
@@ -101,7 +117,7 @@ export const FSCK = ["-c", "transfer.fsckObjects=true"];
// `limits` is { timeoutMs, graceMs }; the tests shorten them. runGit
// (checkout.mjs) runs git in its own process group and signals the group.
const git = (args, env, { pre = [], limits = GIT_LIMITS, stdin = null } = {}) => runGit([...SAFE, ...pre, ...args], gitEnv(env), limits, { stdin });
const git = (args, env, { pre = [], limits = GIT_LIMITS, stdin = null, pass = null } = {}) => runGit([...SAFE, ...pre, ...args], gitEnv(env), limits, { stdin, pass });
// git with this call's env and limits, for the helpers below.
const bound = (env, limits) => (args, opts = {}) => git(args, env, { ...opts, limits });
@@ -402,3 +418,75 @@ async function removeWorktree(repo, worktree, g) {
if (!r.ok) rmSync(worktree, { recursive: true, force: true });
await g([`--git-dir=${repo}`, "worktree", "prune"]);
}
// await publish({ dataRoot, business, project, root, taskRef, source, email, remote, env, limits })
// Resolves what remote() resolves; rejects with WorkingError.
export function publish({ dataRoot, business, project, root, taskRef, source, email, remote, env = process.env, limits = GIT_LIMITS }) {
if (!PROJECT.test(project ?? "") || !OID.test(source ?? "") || typeof email !== "string" || !email) refuse("invalid-request");
const ref = `refs/heads/${workRef(taskRef)}`;
const repo = workingRepo(dataRoot, business, project);
const g = bound(env, limits);
return locked(repo, async () => {
try {
if (!lstatSync(repo).isDirectory()) refuse("working-repo-invalid");
} catch (e) {
if (e instanceof WorkingError) throw e;
refuse("working-repo-invalid", `${repo} can't be read: ${e.code}`);
}
await seed(repo, root, g);
const at = `--git-dir=${repo}`;
if ((await tip(repo, ref, g)) !== source) refuse("ref-moved", `${ref} isn't at ${source}`);
const range = await g([at, "log", "--format=%ae%x00%ce", `refs/heads/main..${source}`]);
if (!range.ok) refuse("working-repo-invalid", range.why);
for (const line of range.out.split("\n").filter(Boolean)) {
if (line !== `${email}\0${email}`) refuse("identity-mismatch", `a commit in main..${source.slice(0, 12)} isn't by ${email}`);
}
return remote();
});
}
const HOST = /^[a-z0-9.-]{1,253}(:[0-9]{1,5})?$/;
const NAME = /^[A-Za-z0-9_.-]{1,100}$/;
// The one-shot helper: for a `get` on https at exactly the fork's host and
// port, it answers the user and the line it reads from fd 3. Anything else,
// and `store` and `erase`, gets no answer. HOST and NAME keep both shell-safe.
const helper = (host, user) =>
`!f() { test "$1" = get || exit 0; p=; h=; while IFS= read -r l; do case "$l" in protocol=*) p=\${l#protocol=};; host=*) h=\${l#host=};; "") break;; esac; done; ` +
`test "$p" = https && test "$h" = '${host}' || exit 0; IFS= read -r t <&3 || exit 0; printf 'username=%s\\npassword=%s\\n' '${user}' "$t"; }; f`;
// await publishRemote({ repo, fork, user, source, dst, token, tls, env, limits })
// Resolves { ref, commit, previous }, previous being dst's tip on the fork
// before the push, or null. `tls` is for the tests' scratch CA only.
export async function publishRemote({ repo, fork, user, source, dst, token, tls = [], env = process.env, limits = GIT_LIMITS }) {
let url;
try {
url = new URL(fork);
} catch {
refuse("invalid-request");
}
if (url.protocol !== "https:" || !HOST.test(url.host) || !NAME.test(user ?? "") || !OID.test(source ?? "") || !dst?.startsWith(`refs/heads/${user}/`)) refuse("invalid-request");
const secret = String(token).trim();
const net = async (args) => {
const pre = [...tls, "-c", "protocol.https.allow=always", "-c", "http.followRedirects=false", "-c", "credential.helper=", "-c", `credential.helper=${helper(url.host, user)}`];
const r = await git([`--git-dir=${repo}`, ...args], env, { pre, limits, pass: `${secret}\n` });
if (r.out.includes(secret) || r.err.includes(secret)) refuse("credential-leak");
return r;
};
const g = bound(env, limits);
const listed = await net(["ls-remote", "--symref", fork, "HEAD", dst]);
if (!listed.ok) refuse("fork-unavailable");
let head = null;
let previous = null;
for (const line of listed.out.split("\n")) {
const [oid, name] = line.split("\t");
if (!OID.test(oid ?? "")) continue;
if (name === "HEAD") head = oid;
else if (name === dst) previous = oid;
}
if (!head) refuse("remote-unrelated", "the fork is empty");
const known = await g([`--git-dir=${repo}`, "cat-file", "-e", `${head}^{commit}`]);
if (!known.ok || !(await g([`--git-dir=${repo}`, "merge-base", "--is-ancestor", head, "refs/heads/main"])).ok) refuse("remote-unrelated", "the fork's HEAD isn't in main's history");
const pushed = await net(["push", "--porcelain", "--no-follow-tags", fork, `${source}:${dst}`]);
if (!pushed.ok) refuse(/\((non-fast-forward|fetch first)\)/.test(pushed.out) ? "non-fast-forward" : "push-failed");
return { ref: dst, commit: source, previous };
}
+95
View File
@@ -0,0 +1,95 @@
// Row 199 (#1697, design T3): a fake Gitea for publish, on 127.0.0.1 over TLS with a scratch CA,
// serving bare repositories under `<dir>/repos/<user>/<repo>.git` through git http-backend. Every
// request needs Basic auth with the user and token it was made with, or with any pair in `users`,
// as a private repository on Gitea does. It records each request's method, path and user, never the password. `redirect`
// makes every request a 302 to `redirect`. A repository's own hooks run on a push, so a test can
// make the fork echo something back as `remote:` lines. Nothing here leaves 127.0.0.1.
import { spawn, spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { createServer } from "node:https";
import { join } from "node:path";
// A CA and a server certificate for IP 127.0.0.1, made once per directory with openssl.
export function scratchCa(dir) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
const ssl = (...args) => {
const r = spawnSync("openssl", args, { cwd: dir, encoding: "utf8" });
if (r.status !== 0) throw new Error(`openssl ${args[0]}: ${r.stderr}`);
};
ssl("req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", "-nodes", "-keyout", "ca.key", "-out", "ca.pem", "-days", "2", "-subj", "/CN=mosaic test CA");
ssl("req", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", "-nodes", "-keyout", "server.key", "-out", "server.csr", "-subj", "/CN=127.0.0.1");
writeFileSync(join(dir, "san.ext"), "subjectAltName=IP:127.0.0.1\n");
ssl("x509", "-req", "-in", "server.csr", "-CA", "ca.pem", "-CAkey", "ca.key", "-CAcreateserial", "-out", "server.pem", "-days", "2", "-extfile", "san.ext");
return { ca: join(dir, "ca.pem"), key: readFileSync(join(dir, "server.key")), cert: readFileSync(join(dir, "server.pem")) };
}
export async function fakeGitea({ dir, tls, user, token, users = { [user]: token }, redirect = null }) {
const root = join(dir, "repos");
mkdirSync(root, { recursive: true });
const requests = [];
const server = createServer({ key: tls.key, cert: tls.cert }, (req, res) => {
const url = new URL(req.url, "https://127.0.0.1");
const auth = /^Basic (.+)$/.exec(req.headers.authorization ?? "");
const [who, pass] = auth ? Buffer.from(auth[1], "base64").toString().split(":") : [null, null];
requests.push({ method: req.method, path: url.pathname, user: who });
if (redirect) {
res.writeHead(302, { location: redirect + req.url });
return res.end();
}
if (!who || !Object.hasOwn(users, who) || pass !== users[who]) {
res.writeHead(401, { "www-authenticate": 'Basic realm="Gitea"' });
return res.end();
}
// /<owner>/<repo>/info/refs and the like, on <root>/<owner>/<repo>.git.
const m = /^\/([^/]+)\/([^/]+?)(?:\.git)?(\/.*)$/.exec(url.pathname);
if (!m) {
res.writeHead(404);
return res.end();
}
const cgi = spawn("git", ["http-backend"], {
env: {
PATH: process.env.PATH,
GIT_PROJECT_ROOT: root,
GIT_HTTP_EXPORT_ALL: "1",
GIT_CONFIG_NOSYSTEM: "1",
GIT_CONFIG_GLOBAL: "/dev/null",
HOME: "/nonexistent",
PATH_INFO: `/${m[1]}/${m[2]}.git${m[3]}`,
REQUEST_METHOD: req.method,
QUERY_STRING: url.search.slice(1),
CONTENT_TYPE: req.headers["content-type"] ?? "",
HTTP_CONTENT_ENCODING: req.headers["content-encoding"] ?? "",
GIT_PROTOCOL: req.headers["git-protocol"] ?? "",
REMOTE_USER: who,
REMOTE_ADDR: "127.0.0.1",
},
stdio: ["pipe", "pipe", "ignore"],
});
req.pipe(cgi.stdin);
const chunks = [];
cgi.stdout.on("data", (c) => chunks.push(c));
cgi.on("close", () => {
const out = Buffer.concat(chunks);
const split = out.indexOf("\r\n\r\n");
const head = out.subarray(0, split).toString().split("\r\n");
const headers = {};
let status = 200;
for (const line of head) {
const [k, ...v] = line.split(":");
if (k.toLowerCase() === "status") status = Number.parseInt(v.join(":").trim(), 10);
else headers[k] = v.join(":").trim();
}
res.writeHead(status, headers);
res.end(out.subarray(split + 4));
});
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const port = server.address().port;
return {
port,
origin: `https://127.0.0.1:${port}`,
requests,
repo: (owner, name) => join(root, owner, `${name}.git`),
close: () => new Promise((r) => server.close(r)),
};
}
+185
View File
@@ -0,0 +1,185 @@
// Row 199 (#1697, design D9 step 3 to 5, D9a, T3): publish and publishRemote (src/working.mjs)
// against a TLS fake Gitea on 127.0.0.1 (fake-gitea.mjs) with a scratch CA, passed only through
// the `tls` parameter. Fake tokens in a scratch root. Each refusal that should reach the network
// checks the fake saw the request, so no case passes because git refused https first.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { gitEnv } from "../src/checkout.mjs";
import { publish, publishRemote, push, workingRepo } from "../src/working.mjs";
import { fakeGitea, scratchCa } from "./fake-gitea.mjs";
const TASK = "vikunja:1/2";
const TOKEN = "fake_publish_token_0123456789";
const USER = "coder";
const EMAIL = "[email protected]";
const DST = "refs/heads/coder/feat";
function git(cwd, ...args) {
const r = spawnSync("git", args, { cwd, encoding: "utf8", env: gitEnv() });
assert.equal(r.status, 0, `git ${args.join(" ")}: ${r.stderr}`);
return r.stdout.trim();
}
function commit(dir, text, email = EMAIL) {
writeFileSync(join(dir, "README"), `${text}\n`);
git(dir, "add", "README");
git(dir, "-c", `user.name=${text}`, "-c", `user.email=${email}`, "commit", "-q", "-m", text);
return git(dir, "rev-parse", "HEAD");
}
const rejects = (p, code) => assert.rejects(p, (e) => e.code === code || assert.fail(`${e.code}: ${e.message}, wanted ${code}`));
// A root, a coder's clone with work on feat pushed to the working repo, and a fake Gitea whose
// coder/stack fork is a bare clone of the root.
async function world(t, { fork = true, redirect = null } = {}) {
const dir = mkdtempSync(join(tmpdir(), "mosaic-publish-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const root = join(dir, "root");
mkdirSync(root);
git(root, "init", "-q", "-b", "main");
const first = commit(root, "one", "[email protected]");
const clone = join(dir, "clone");
git(dir, "clone", "-q", root, clone);
git(clone, "checkout", "-q", "-b", "feat");
const work = commit(clone, "work");
const dataRoot = join(dir, "data");
mkdirSync(dataRoot, { mode: 0o700 });
const file = join(dir, "b.bundle");
git(clone, "bundle", "create", "-q", file, "refs/heads/feat", "--not", "--remotes=origin");
await push({ dataRoot, business: "acme", project: "stack", root, taskRef: TASK, branch: "feat", bundle: file });
const ca = scratchCa(join(dir, "ca"));
const gitea = await fakeGitea({ dir: join(dir, "gitea"), tls: ca, user: USER, token: TOKEN, redirect });
t.after(() => gitea.close());
const forkRepo = gitea.repo(USER, "stack");
if (fork) {
mkdirSync(join(forkRepo, ".."), { recursive: true });
git(dir, "clone", "-q", "--bare", root, forkRepo);
}
const tls = ["-c", `http.sslCAInfo=${ca.ca}`];
const repo = workingRepo(dataRoot, "acme", "stack");
const remote = (extra = {}) => publishRemote({ repo, fork: `${gitea.origin}/${USER}/stack`, user: USER, source: work, dst: DST, token: TOKEN + "\n", tls, ...extra });
const go = (extra = {}) => publish({ dataRoot, business: "acme", project: "stack", root, taskRef: TASK, source: work, email: EMAIL, remote: () => remote(), ...extra });
return { dir, root, first, clone, work, dataRoot, repo, gitea, forkRepo, tls, remote, go };
}
test("T3: publish pushes the work commit by oid to <user>/<branch> on the fork, with the token only through the helper", async (t) => {
const w = await world(t);
assert.deepEqual(await w.go(), { ref: DST, commit: w.work, previous: null });
assert.equal(git(w.dir, `--git-dir=${w.forkRepo}`, "rev-parse", DST), w.work);
// Every request went to the fork's path, and each one that carried auth carried the agent's user.
assert.ok(w.gitea.requests.length > 0);
for (const r of w.gitea.requests) assert.match(r.path, /^\/coder\/stack\/(info\/refs|git-receive-pack|git-upload-pack)$/);
assert.ok(w.gitea.requests.some((r) => r.user === USER && r.path.endsWith("git-receive-pack")));
// A second publish of the same commit is a no-op push and names the previous tip.
assert.deepEqual(await w.go(), { ref: DST, commit: w.work, previous: w.work });
// The working repo holds no remote and no credential config.
assert.equal(spawnSync("git", [`--git-dir=${w.repo}`, "config", "--get-regexp", "^(remote|credential)\\."], { env: gitEnv() }).stdout.toString(), "");
});
test("T3: a moved fork branch refuses non-fast-forward; no + and no force", async (t) => {
const w = await world(t);
const other = join(w.dir, "other");
git(w.dir, "clone", "-q", w.root, other);
const elsewhere = commit(other, "elsewhere");
git(other, "push", "-q", w.forkRepo, `${elsewhere}:${DST}`);
await rejects(w.go(), "non-fast-forward");
assert.equal(git(w.dir, `--git-dir=${w.forkRepo}`, "rev-parse", DST), elsewhere);
});
test("T3: D9a: an empty fork and an unrelated fork refuse remote-unrelated; a missing fork refuses fork-unavailable", async (t) => {
const empty = await world(t, { fork: false });
git(empty.dir, "init", "-q", "--bare", empty.forkRepo);
await rejects(empty.go(), "remote-unrelated");
assert.ok(empty.gitea.requests.length > 0);
assert.equal(empty.gitea.requests.some((r) => r.path.endsWith("git-receive-pack")), false);
const unrelated = await world(t, { fork: false });
const alone = join(unrelated.dir, "alone");
mkdirSync(alone);
git(alone, "init", "-q", "-b", "main");
commit(alone, "unrelated");
git(unrelated.dir, "clone", "-q", "--bare", alone, unrelated.forkRepo);
await rejects(unrelated.go(), "remote-unrelated");
assert.equal(unrelated.gitea.requests.some((r) => r.path.endsWith("git-receive-pack")), false);
const missing = await world(t, { fork: false });
await rejects(missing.go(), "fork-unavailable");
assert.ok(missing.gitea.requests.length > 0);
});
test("T3: a fork past main refuses remote-unrelated until root catches up", async (t) => {
const w = await world(t);
const other = join(w.dir, "other");
git(w.dir, "clone", "-q", w.forkRepo, other);
const ahead = commit(other, "ahead");
git(other, "push", "-q", w.forkRepo, `${ahead}:refs/heads/main`);
await rejects(w.go(), "remote-unrelated");
});
test("T3: a wrong token refuses fork-unavailable; git's stderr isn't in the refusal", async (t) => {
const w = await world(t);
await assert.rejects(w.remote({ token: "fake_wrong_token_0123456789" }), (e) => e.code === "fork-unavailable" && e.message === "fork-unavailable");
assert.ok(w.gitea.requests.length > 0);
assert.equal(git(w.dir, `--git-dir=${w.forkRepo}`, "for-each-ref", DST), "");
});
test("T3: a 302 is not followed: fork-unavailable, and the target sees no request", async (t) => {
const w = await world(t);
const target = await fakeGitea({ dir: join(w.dir, "target"), tls: scratchCa(join(w.dir, "ca2")), user: USER, token: TOKEN });
t.after(() => target.close());
const bounce = await fakeGitea({ dir: join(w.dir, "bounce"), tls: scratchCa(join(w.dir, "ca3")), user: USER, token: TOKEN, redirect: target.origin });
t.after(() => bounce.close());
const tls = ["-c", `http.sslCAInfo=${join(w.dir, "ca3", "ca.pem")}`];
await rejects(w.remote({ fork: `${bounce.origin}/${USER}/stack`, tls }), "fork-unavailable");
assert.ok(bounce.requests.length > 0);
assert.equal(target.requests.length, 0);
});
test("T3: the helper answers only the fork's host and port: a fork on another port gets no token", async (t) => {
const w = await world(t);
// The fork names port 1; insteadOf (through the tests' tls parameter) sends git to the fake's
// port. The helper was told port 1, so it answers nothing and no password goes out.
const before = w.gitea.requests.length;
await rejects(w.remote({ fork: `https://127.0.0.1:1/${USER}/stack`, tls: [...w.tls, "-c", `url.https://127.0.0.1:${w.gitea.port}/.insteadOf=https://127.0.0.1:1/`] }), "fork-unavailable");
assert.ok(w.gitea.requests.length > before, "the rewritten URL reached the fake");
assert.equal(w.gitea.requests.slice(before).some((r) => r.user !== null), false, "no credential went to a host the helper wasn't told");
});
test("T3: a fork that echoes the token refuses credential-leak", async (t) => {
const w = await world(t);
const hook = join(w.forkRepo, "hooks", "pre-receive");
writeFileSync(hook, `#!/bin/sh\necho ${TOKEN} >&2\nexit 0\n`);
chmodSync(hook, 0o755);
await rejects(w.go(), "credential-leak");
});
test("T3: no tls parameter, no trust: the scratch CA reaches git only through it", async (t) => {
const w = await world(t);
await rejects(w.remote({ tls: [] }), "fork-unavailable");
});
test("D9 steps 3 and 4: ref-moved and identity-mismatch refuse before any request", async (t) => {
const w = await world(t);
await rejects(w.go({ source: w.first }), "ref-moved");
const other = commit(w.clone, "by someone else", "[email protected]");
const file = join(w.dir, "b2.bundle");
git(w.clone, "bundle", "create", "-q", file, "refs/heads/feat", "--not", "--remotes=origin");
await push({ dataRoot: w.dataRoot, business: "acme", project: "stack", root: w.root, taskRef: TASK, branch: "feat", bundle: file });
await rejects(w.go({ source: other }), "identity-mismatch");
assert.equal(w.gitea.requests.length, 0);
});
test("publishRemote refuses a fork, user or destination that isn't shell- and ref-safe before any git", async (t) => {
const w = await world(t);
for (const extra of [
{ fork: `http://127.0.0.1:${w.gitea.port}/${USER}/stack` },
{ fork: `https://127.0.0.1:${w.gitea.port}'/${USER}/stack` },
{ user: "co'der" },
{ dst: "refs/heads/main" },
{ dst: "refs/heads/pm/feat" },
{ source: "HEAD" },
]) await rejects(w.remote(extra), "invalid-request");
assert.equal(w.gitea.requests.length, 0);
});