cli, seat, harness: git socket hardening and refusal tests, row 100 (#1594)
Row 93 follow-up: SIGTERM then SIGKILL for host git, rollback-failed, a total body deadline, fetch-aborted for a reviewer gone mid-fetch, working refs first in have, and tests for five untested refusals. Dewey approved in 28226 and Sage in 28225; Sage's full gate passed 16:57:32-17:01:32Z. The host side is live at the next bus restart. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
+15
-1
@@ -323,11 +323,25 @@ A push runs under the repository's lock, in this order:
|
||||
fast-forward (`non-fast-forward`).
|
||||
8. The broker records the push, an `action.allowed` for
|
||||
`git.push.working` naming the commit. When it
|
||||
refuses, the work ref goes back to where it was.
|
||||
refuses, the work ref goes back to where it was. A work ref that can't
|
||||
go back refuses `rollback-failed` in place of the broker's code, with a
|
||||
message naming the ref and the commit it still holds. The host logs it,
|
||||
since the operator has to move that ref by hand.
|
||||
|
||||
The temp ref is deleted on every path, and so is the bundle file in
|
||||
`bus-host/`. No other ref moves, and the root is only read.
|
||||
|
||||
The repository's lock is a promise chain inside the host's process, so it
|
||||
assumes one launcher per dataRoot. `startHost` refuses a second bus host
|
||||
while `bus-host/host.json` names a live one, but that check isn't atomic:
|
||||
two launchers started at the same moment could both pass it, and their
|
||||
pushes to one repository would no longer be serialized.
|
||||
|
||||
A host git call that runs past 100 s (`GIT_TIMEOUT_MS`) gets SIGTERM. git
|
||||
removes its lockfiles on SIGTERM. Only a git still running 5 s later
|
||||
(`GIT_GRACE_MS`) gets SIGKILL, which can leave a ref's `.lock` behind; that
|
||||
lock refuses every later push to the ref until someone removes it.
|
||||
|
||||
For a review, under the same lock, the host fetches `main` again and
|
||||
writes a bundle of the requested commit with one ref,
|
||||
`refs/mosaic/review`. It leaves out the commits the reviewer says its
|
||||
|
||||
@@ -28,10 +28,19 @@
|
||||
// 6. the commit shares history with main (unrelated-history)
|
||||
// 7. the temp ref moves to the work ref with no `+`, so git refuses
|
||||
// anything but a fast-forward (non-fast-forward)
|
||||
// 8. record() runs; when it throws, the work ref goes back
|
||||
// 8. record() runs; when it throws, the work ref goes back. A work ref
|
||||
// that can't go back refuses rollback-failed in place of record's error.
|
||||
// The temp ref is deleted on every path. The caller owns the bundle file and
|
||||
// deletes it.
|
||||
//
|
||||
// A git past GIT_TIMEOUT_MS gets SIGTERM, on which git removes its lockfiles,
|
||||
// and SIGKILL only if it still runs GIT_GRACE_MS later. A SIGKILL leaves a
|
||||
// ref's .lock behind, and that lock refuses every later push to the ref.
|
||||
//
|
||||
// The lock is a promise chain in this process, so it holds only while one
|
||||
// launcher serves a dataRoot. startHost refuses a second bus host while
|
||||
// bus-host/host.json names a live one; that check isn't atomic.
|
||||
//
|
||||
// review, under the same lock: main again from the root, then a bundle of
|
||||
// the requested commit that leaves out the commits the reviewer said it has
|
||||
// (those the repository knows), written to the file the caller names.
|
||||
@@ -50,6 +59,9 @@ const V2 = "# v2 git bundle\n";
|
||||
const OID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
||||
const TASK_REF = /^vikunja:([1-9][0-9]*)\/([1-9][0-9]*)$/;
|
||||
const PROJECT = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
// How long a git that got SIGTERM has to exit before SIGKILL.
|
||||
export const GIT_GRACE_MS = 5000;
|
||||
const LIMITS = { timeoutMs: GIT_TIMEOUT_MS, graceMs: GIT_GRACE_MS };
|
||||
|
||||
export class WorkingError extends Error {
|
||||
constructor(code, message = code) {
|
||||
@@ -72,17 +84,30 @@ const SAFE = [
|
||||
// Ahead of the fetch from a bundle. Tests replace it to show it matters.
|
||||
export const FSCK = ["-c", "transfer.fsckObjects=true"];
|
||||
|
||||
function git(args, env, { pre = [] } = {}) {
|
||||
// `limits` is { timeoutMs, graceMs }; the tests shorten them.
|
||||
function git(args, env, { pre = [], limits = LIMITS } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("git", [...SAFE, ...pre, ...args], { env: gitEnv(env), timeout: GIT_TIMEOUT_MS, killSignal: "SIGKILL", stdio: ["ignore", "pipe", "pipe"] });
|
||||
const child = spawn("git", [...SAFE, ...pre, ...args], { env: gitEnv(env), stdio: ["ignore", "pipe", "pipe"] });
|
||||
let out = "";
|
||||
let err = "";
|
||||
let kill = null;
|
||||
const term = setTimeout(() => {
|
||||
child.kill("SIGTERM");
|
||||
kill = setTimeout(() => child.kill("SIGKILL"), limits.graceMs);
|
||||
}, limits.timeoutMs);
|
||||
const settle = (r) => {
|
||||
clearTimeout(term);
|
||||
clearTimeout(kill);
|
||||
resolve(r);
|
||||
};
|
||||
child.stdout.setEncoding("utf8").on("data", (d) => (out += d));
|
||||
child.stderr.setEncoding("utf8").on("data", (d) => (err += d));
|
||||
child.on("error", (e) => resolve({ ok: false, out, err, why: e.code ?? e.message }));
|
||||
child.on("close", (code, signal) => resolve({ ok: code === 0, out, err, why: signal ? `killed by ${signal}` : err.trim().split("\n").pop() || `exit ${code}` }));
|
||||
child.on("error", (e) => settle({ ok: false, out, err, why: e.code ?? e.message }));
|
||||
child.on("close", (code, signal) => settle({ ok: code === 0, out, err, why: signal ? `killed by ${signal}` : err.trim().split("\n").pop() || `exit ${code}` }));
|
||||
});
|
||||
}
|
||||
// git with this call's env and limits, for the helpers below.
|
||||
const bound = (env, limits) => (args, opts = {}) => git(args, env, { ...opts, limits });
|
||||
|
||||
// The branch name a task's work goes to. A task ref holds a colon, which a
|
||||
// ref name can't.
|
||||
@@ -117,7 +142,7 @@ function locked(repo, fn) {
|
||||
|
||||
// The bare repository, made on first use in a temp directory beside it and
|
||||
// renamed into place. One that exists must be a real directory.
|
||||
async function ensure(repo, env) {
|
||||
async function ensure(repo, g) {
|
||||
try {
|
||||
if (!lstatSync(repo).isDirectory()) refuse("working-repo-invalid", `${repo} isn't a directory`);
|
||||
return;
|
||||
@@ -128,7 +153,7 @@ async function ensure(repo, env) {
|
||||
mkdirSync(dirname(repo), { recursive: true, mode: 0o700 });
|
||||
const tmp = join(dirname(repo), `.init-${randomBytes(4).toString("hex")}`);
|
||||
try {
|
||||
const r = await git(["init", "-q", "--bare", "--template=", tmp], env);
|
||||
const r = await g(["init", "-q", "--bare", "--template=", tmp]);
|
||||
if (!r.ok) refuse("working-repo-invalid", `git init failed: ${r.why}`);
|
||||
renameSync(tmp, repo);
|
||||
} finally {
|
||||
@@ -138,8 +163,8 @@ async function ensure(repo, env) {
|
||||
|
||||
// main from what the root has checked out. The root is the owner's; it isn't
|
||||
// a session's.
|
||||
async function seed(repo, root, env) {
|
||||
const r = await git([`--git-dir=${repo}`, "fetch", "-q", "--no-tags", "--no-write-fetch-head", "--no-recurse-submodules", "--no-auto-maintenance", root, "+HEAD:refs/heads/main"], env);
|
||||
async function seed(repo, root, g) {
|
||||
const r = await g([`--git-dir=${repo}`, "fetch", "-q", "--no-tags", "--no-write-fetch-head", "--no-recurse-submodules", "--no-auto-maintenance", root, "+HEAD:refs/heads/main"]);
|
||||
if (!r.ok) refuse("root-unreadable", `main couldn't be fetched from ${root}: ${r.why}`);
|
||||
}
|
||||
|
||||
@@ -153,68 +178,73 @@ function header(file) {
|
||||
}
|
||||
}
|
||||
|
||||
const tip = async (repo, ref, env) => {
|
||||
const r = await git([`--git-dir=${repo}`, "rev-parse", "-q", "--verify", `${ref}^{commit}`], env);
|
||||
const tip = async (repo, ref, g) => {
|
||||
const r = await g([`--git-dir=${repo}`, "rev-parse", "-q", "--verify", `${ref}^{commit}`]);
|
||||
return r.ok ? r.out.trim() : null;
|
||||
};
|
||||
|
||||
// await push({ dataRoot, business, project, root, taskRef, branch, bundle, record, env, fsck })
|
||||
// await push({ dataRoot, business, project, root, taskRef, branch, bundle, record, env, fsck, limits })
|
||||
// Resolves { ref, commit, previous }; rejects with WorkingError. `record`
|
||||
// runs under the lock once the ref moved, and a throw moves it back. `fsck`
|
||||
// is for the tests.
|
||||
export function push({ dataRoot, business, project, root, taskRef, branch, bundle, record = async () => {}, env = process.env, fsck = FSCK }) {
|
||||
// and `limits` are for the tests.
|
||||
export function push({ dataRoot, business, project, root, taskRef, branch, bundle, record = async () => {}, env = process.env, fsck = FSCK, limits = LIMITS }) {
|
||||
if (!PROJECT.test(project ?? "")) refuse("invalid-request", "not a project id");
|
||||
if (!validBranch(branch)) refuse("invalid-request", "not a branch name");
|
||||
const ref = `refs/heads/${workRef(taskRef)}`;
|
||||
const repo = workingRepo(dataRoot, business, project);
|
||||
const g = bound(env, limits);
|
||||
return locked(repo, async () => {
|
||||
if (!header(bundle)) refuse("invalid-bundle", "not a v2 git bundle");
|
||||
await ensure(repo, env);
|
||||
await seed(repo, root, env);
|
||||
await ensure(repo, g);
|
||||
await seed(repo, root, g);
|
||||
const at = [`--git-dir=${repo}`];
|
||||
// Not -q: with it, git 2.55 drops the list of missing prerequisites.
|
||||
const verify = await git([...at, "bundle", "verify", bundle], env);
|
||||
const verify = await g([...at, "bundle", "verify", bundle]);
|
||||
if (!verify.ok) {
|
||||
if (/lacks these prerequisite/.test(verify.err)) refuse("missing-prerequisite", "the bundle needs commits the working repository doesn't have");
|
||||
refuse("invalid-bundle", verify.why);
|
||||
}
|
||||
const heads = await git([...at, "bundle", "list-heads", bundle], env);
|
||||
const heads = await g([...at, "bundle", "list-heads", bundle]);
|
||||
if (!heads.ok) refuse("invalid-bundle", heads.why);
|
||||
if (!heads.out.split("\n").some((l) => l.split(" ")[1] === `refs/heads/${branch}`)) refuse("branch-not-in-bundle");
|
||||
const incoming = `refs/mosaic/incoming/${randomBytes(8).toString("hex")}`;
|
||||
try {
|
||||
const fetched = await git([...at, "fetch", "-q", "--no-tags", "--no-write-fetch-head", "--no-recurse-submodules", "--no-auto-maintenance", bundle, `refs/heads/${branch}:${incoming}`], env, { pre: fsck });
|
||||
const fetched = await g([...at, "fetch", "-q", "--no-tags", "--no-write-fetch-head", "--no-recurse-submodules", "--no-auto-maintenance", bundle, `refs/heads/${branch}:${incoming}`], { pre: fsck });
|
||||
if (!fetched.ok) refuse("invalid-bundle", fetched.why);
|
||||
const commit = await tip(repo, incoming, env);
|
||||
const commit = await tip(repo, incoming, g);
|
||||
if (!commit) refuse("invalid-bundle", "the branch isn't a commit");
|
||||
if (!(await git([...at, "merge-base", commit, "refs/heads/main"], env)).ok) refuse("unrelated-history", "the commit shares no history with main");
|
||||
const previous = await tip(repo, ref, env);
|
||||
if (!(await g([...at, "merge-base", commit, "refs/heads/main"])).ok) refuse("unrelated-history", "the commit shares no history with main");
|
||||
const previous = await tip(repo, ref, g);
|
||||
// From the repository into itself, by its path (`.` would be the cwd), and
|
||||
// not -q, which drops the "(non-fast-forward)" reason.
|
||||
const moved = await git([...at, "fetch", "--no-tags", "--no-write-fetch-head", "--no-auto-maintenance", repo, `${incoming}:${ref}`], env);
|
||||
const moved = await g([...at, "fetch", "--no-tags", "--no-write-fetch-head", "--no-auto-maintenance", repo, `${incoming}:${ref}`]);
|
||||
if (!moved.ok) refuse(/\(non-fast-forward\)/.test(moved.err) ? "non-fast-forward" : "push-failed", moved.why);
|
||||
try {
|
||||
await record({ ref: workRef(taskRef), commit, previous });
|
||||
} catch (e) {
|
||||
const back = previous ? ["update-ref", ref, previous, commit] : ["update-ref", "-d", ref, commit];
|
||||
await git([...at, ...back], env);
|
||||
const undone = await g([...at, ...back]);
|
||||
// The ref still names a commit the broker didn't record. That is the
|
||||
// operator's to fix, so it outranks record's own error.
|
||||
if (!undone.ok) refuse("rollback-failed", `${ref} still names ${commit}, which wasn't recorded (${e.code ?? e.message}); moving it back failed: ${undone.why}`);
|
||||
throw e;
|
||||
}
|
||||
return { ref: workRef(taskRef), commit, previous };
|
||||
} finally {
|
||||
await git([...at, "update-ref", "-d", incoming], env);
|
||||
await g([...at, "update-ref", "-d", incoming]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// await review({ dataRoot, business, project, root, taskRef, commit, have, out, env })
|
||||
// await review({ dataRoot, business, project, root, taskRef, commit, have, out, env, limits })
|
||||
// Writes a bundle of `commit` to `out`, with one ref, refs/mosaic/review.
|
||||
// `have` lists commits the reviewer's clone holds; those the repository
|
||||
// doesn't know are skipped. Resolves { ref, commit }.
|
||||
export function review({ dataRoot, business, project, root, taskRef, commit, have = [], out, env = process.env }) {
|
||||
export function review({ dataRoot, business, project, root, taskRef, commit, have = [], out, env = process.env, limits = LIMITS }) {
|
||||
if (!PROJECT.test(project ?? "") || !OID.test(commit ?? "")) refuse("invalid-request");
|
||||
if (!Array.isArray(have) || have.length > HAVE_MAX || have.some((h) => !OID.test(h ?? ""))) refuse("invalid-request", "have must list commit ids");
|
||||
const repo = workingRepo(dataRoot, business, project);
|
||||
const g = bound(env, limits);
|
||||
return locked(repo, async () => {
|
||||
try {
|
||||
if (!lstatSync(repo).isDirectory()) refuse("working-repo-invalid");
|
||||
@@ -222,18 +252,18 @@ export function review({ dataRoot, business, project, root, taskRef, commit, hav
|
||||
if (e instanceof WorkingError) throw e;
|
||||
refuse("unknown-commit", "the working repository doesn't exist");
|
||||
}
|
||||
await seed(repo, root, env);
|
||||
await seed(repo, root, g);
|
||||
const at = [`--git-dir=${repo}`];
|
||||
if (!(await tip(repo, commit, env))) refuse("unknown-commit");
|
||||
if (!(await tip(repo, commit, g))) refuse("unknown-commit");
|
||||
const known = [];
|
||||
for (const h of have) if (await tip(repo, h, env)) known.push(h);
|
||||
for (const h of have) if (await tip(repo, h, g)) known.push(h);
|
||||
const temp = "refs/mosaic/review";
|
||||
try {
|
||||
if (!(await git([...at, "update-ref", temp, commit], env)).ok) refuse("fetch-failed");
|
||||
const made = await git([...at, "bundle", "create", "-q", out, temp, ...(known.length ? ["--not", ...known] : [])], env);
|
||||
if (!(await g([...at, "update-ref", temp, commit])).ok) refuse("fetch-failed");
|
||||
const made = await g([...at, "bundle", "create", "-q", out, temp, ...(known.length ? ["--not", ...known] : [])]);
|
||||
if (!made.ok) refuse(/empty bundle/.test(made.err) ? "nothing-to-fetch" : "fetch-failed", made.why);
|
||||
} finally {
|
||||
await git([...at, "update-ref", "-d", temp], env);
|
||||
await g([...at, "update-ref", "-d", temp]);
|
||||
}
|
||||
return { ref: workRef(taskRef), commit };
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { gitEnv } from "../src/checkout.mjs";
|
||||
@@ -87,6 +87,34 @@ async function refuses(w, fn, code) {
|
||||
assert.doesNotMatch(w.refs(), /refs\/mosaic\//, "no temp ref is left");
|
||||
}
|
||||
|
||||
// The .lock files under a repository's refs.
|
||||
function locks(repo) {
|
||||
const found = [];
|
||||
const walk = (d) => {
|
||||
for (const name of readdirSync(d)) {
|
||||
const p = join(d, name);
|
||||
if (lstatSync(p).isDirectory()) walk(p);
|
||||
else if (name.endsWith(".lock")) found.push(p.slice(repo.length + 1));
|
||||
}
|
||||
};
|
||||
walk(repo);
|
||||
return found;
|
||||
}
|
||||
|
||||
// A `git` first in PATH that runs `body` (bash) with REAL as the real git,
|
||||
// and the env that puts it there.
|
||||
const REAL_GIT = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim();
|
||||
function wrapGit(w, body) {
|
||||
const bin = join(w.dir, "bin");
|
||||
mkdirSync(bin);
|
||||
writeFileSync(join(bin, "git"), `#!/bin/bash
|
||||
REAL='${REAL_GIT}'
|
||||
${body}
|
||||
`);
|
||||
chmodSync(join(bin, "git"), 0o755);
|
||||
return { ...process.env, PATH: `${bin}:${process.env.PATH}` };
|
||||
}
|
||||
|
||||
async function lands(w, fn) {
|
||||
const root = fingerprint(w.root);
|
||||
const r = await fn();
|
||||
@@ -236,6 +264,85 @@ test("a record that throws moves the work ref back, or removes a new one", async
|
||||
assert.equal(w.refs(), `refs/heads/main ${w.first}\n${REF} ${c1}`);
|
||||
});
|
||||
|
||||
test("a work ref that can't go back refuses rollback-failed, naming the ref and the commit", async (t) => {
|
||||
const w = world(t);
|
||||
git(w.clone, "checkout", "-q", "-b", "feat");
|
||||
const c1 = commit(w.clone, "two");
|
||||
// record() fails after taking the ref's lock, so moving the ref back fails.
|
||||
const lock = join(w.repo, `${REF}.lock`);
|
||||
const stuck = async () => {
|
||||
writeFileSync(lock, "");
|
||||
throw new Error("not recorded");
|
||||
};
|
||||
const message = (c) => (e) => e instanceof WorkingError && e.code === "rollback-failed" && e.message.includes(`${REF} still names ${c}`) && /not recorded/.test(e.message);
|
||||
// A new ref that can't be removed.
|
||||
await assert.rejects(w.go(w.bundle(), { record: stuck }), message(c1));
|
||||
assert.equal(w.refs(), `refs/heads/main ${w.first}\n${REF} ${c1}`);
|
||||
rmSync(lock);
|
||||
// A ref that can't go back to the one before.
|
||||
git(w.clone, "update-ref", "refs/remotes/working/work/vikunja-1-2", c1);
|
||||
const c2 = commit(w.clone, "three");
|
||||
await assert.rejects(w.go(w.bundle(), { record: stuck }), message(c2));
|
||||
assert.equal(w.refs(), `refs/heads/main ${w.first}\n${REF} ${c2}`);
|
||||
rmSync(lock);
|
||||
assert.deepEqual(locks(w.repo), []);
|
||||
});
|
||||
|
||||
test("a root that can't be read refuses root-unreadable", async (t) => {
|
||||
const w = world(t);
|
||||
git(w.clone, "checkout", "-q", "-b", "feat");
|
||||
commit(w.clone, "two");
|
||||
await refuses(w, () => w.go(w.bundle(), { root: join(w.dir, "no-root") }), "root-unreadable");
|
||||
});
|
||||
|
||||
test("a git past its time gets SIGTERM, leaves no ref lock, and the next push lands", async (t) => {
|
||||
const w = world(t);
|
||||
git(w.clone, "checkout", "-q", "-b", "feat");
|
||||
const c1 = commit(w.clone, "two");
|
||||
await w.go(w.bundle());
|
||||
git(w.clone, "update-ref", "refs/remotes/working/work/vikunja-1-2", c1);
|
||||
// The move of the work ref holds the ref's lock in a reference-transaction
|
||||
// hook until the lock is gone, so the git is still holding it when its time
|
||||
// is up. Only that one fetch gets the hook.
|
||||
const hooks = join(w.dir, "hooks");
|
||||
mkdirSync(hooks);
|
||||
const lock = join(w.repo, `${REF}.lock`);
|
||||
writeFileSync(join(hooks, "reference-transaction"), `#!/bin/bash\n[ "$1" = prepared ] || exit 0\nfor i in $(seq 200); do [ -e '${lock}' ] || exit 0; sleep 0.05; done\n`);
|
||||
chmodSync(join(hooks, "reference-transaction"), 0o755);
|
||||
const env = wrapGit(
|
||||
w,
|
||||
`self=0
|
||||
for a in "$@"; do case "$a" in refs/mosaic/incoming/*:refs/heads/work/*) self=1;; esac; done
|
||||
args=()
|
||||
for a in "$@"; do
|
||||
[ $self = 1 ] && [ "$a" = core.hooksPath=/dev/null ] && a='core.hooksPath=${hooks}'
|
||||
args+=("$a")
|
||||
done
|
||||
exec "$REAL" "\${args[@]}"`,
|
||||
);
|
||||
commit(w.clone, "three");
|
||||
const limits = { timeoutMs: 2000, graceMs: 5000 };
|
||||
await assert.rejects(w.go(w.bundle(), { env, limits }), (e) => e instanceof WorkingError && e.code === "push-failed" && /killed by SIGTERM/.test(e.message));
|
||||
assert.deepEqual(locks(w.repo), []);
|
||||
assert.equal(w.refs(), `refs/heads/main ${w.first}\n${REF} ${c1}`);
|
||||
const c3 = commit(w.clone, "four");
|
||||
const r = await lands(w, () => w.go(w.bundle()));
|
||||
assert.deepEqual(r, { ref: "work/vikunja-1-2", commit: c3, previous: c1 });
|
||||
});
|
||||
|
||||
test("a git that ignores SIGTERM gets SIGKILL once the grace period is over", { timeout: 20000 }, async (t) => {
|
||||
const w = world(t);
|
||||
git(w.clone, "checkout", "-q", "-b", "feat");
|
||||
commit(w.clone, "two");
|
||||
// The fetch of main from the root never ends and ignores SIGTERM.
|
||||
const env = wrapGit(
|
||||
w,
|
||||
`for a in "$@"; do [ "$a" = +HEAD:refs/heads/main ] && { trap '' TERM; while :; do sleep 0.05; done; }; done
|
||||
exec "$REAL" "$@"`,
|
||||
);
|
||||
await assert.rejects(w.go(w.bundle(), { env, limits: { timeoutMs: 500, graceMs: 500 } }), (e) => e instanceof WorkingError && e.code === "root-unreadable" && /killed by SIGKILL/.test(e.message));
|
||||
});
|
||||
|
||||
test("a working repository path that isn't a directory refuses", async (t) => {
|
||||
const w = world(t);
|
||||
git(w.clone, "checkout", "-q", "-b", "feat");
|
||||
|
||||
Reference in New Issue
Block a user