openclaw/test/scripts/ci-platform-checkout.test.ts
Vincent Koc b9cd492ac6
fix(tooling): enforce Linux process-tree memory limits (#160024)
Add opt-in aggregate Linux process-tree memory containment to the managed tooling runner. Own the invocation-specific scope through verified cleanup before releasing its resource claim; preserve uncapped caller behavior.

Validated real-kernel OOM, descendant cleanup, signals, cancellation and packaged launcher behavior; focused lifecycle/preload tests and exact-head CI/security gates passed. Related: #160010.
2026-09-28 11:58:07 +08:00

1528 lines
63 KiB
TypeScript

import { execFileSync, spawnSync } from "node:child_process";
import {
chmodSync,
existsSync,
mkdirSync,
readFileSync,
readlinkSync,
realpathSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expectDefined } from "@openclaw/normalization-core";
import { beforeAll, expect, it, vi } from "vitest";
import { spawnOwnedVitestProcess } from "../../scripts/lib/vitest-process.mts";
import { isProcessAlive, waitForDead } from "../helpers/process-wait.js";
import {
ciCheckoutFixture,
expectCiCheckoutCleanup,
readCiCheckoutStep,
renderGitTestClock,
withCiCheckoutFixture,
} from "./ci-checkout.test-support.js";
import { runCiGitStep } from "./ci-git-owner.test-support.js";
import {
censusPreload,
expectCensusClosed,
registerWindowsCensusTests,
} from "./ci-windows-process-census.test-support.js";
// Each case owns its checkout and process trees. Overlap their real deadline
// and drain waits while keeping subprocess pressure bounded within one worker.
beforeAll(() => {
vi.setConfig({ maxConcurrency: 2 });
return () => vi.resetConfig();
});
function expectedHarnessSparseCheckoutArgs(linux: boolean) {
return [
"sparse-checkout",
"set",
"--no-cone",
"/.github/actions/",
"/scripts/lib/pnpm-lockfile-documents.mjs",
"/scripts/ios-screenshot-evidence.mjs",
"/scripts/lib/direct-run.mjs",
"/scripts/ci-static-step.sh",
...(linux
? [
"/scripts/lib/release-upgrade-baseline.mjs",
"/scripts/lib/release-version.mjs",
"/scripts/ci-npm-lock-admission.mjs",
"/scripts/generate-npm-package-lock.mjs",
"/scripts/generate-npm-package-lock.mts",
"/scripts/changed-lanes.mts",
"/scripts/lib/merge-head-diff-base.mjs",
]
: ["/scripts/lib/swift-toolchain.sh"]),
];
}
// Execute both workflow policies against the same owned tree fixture. A leader's
// exit must not authorize workspace deletion, Git reuse, or final success.
const platformCases = [
{ scenario: "timeouts-exhausted", attempts: 3, code: 124, checkout: false },
{ scenario: "recovery", attempts: 4, code: 0, checkout: true },
{ scenario: "early-leader-exit", attempts: 2, code: 0, checkout: true },
{ scenario: "harness-timeout", attempts: 2, code: 124, checkout: true },
{ scenario: "git-failure", attempts: 1, code: 23, checkout: false },
{ scenario: "git-exit-124", attempts: 1, code: 124, checkout: false },
{ scenario: "pre-existing-lock", attempts: 1, code: 128, checkout: false },
// Windows has no POSIX signals/ps boundary; native Job cancellation proof is separate.
...(process.platform === "win32" ? [] : ["SIGTERM", "SIGINT", "SIGHUP"]).map((signal, index) => ({
scenario: `cancel-${signal}`,
attempts: 1,
code: [143, 130, 129][index],
checkout: false,
})),
...(process.platform === "win32"
? []
: [{ scenario: "cleanup-failure", attempts: 1, code: 125, checkout: false }]),
];
const linuxCases =
process.platform === "win32"
? []
: [
{ scenario: "timeouts-exhausted", attempts: 5, code: 1, checkout: false, deletions: 5 },
{ scenario: "recovery", attempts: 4, code: 0, checkout: true, deletions: 3 },
{ scenario: "early-leader-exit", attempts: 2, code: 0, checkout: true, deletions: 1 },
{ scenario: "git-failure", attempts: 5, code: 1, checkout: false, deletions: 5 },
{ scenario: "checkout-failure", attempts: 5, code: 1, checkout: true, deletions: 5 },
{ scenario: "harness-recovery", attempts: 4, code: 0, checkout: true, deletions: 2 },
{ scenario: "cancel-SIGTERM", attempts: 1, code: 143, checkout: false, deletions: 1 },
{ scenario: "cleanup-failure", attempts: 1, code: 125, checkout: false, deletions: 1 },
{ scenario: "non-executable-git", attempts: 0, code: null, checkout: false, deletions: 0 },
{ scenario: "non-executable-find", attempts: 0, code: null, checkout: false, deletions: 0 },
];
it.concurrent.each([
...platformCases.map((entry) => Object.assign(entry, { linux: false, deletions: 0 })),
...linuxCases.map((entry) => Object.assign(entry, { linux: true })),
])(
"preserves checkout ownership and fixture isolation (Linux=$linux, $scenario)",
async ({ scenario, attempts, code, checkout, linux, deletions }) => {
const setupFailure = scenario.startsWith("non-executable-");
const run = readCiCheckoutStep(linux ? "checks-fast-core" : "checks-windows").run;
const policyScenario = `${linux ? "linux:" : ""}${scenario}`;
await withCiCheckoutFixture(
policyScenario,
(root) => {
const workspace = path.join(root, "workspace");
if (scenario === "cancel-SIGTERM") {
// One slow-start proof per policy; all signals share the same readiness path.
writeFileSync(
path.join(root, "fixture-config.json"),
JSON.stringify({ initDelayMs: 4_100 }),
);
}
if (linux) {
writeFileSync(path.join(workspace, ".previous-checkout"), "stale\n");
}
if (scenario === "recovery") {
// Reproduce startup beyond the old wall-clock budget without delaying other consumers.
writeFileSync(path.join(root, "tree-start-delay-3.json"), "2100");
}
if (scenario === "git-exit-124") {
// Slow child startup must not replace Git's injected exit with a fixture timeout.
writeFileSync(path.join(root, "tree-start-delay-1.json"), "4100");
}
const accelerated = renderGitTestClock(run, { realDrain: scenario.startsWith("cancel-") });
expect(accelerated).not.toBe(run);
// A broken preflight must never let these negative fixture tests run real Git.
writeFileSync(
path.join(root, "checkout.sh"),
setupFailure ? "printf 'unexpected workflow invocation\\n' >&2\nexit 99\n" : accelerated,
);
if (process.platform === "win32" || scenario === "git-exit-124") {
return censusPreload(
root,
scenario === "git-exit-124"
? String.raw`
if (process.argv[2] === "supervise") {
const launch = cp.spawn;
cp.spawn = (...args) => {
const child = launch(...args);
if (args[1]?.[1] === "sentinel") {
child.kill = () => true;
child.once("close", (code, signal) => {
fs.writeFileSync(path.join(root, "lease-actor-close.json"), JSON.stringify({ code, signal }));
});
}
return child;
};
}
if (process.argv[2] === "sentinel") {
const read = fs.readFileSync;
fs.readFileSync = (filename, ...args) => {
try {
return read(filename, ...args);
} catch (error) {
if (filename === path.join(root, "lease") && ["ENOENT", "EPERM"].includes(error.code)) {
fs.writeFileSync(path.join(root, "lease-read-denied.json"), JSON.stringify("EPERM"));
error.code = "EPERM";
}
throw error;
}
};
}
syncFixtureBuiltinExports();
`
: "",
["timeouts-exhausted", "recovery", "early-leader-exit", "harness-timeout"].includes(
scenario,
),
);
}
return undefined;
},
(report, result, stderr, root) => {
const workspace = path.join(root, "workspace");
// Emit evidence before assertions; it remains available even for this deliberately red test.
console.log(`${scenario}: ${JSON.stringify(report)}`);
if (setupFailure) {
expect(report.cleanupRemaining, "fixture cleanup left owned processes").toEqual([]);
expect(report.error, report.output).toContain(
"Fixture setup: mock command resolution failed",
);
expect(report.error).toContain(scenario.slice("non-executable-".length));
expect(result, stderr).toEqual({ code: 1, signal: null });
expect(report.code).toBeNull();
expect(report.output).toBe("");
expect(report.commands).toEqual([]);
expect(report.boundaries).toEqual([]);
return;
}
expect(result, stderr).toEqual({ code: 0, signal: null });
expect(report.error, stderr).toBeUndefined();
expectCiCheckoutCleanup(report);
expectCensusClosed(
root,
report.ownedProcesses.map((entry) => entry.pid),
);
expect(report.code).toBe(code);
expect(readFileSync(path.join(workspace, ".git/preexisting.lock"), "utf8")).toBe(
"not invocation-owned\n",
);
if (scenario === "pre-existing-lock") {
expect(readFileSync(path.join(workspace, ".git/shallow.lock"), "utf8")).toBe(
"not invocation-owned\n",
);
}
if (scenario === "recovery") {
for (let attempt = 1; attempt <= attempts; attempt++) {
expect(
readFileSync(path.join(root, "shared-git-cache", `${attempt}.lock`), "utf8"),
).toBe("outside Git ownership\n");
}
}
if (scenario === "git-exit-124") {
expect(report.output).toBe("");
expect(JSON.parse(readFileSync(path.join(root, "lease-read-denied.json"), "utf8"))).toBe(
"EPERM",
);
expect(
JSON.parse(readFileSync(path.join(root, "lease-actor-close.json"), "utf8")),
).toEqual({
code: 0,
signal: null,
});
}
const readyAttempts =
scenario === "pre-existing-lock" ? [] : Array.from({ length: attempts }, (_, i) => i + 1);
expect(report.readyAttempts).toEqual(readyAttempts);
expect(report.boundaries.filter((entry) => entry.name.startsWith("fetch:"))).toHaveLength(
attempts,
);
expect(report.boundaries.some((entry) => entry.name === "checkout")).toBe(checkout);
expect(report.boundaries.filter((entry) => entry.name === "delete")).toHaveLength(
deletions,
);
expect(report.output.includes("refusing reuse or retry")).toBe(
scenario === "cleanup-failure",
);
if (scenario.startsWith("cancel-")) {
const alive = report.ownedProcesses.filter((entry) => entry.attempt === 1);
expect(alive.map((entry) => entry.role).toSorted()).toEqual([
"child",
"grandchild",
"parent",
]);
const owner = expectDefined(
report.ownedProcesses.find((entry) => entry.role === "shell"),
"workflow owner",
);
expect(owner.pid).toBeGreaterThan(1);
const signal = scenario.slice("cancel-".length);
expect(report.output).toContain(
`cancellation: ${JSON.stringify({ signal, owner: owner.pid, alive })}\n`,
);
}
if (code === 0) {
const fetches = report.commands.filter(({ args }) => args.includes("fetch"));
const candidateFetch = expectDefined(fetches[0], "candidate fetch");
expect(candidateFetch.args).toContain(
`+${"a".repeat(40)}:refs/remotes/origin/${linux ? "ci-target" : "checkout"}`,
);
expect(
candidateFetch.args.includes(`+${"c".repeat(40)}:refs/remotes/origin/ci-ratchet-base`),
).toBe(linux && scenario === "early-leader-exit");
if (linux) {
expect(
report.commands.filter(
({ args }) =>
args.join(" ") === `config --global --add safe.directory ${workspace}`,
),
).toHaveLength(deletions);
expect(
report.commands
.filter(({ cwd, args }) => cwd === workspace && args[0] === "checkout")
.every(
({ args }) => args.join(" ") === `checkout --force --detach ${"a".repeat(40)}`,
),
).toBe(true);
}
expect(candidateFetch.cwd).toBe(workspace);
expect(fetches.at(-1)?.cwd).toBe(path.join(workspace, ".ci-harness"));
for (const { args } of fetches) {
expect(args).toEqual(
expect.arrayContaining(["--no-tags", "--no-recurse-submodules", "--depth=1"]),
);
}
expect(fetches.at(-1)?.args).toContain(
`+${"b".repeat(40)}:refs/remotes/origin/ci-harness`,
);
expect(report.commands.find(({ args }) => args[0] === "sparse-checkout")?.args).toEqual(
expectedHarnessSparseCheckoutArgs(linux),
);
expect(report.commands.at(-1)?.args).toEqual([
"checkout",
"--force",
"--detach",
"b".repeat(40),
]);
}
},
);
},
55_000,
);
it.concurrent.each([
...[
...(process.platform === "win32" ? [] : [{ kind: "linux-node", retained: false }]),
...(process.platform === "win32"
? []
: [{ kind: "linux-node", retained: false, workflow: "previous", fetches: 2 }]),
{ kind: "platform", retained: false },
{ kind: "platform", retained: true },
{ kind: "platform", retained: false, workflow: "previous", fetches: 2 },
].map((entry) =>
Object.assign(
{
event: "push",
workflow: "same",
target: "selected",
code: 0,
fetches: 1,
},
entry,
),
),
...(process.platform === "win32"
? []
: [
{ event: "push", workflow: "same", target: "selected", code: 0, fetches: 2 },
{ event: "pull_request", workflow: "same", target: "selected", code: 0, fetches: 2 },
{ event: "pull_request", workflow: "previous", target: "selected", code: 0, fetches: 2 },
{
event: "workflow_dispatch",
workflow: "previous",
target: "selected",
code: 0,
fetches: 2,
},
{
event: "workflow_dispatch",
workflow: "previous",
target: "missing-branch",
code: 0,
fetches: 3,
},
{ event: "pull_request", workflow: "missing", target: "selected", code: 0, fetches: 2 },
{ event: "push", workflow: "missing-action", target: "selected", code: 1, fetches: 2 },
{
event: "workflow_dispatch",
workflow: "previous",
target: "moved-event",
code: 0,
fetches: 3,
},
{ event: "push", workflow: "same", target: "missing-sha", code: 128, fetches: 1 },
{
event: "workflow_dispatch",
workflow: "previous",
target: "missing-sha",
code: 1,
fetches: 2,
},
].map((entry) => Object.assign(entry, { kind: "preflight", retained: false }))),
])(
"materializes $kind trusted harness ($event, workflow=$workflow, target=$target, retained=$retained) without mutating the candidate",
async ({ kind, retained, event, workflow, target, code, fetches }) => {
const linux = kind !== "platform";
const preflight = kind === "preflight";
const posix = process.platform !== "win32";
const action = ".github/actions/setup-node-env/action.yml";
const executable = ".github/actions/tool/line\nbreak.sh";
const link = ".github/actions/tool/link";
const files = {
[action]: "name: trusted $Format:%H$\n",
".github/actions/setup-node-env/dependency-fingerprint.mjs": readFileSync(
".github/actions/setup-node-env/dependency-fingerprint.mjs",
"utf8",
),
".github/actions/tool/with space.txt": "literal action bytes\n",
...(posix ? { [executable]: "#!/bin/sh\nexit 0\n" } : {}),
};
const evidenceScripts = {
"scripts/ios-screenshot-evidence.mjs": "workflow evidence script\n",
"scripts/lib/direct-run.mjs": "workflow direct-run script\n",
"scripts/ci-static-step.sh": "workflow static-step script\n",
};
const nodeSetupScripts = {
"scripts/lib/pnpm-lockfile-documents.mjs": readFileSync(
"scripts/lib/pnpm-lockfile-documents.mjs",
"utf8",
),
};
const platformScripts = {
"scripts/lib/swift-toolchain.sh": "workflow Swift toolchain helper\n",
};
const releasePolicy = Object.fromEntries(
[
"scripts/lib/release-context.mjs",
"scripts/lib/release-version.mjs",
"scripts/lib/release-upgrade-baseline.mjs",
].map((name) => [name, readFileSync(name, "utf8")]),
);
const candidateFiles = {
"candidate-only.txt": "candidate stays intact\n",
"package.json": '{"name":"checkout-fixture"}\n',
"pnpm-lock.yaml": "---\nlockfileVersion: '9.0'\n---\nimporters:\n .: {}\n",
"extensions/browser/icon.png": "complete binary path\0\xff",
"ui/src/i18n/.i18n/de-DE.tm.jsonl": '{"fixture":"complete inventory"}\n',
"scripts/lib/candidate-only.mjs": "export const candidate = true;\n",
};
let revision = "";
let workflowRevision = "";
let candidateAction = files[action];
let candidateEvidenceScripts: Record<string, string> = evidenceScripts;
let candidatePlatformScripts: Record<string, string> = platformScripts;
const existingExcludes = retained
? "/saved-artifact/\n/.ci-harness/\n"
: "# Existing local excludes\r\n/saved-artifact/";
let readSourceStatus: (() => string[]) | undefined;
await withCiCheckoutFixture(
`${linux ? "linux:" : ""}configured`,
(root) => {
const source = path.join(root, "source");
mkdirSync(source);
const git = execFileSync(process.platform === "win32" ? "where.exe" : "which", ["git"], {
encoding: "utf8",
})
.trim()
.split(/\r?\n/u)[0];
const gitConfig = path.join(root, "gitconfig");
writeFileSync(gitConfig, "");
const gitTemplate = path.join(root, "git-template");
mkdirSync(path.join(gitTemplate, "info"), { recursive: true });
writeFileSync(path.join(gitTemplate, "info/exclude"), existingExcludes);
const gitEnv = {
GIT_CONFIG_NOSYSTEM: "1",
GIT_CONFIG_GLOBAL: gitConfig,
GIT_TEMPLATE_DIR: gitTemplate,
GIT_TERMINAL_PROMPT: "0",
GIT_AUTHOR_NAME: "Checkout fixture",
GIT_AUTHOR_EMAIL: "checkout@example.invalid",
GIT_COMMITTER_NAME: "Checkout fixture",
GIT_COMMITTER_EMAIL: "checkout@example.invalid",
};
readSourceStatus = () =>
execFileSync(
expectDefined(git, "real Git executable"),
["-C", path.join(root, "workspace"), "status", "--porcelain", "--untracked-files=all"],
{
env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, ...gitEnv },
encoding: "utf8",
},
)
.split(/\r?\n/u)
.filter(Boolean);
const run = (...args: string[]) =>
execFileSync(expectDefined(git, "real Git executable"), ["-C", source, ...args], {
env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, ...gitEnv },
encoding: "utf8",
}).trim();
run("init");
for (const [name, contents] of Object.entries({
...files,
...evidenceScripts,
...nodeSetupScripts,
...platformScripts,
...releasePolicy,
...candidateFiles,
})) {
mkdirSync(path.dirname(path.join(source, name)), { recursive: true });
writeFileSync(path.join(source, name), contents);
}
// Archive export would omit or rewrite these trusted action bytes.
writeFileSync(path.join(source, ".gitattributes"), "* -text export-ignore export-subst\n");
if (posix) {
chmodSync(path.join(source, executable), 0o755);
symlinkSync("line\nbreak.sh", path.join(source, link));
}
if (workflow === "missing-action") {
rmSync(path.join(source, action));
}
run("add", "--all");
run("commit", "--no-gpg-sign", "-m", "fixture revision");
revision = run("rev-parse", "HEAD");
workflowRevision = revision;
if (workflow === "previous") {
candidateAction = "name: candidate action must not replace the trusted workflow\n";
writeFileSync(path.join(source, action), candidateAction);
candidateEvidenceScripts = Object.fromEntries(
Object.keys(evidenceScripts).map((name) => [
name,
`candidate ${path.basename(name)} must not replace the trusted workflow\n`,
]),
);
for (const [name, contents] of Object.entries(candidateEvidenceScripts)) {
writeFileSync(path.join(source, name), contents);
}
candidatePlatformScripts = Object.fromEntries(
Object.keys(platformScripts).map((name) => [
name,
"candidate Swift toolchain helper\n",
]),
);
for (const [name, contents] of Object.entries(candidatePlatformScripts)) {
writeFileSync(path.join(source, name), contents);
}
for (const name of [...Object.keys(releasePolicy), ...Object.keys(nodeSetupScripts)]) {
writeFileSync(path.join(source, name), "throw new Error('candidate policy');\n");
}
run(
"add",
action,
...Object.keys(evidenceScripts),
...Object.keys(nodeSetupScripts),
...Object.keys(platformScripts),
...Object.keys(releasePolicy),
);
run("commit", "--no-gpg-sign", "-m", "selected candidate");
revision = run("rev-parse", "HEAD");
} else if (workflow === "missing") {
workflowRevision = "f".repeat(40);
}
if (target === "moved-event") {
run("branch", "event", workflowRevision);
}
if (retained) {
const staleAction = path.join(root, "workspace", ".ci-harness", action);
mkdirSync(path.dirname(staleAction), { recursive: true });
writeFileSync(staleAction, "name: stale platform action\n");
}
writeFileSync(
path.join(root, "fixture-options.json"),
JSON.stringify({
localGit: { git, remote: source },
fetchResults: [0, 0],
cooperativeTrees: true,
env: {
...gitEnv,
CHECKOUT_KIND: kind,
CHECKOUT_SHA: revision,
CHECKOUT_REF:
target === "missing-branch"
? "refs/heads/missing"
: target === "missing-sha"
? "f".repeat(40)
: revision,
CHECKOUT_FALLBACK_REF: revision,
CHECKOUT_EVENT_REF: target === "moved-event" ? "refs/heads/event" : "",
GITHUB_EVENT_NAME: event,
GITHUB_REPOSITORY: "fixture/checkout",
CHECKOUT_TOKEN: "fixture-read-only-token",
WORKFLOW_SHA: workflowRevision,
},
}),
);
writeFileSync(
path.join(root, "checkout.sh"),
renderGitTestClock(
readCiCheckoutStep(
preflight ? "preflight" : linux ? "checks-fast-core" : "checks-windows",
).run,
{
realClock: true,
},
),
);
},
(report, result, stderr, root) => {
expect(result, `${stderr}\n${report.output}`).toEqual({ code: 0, signal: null });
expect(report.error, report.output).toBeUndefined();
expectCiCheckoutCleanup(report);
expect(report.code, report.output).toBe(code);
expect(report.commands.filter(({ args }) => args[0] === "fetch")).toHaveLength(fetches);
const workspace = path.join(root, "workspace");
const harness = path.join(workspace, ".ci-harness");
if (target === "missing-sha") {
expect(existsSync(path.join(root, "candidate-index"))).toBe(false);
expect(existsSync(harness)).toBe(false);
return;
}
expect(readFileSync(path.join(workspace, ".git/index"))).toEqual(
readFileSync(path.join(root, "candidate-index")),
);
expect(readFileSync(path.join(workspace, ".git/HEAD"), "utf8").trim()).toBe(revision);
expect(readFileSync(path.join(workspace, ".git/config"), "utf8")).not.toContain(
"AUTHORIZATION",
);
for (const [name, contents] of Object.entries(candidateFiles)) {
expect(readFileSync(path.join(workspace, name), "utf8")).toBe(contents);
expect(existsSync(path.join(harness, name))).toBe(false);
}
const workflowOwnsEvidence = kind === "platform" || kind === "linux-node";
for (const name of Object.keys(evidenceScripts)) {
expect(readFileSync(path.join(workspace, name), "utf8")).toBe(
candidateEvidenceScripts[name],
);
}
for (const name of Object.keys(platformScripts)) {
expect(readFileSync(path.join(workspace, name), "utf8")).toBe(
candidatePlatformScripts[name],
);
}
if (workflow === "missing-action") {
expect(existsSync(path.join(workspace, action))).toBe(false);
expect(existsSync(path.join(harness, action))).toBe(false);
return;
}
expect(readFileSync(path.join(workspace, action), "utf8")).toBe(candidateAction);
if (preflight && workflow !== "same") {
// A different workflow revision stays with the pinned Actions checkout.
expect(existsSync(harness)).toBe(false);
return;
}
const sourceStatus = expectDefined(readSourceStatus, "native source status");
expect(sourceStatus()).toEqual([]);
expect(readFileSync(path.join(workspace, ".git/info/exclude"), "utf8")).toBe(
retained ? existingExcludes : `${existingExcludes}\n/.ci-harness/\n`,
);
if (workflow === "same") {
expect(existsSync(path.join(harness, ".git"))).toBe(false);
}
for (const [name, contents] of Object.entries(files)) {
expect(readFileSync(path.join(harness, name), "utf8")).toBe(contents);
}
const fingerprint = spawnSync(
process.execPath,
[
path.join(harness, ".github/actions/setup-node-env/dependency-fingerprint.mjs"),
"--workspace",
workspace,
"--frozen-lockfile",
"true",
],
{ cwd: workspace, encoding: "utf8" },
);
expect(fingerprint.status, fingerprint.stderr).toBe(0);
expect(fingerprint.stdout.trim()).toMatch(/^v2-[a-f0-9]{64}$/u);
for (const [name, contents] of Object.entries(evidenceScripts)) {
expect(existsSync(path.join(harness, name))).toBe(workflowOwnsEvidence);
if (workflowOwnsEvidence) {
expect(readFileSync(path.join(harness, name), "utf8")).toBe(contents);
}
}
for (const [name, contents] of Object.entries(platformScripts)) {
expect(existsSync(path.join(harness, name)), name).toBe(kind === "platform");
if (kind === "platform") {
expect(readFileSync(path.join(harness, name), "utf8")).toBe(contents);
}
}
for (const [name, contents] of Object.entries(releasePolicy)) {
const ownsPolicy = preflight
? name !== "scripts/lib/release-upgrade-baseline.mjs"
: kind === "linux-node" && name !== "scripts/lib/release-context.mjs";
expect(existsSync(path.join(harness, name))).toBe(ownsPolicy);
if (ownsPolicy) {
expect(readFileSync(path.join(harness, name), "utf8")).toBe(contents);
writeFileSync(path.join(workspace, name), "throw new Error('candidate policy');\n");
expect(readFileSync(path.join(harness, name), "utf8")).toBe(contents);
}
}
if (kind === "linux-node") {
const versions = path.join(root, "published-versions.json");
writeFileSync(versions, JSON.stringify(["2026.9.1", "2026.9.2", "2026.9.3"]));
const resolved = spawnSync(
process.execPath,
[
path.join(harness, "scripts/lib/release-upgrade-baseline.mjs"),
"--candidate-version",
"2026.9.3",
"--versions-json",
versions,
],
{ cwd: workspace, encoding: "utf8" },
);
expect(resolved.status, resolved.stderr).toBe(0);
expect(resolved.stdout.trim()).toBe("openclaw@2026.9.2");
}
if (posix) {
// Git tracks only executable state; checkout materialization applies the process umask.
expect(statSync(path.join(harness, executable)).mode & 0o111).not.toBe(0);
expect(readlinkSync(path.join(harness, link))).toBe("line\nbreak.sh");
}
if (workflow !== "same" && workflowOwnsEvidence) {
expect(report.commands.find(({ args }) => args[0] === "sparse-checkout")?.args).toEqual(
expectedHarnessSparseCheckoutArgs(linux),
);
}
writeFileSync(path.join(workspace, action), "later candidate edit\n");
expect(readFileSync(path.join(harness, action), "utf8")).toBe(files[action]);
for (const name of [
"saved-artifact/ignored.txt",
"nested/.ci-harness/source.ts",
"untracked-source.ts",
]) {
mkdirSync(path.dirname(path.join(workspace, name)), { recursive: true });
writeFileSync(path.join(workspace, name), "later source or artifact\n");
}
const dirty = sourceStatus();
expect(dirty).toContain(` M ${action}`);
expect(dirty.filter((line) => line.startsWith("?? "))).toEqual([
"?? nested/.ci-harness/source.ts",
"?? untracked-source.ts",
]);
},
);
},
55_000,
);
registerWindowsCensusTests();
it.each(["prepare", "inspect"])(
"removes checkout artifacts after %s assertion failure",
async (phase) => {
let root: string | undefined;
await expect(
withCiCheckoutFixture(
"early-leader-exit",
(directory) => {
root = directory;
expect(phase, "injected prepare assertion").not.toBe("prepare");
writeFileSync(path.join(directory, "checkout.sh"), "exit 0\n");
},
(report, result, stderr) => {
expect(result, stderr).toEqual({ code: 0, signal: null });
expectCiCheckoutCleanup(report);
expect(report.code, "injected inspect assertion").toBe(99);
},
),
).rejects.toThrow(`injected ${phase} assertion`);
expect(existsSync(expectDefined(root, "created checkout root"))).toBe(false);
},
55_000,
);
it.skipIf(process.platform === "win32").each(["census", "corrupt-report", "timeout"])(
"retains checkout artifacts across failed outer-runner cleanup (%s)",
async (fault) => {
const preload = String.raw`
import cp from "node:child_process";
import fs from "node:fs";
import { syncFixtureBuiltinExports } from ${JSON.stringify(new URL("./fixtures/ci-fixture-runtime.cjs", import.meta.url).href)};
import path from "node:path";
if (process.argv[2] === "sentinel" && fault === "timeout") {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0);
}
if (process.argv[2] === "supervise") {
const root = process.argv[3], children = [], pending = new Set();
const spawn = cp.spawn, spawnSync = cp.spawnSync, renameSync = fs.renameSync;
cp.spawn = (...args) => {
const child = spawn(...args);
children.push(child.pid);
pending.add(child);
fs.writeFileSync(path.join(root, "creator-pids.json"), JSON.stringify([process.pid, ...children]));
child.once("close", () => pending.delete(child));
if (fault === "timeout") {
// Notify after spawn returns and the fixture installs direct-child tracking.
// Flush IPC before stalling; sentinel registration is deliberately blocked.
queueMicrotask(() => {
process.send({ type: "ci-checkout:sentinel-created", pids: [process.pid, child.pid] }, error => {
if (error) throw error;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0);
});
});
}
return child;
};
cp.spawnSync = (...args) => {
if (fault === "census" && args[0] === "/bin/ps" && children.length === 2 && pending.size === 0) {
fs.writeFileSync(path.join(root, "closed-before-census.json"), JSON.stringify(children));
throw new Error("injected final census failure after direct child close");
}
return spawnSync(...args);
};
fs.renameSync = (...args) => {
const result = renameSync(...args);
if (fault === "corrupt-report" && args[1] === path.join(root, "report.json")) {
fs.writeFileSync(args[1], "null");
}
return result;
};
syncFixtureBuiltinExports();
}
`;
// Use the actual outer namespace owner, including its cleanup on exit code 1.
const { child, completion } = spawnOwnedVitestProcess({
command: process.execPath,
args: [
"--input-type=module",
"-e",
String.raw`
import assert from "node:assert/strict";
import cp from "node:child_process";
import fs from "node:fs";
import { fixturePreloadEnv, syncFixtureBuiltinExports } from ${JSON.stringify(new URL("./fixtures/ci-fixture-runtime.cjs", import.meta.url).href)};
import { tmpdir } from "node:os";
import path from "node:path";
import { mock } from "node:test";
const timeoutFault = process.argv[2] === "timeout";
let root, failure;
let supervisor, ready, onReady;
const fork = cp.fork;
if (timeoutFault) {
ready = new Promise(resolve => {
onReady = message => {
if (message?.type === "ci-checkout:sentinel-created") resolve(message.pids);
};
});
cp.fork = (...args) => {
supervisor = fork(...args);
supervisor.on("message", onReady);
return supervisor;
};
syncFixtureBuiltinExports();
}
try {
const { withCiCheckoutFixture } = await import(process.argv[1]);
if (timeoutFault) mock.timers.enable({ apis: ["setTimeout"] });
const completed = withCiCheckoutFixture("early-leader-exit", directory => {
root = directory;
fs.writeFileSync(path.join(root, "checkout.sh"), "exit 0\n");
const preload = path.join(root, "fault.mjs");
fs.writeFileSync(preload, "const fault = " + JSON.stringify(process.argv[2]) + ";\n" + process.argv[3]);
return fixturePreloadEnv(preload);
}, (report, result, stderr) => {
throw new Error("unexpected completed report: " + JSON.stringify({ report, result, stderr }));
}).catch(error => {
console.error(error);
failure = String(error);
});
try {
if (timeoutFault) {
const pids = await Promise.race([ready, completed.then(() => {
throw new Error("supervisor completed before the timeout probe was ready");
})]);
assert.equal(pids.length, 2);
assert.equal(pids[0], supervisor.pid);
assert.notEqual(pids[1], supervisor.pid);
for (const pid of pids) {
assert(Number.isInteger(pid) && pid > 1);
process.kill(pid, 0);
}
}
} finally {
if (timeoutFault) {
// Creation belongs to the supervisor, not a child's delayed self-registration.
// Restore timers before the expired controller deadline starts real cleanup.
mock.timers.tick(50_000);
mock.timers.reset();
}
await completed;
}
} catch (error) {
console.error(error);
failure = String(error);
} finally {
if (timeoutFault) {
mock.timers.reset();
supervisor?.off("message", onReady);
cp.fork = fork;
syncFixtureBuiltinExports();
}
}
console.log(JSON.stringify({ root, outerRoot: tmpdir(), failure,
pids: JSON.parse(fs.readFileSync(path.join(root, "creator-pids.json"), "utf8")),
closedBeforeCensus: fs.existsSync(path.join(root, "closed-before-census.json")),
}));
process.exitCode = 1;
`,
new URL("./ci-checkout.test-support.ts", import.meta.url).href,
fault,
preload,
],
options: { stdio: ["ignore", "pipe", "pipe"] },
});
let stdout = "",
stderr = "";
child.stdout?.on("data", (data) => (stdout += String(data)));
child.stderr?.on("data", (data) => (stderr += String(data)));
const result = await completion;
expect(stdout, stderr).not.toBe("");
const evidence = JSON.parse(stdout) as {
root: string;
outerRoot: string;
failure: string;
pids: number[];
closedBeforeCensus: boolean;
};
try {
console.log(`${fault}: ${JSON.stringify({ result, ...evidence, stderr })}`);
expect(result, stderr).toEqual({ code: 1, signal: null, groupJoined: true });
expect(existsSync(evidence.outerRoot), "outer runner did not remove its own namespace").toBe(
false,
);
expect(path.dirname(evidence.root)).toBe(
realpathSync(fileURLToPath(new URL("../../.artifacts/ci-checkout/", import.meta.url))),
);
expect(existsSync(evidence.root), stderr).toBe(true);
expect(
evidence.pids.every((pid) => !isProcessAlive(pid)),
"fixture left owned processes alive",
).toBe(true);
expect(stderr).toContain(
`Checkout fixture retained at ${evidence.root}; no completed report.`,
);
expect(stderr).toContain("Supervisor close: true; group extinction: true.");
if (fault === "census") {
expect(evidence.closedBeforeCensus).toBe(true);
expect(evidence.pids).toHaveLength(3);
expect(stderr).toContain("injected final census failure after direct child close");
expect(existsSync(path.join(evidence.root, "report.json"))).toBe(false);
} else if (fault === "timeout") {
expect(evidence.pids).toHaveLength(2);
expect(evidence.failure).toContain("did not close within 50000ms");
expect(existsSync(path.join(evidence.root, "report.json"))).toBe(false);
} else {
expect(evidence.failure).not.toContain("unexpected completed report");
expect(readFileSync(path.join(evidence.root, "report.json"), "utf8")).toBe("null");
}
} finally {
await Promise.all(evidence.pids.map((pid) => waitForDead(pid, 4_000)));
rmSync(evidence.root, { recursive: true, force: true });
}
},
55_000,
);
it.skipIf(process.platform === "win32")(
"waits for legal slow tree startup before cancellation",
async () => {
const report = await runCiGitStep({
job: "checks-windows",
env: { CHECKOUT_KIND: "platform" },
fetchResults: ["hang"],
scenario: "cancel-SIGTERM",
startupDelay: { tree: 4_100 },
});
expect(report.code, report.output).toBe(143);
expect(report.readyAttempts).toEqual([1]);
expect(report.fetches).toHaveLength(1);
},
55_000,
);
it.skipIf(process.platform === "win32")(
"reports owner exit and output instead of a cleanup readiness timeout",
async () => {
const report = await runCiGitStep({
policy: 'print("owner exited before cleanup readiness", flush=True)\nraise SystemExit(23)\n',
fetchResults: [],
cancelDuringCleanup: true,
});
expect(report.code).toBe(23);
expect(report.cancelledDuringCleanup).toBe(false);
expect(report.output).toBe("owner exited before cleanup readiness\n");
expect(report.readyAttempts).toEqual([]);
expect(report.commands).toEqual([]);
},
55_000,
);
it("does not revive a terminated fixture instance when its PID is reused", () => {
const result = spawnSync(
process.platform === "win32" ? "python" : "python3",
[
"-I",
"-S",
"-c",
String.raw`
import contextlib, json, os, pathlib, runpy, subprocess, sys, tempfile
with tempfile.TemporaryDirectory(prefix="checkout-pid-reuse-") as directory:
root = pathlib.Path(directory).resolve()
workspace = root / "workspace"
workspace.mkdir()
records = root / "pids"
records.mkdir()
(root / "lease").write_text("owned")
# Guard command scope while retaining the real OS liveness result.
guard = root / "census.cjs"
guard.write_text('''
const assert = require("node:assert/strict");
const cp = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
const spawnSync = cp.spawnSync;
const inspected = new Set();
cp.spawnSync = (command, args, options) => {
if (command === "/bin/ps") {
const index = args.indexOf("-p");
assert(index >= 0 && args.filter(arg => arg === "-p").length === 1 && /^[1-9][0-9]*(?:,[1-9][0-9]*)*$/.test(args[index + 1]), "fixture census must query an explicit PID list");
const selected = args[index + 1].split(",").map(Number);
assert(process.platform === "linux" || selected.length === 1, "non-Linux census must query exactly one PID");
assert(args.every(arg => !arg.startsWith("-") || ["-p", "-o"].includes(arg)), "fixture census must select owned PIDs only");
const records = path.join(process.argv[3], "pids");
const allowed = new Set(fs.readdirSync(records).filter(name => name.endsWith(".json")).map(name => JSON.parse(fs.readFileSync(path.join(records, name), "utf8"))).filter(record => !fs.existsSync(path.join(records, record.instance + ".dead"))).map(record => record.pid));
for (const pid of selected) {
assert(allowed.has(pid) && !inspected.has(pid), "fixture census escaped deduplicated registered ownership");
inspected.add(pid);
}
}
return spawnSync(command, args, options);
};
''' + "\nrequire(" + json.dumps(sys.argv[5]) + ").syncFixtureBuiltinExports();\n")
with subprocess.Popen([sys.executable, "-I", "-S", "-c", "import sys; sys.stdin.read()"],
stdin=subprocess.PIPE) as child, contextlib.ExitStack() as cleanup:
if os.name == "nt":
broker = cleanup.enter_context(subprocess.Popen([
sys.argv[1], "--input-type=module", "-e", """
const { createWindowsProcessCensus } = await import(process.argv[1]);
const owner = createWindowsProcessCensus({ root: process.argv[2], token: "owned",
onFailure: error => { console.error(error); process.exitCode = 1; void owner.close(); } });
try {
await owner.ready;
console.log("ready");
await new Promise(resolve => { process.stdin.once("end", resolve); process.stdin.resume(); });
} finally { await owner.close(); }
""", sys.argv[4], str(root)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True))
# EOF retires the broker and sampler before the Python namespace owner leaves.
cleanup.callback(lambda: broker.communicate(timeout=4))
assert broker.stdout.readline().strip() == "ready", "census owner failed to initialize"
retired = dict(pid=child.pid, role="grandchild", attempt=1, instance="retired")
current = dict(pid=os.getpid(), role="grandchild", attempt=2, instance="current")
if os.name == "nt":
read_processes = runpy.run_path(sys.argv[3])["read_processes"]
identities = read_processes([child.pid, os.getpid()])
assert all(identity["alive"] for identity in identities)
retired["creationTime"], current["creationTime"] = (
identity["creationTime"] for identity in identities)
child.communicate(timeout=10)
(records / "retired.json").write_text(json.dumps(retired))
(records / "current.json").write_text(json.dumps(current))
(records / "sentinel.json").write_text(json.dumps(
dict(current, role="sentinel", attempt=0, instance="sentinel")))
def observe():
subprocess.run([sys.argv[1], "--require", str(guard), sys.argv[2], "git", str(root), "early-leader-exit",
"-C", str(workspace), "checkout"], cwd=workspace, check=True)
observed = json.loads((root / "events.jsonl").read_text().splitlines()[-1])
assert observed["sentinelAlive"], "unrelated live sentinel was lost"
return observed["alive"]
assert observe() == [current], "first boundary must observe real child termination"
# Fault-inject PID reuse only after actual death was observed. The fresh
# instance at that live PID must remain visible, never hidden by retirement.
retired["pid"] = current["pid"]
(records / "retired.json").write_text(json.dumps(retired))
assert observe() == [current], "a retired instance was revived by a reused PID"
if os.name == "nt":
# No death receipt exists for this instance: birth identity must
# reject reuse even when no census observed the PID between lives.
(records / "unobserved.json").write_text(json.dumps(
dict(retired, instance="unobserved")))
assert observe() == [current], "an unobserved retired birth was revived by PID reuse"
print("fixture lifetime contract passed")
`,
process.execPath,
ciCheckoutFixture,
fileURLToPath(new URL("./fixtures/ci-windows-process-census.py", import.meta.url)),
new URL("./fixtures/ci-windows-process-census.mjs", import.meta.url).href,
fileURLToPath(new URL("./fixtures/ci-fixture-runtime.cjs", import.meta.url)),
],
{ encoding: "utf8", timeout: 15_000, killSignal: "SIGKILL" },
);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("fixture lifetime contract passed");
});
it.skipIf(process.platform === "win32")(
"recognizes terminated POSIX groups without accepting live signal denials",
() => {
const owner = readFileSync(".github/actions/git-owner/owner.py", "utf8");
const result = spawnSync(
"python3",
[
"-I",
"-S",
"-c",
String.raw`
import ast, contextlib, errno, io, json, os, pathlib, re, signal, subprocess, sys, tempfile, time
# Load only the actual boundary functions; never execute checkout or real Git.
functions = [node for node in ast.parse(sys.stdin.read()).body
if isinstance(node, ast.FunctionDef) and node.name in ("group_alive", "group_signal", "group_states", "drain")]
exec(compile(ast.Module(body=functions, type_ignores=[]), "checkout-owner.py", "exec"))
cleanup_seconds = 10
# Retain the Popen handle without polling, so the owned zombie cannot be reaped or reused.
with subprocess.Popen([sys.executable, "-I", "-S", "-c", "pass"], start_new_session=True) as child:
deadline = time.monotonic() + 10
while True:
state = subprocess.run(["ps", "-o", "stat=", "-p", str(child.pid)],
check=True, capture_output=True, text=True).stdout.strip()
if state.startswith("Z"):
break
assert time.monotonic() < deadline, "owned child did not terminate"
time.sleep(0.01)
assert not group_alive(child.pid, deadline), "zombies are terminated, not checkout writers"
group_signal(child.pid, signal.SIGTERM, deadline)
group_signal(child.pid, signal.SIGKILL, deadline)
with tempfile.TemporaryDirectory(prefix="checkout-zombie-") as directory:
root = pathlib.Path(directory).resolve()
(root / "workspace").mkdir()
(root / "pids").mkdir()
(root / "lease").write_text("owned")
for pid, role, attempt in [(child.pid, "grandchild", 1), (os.getpid(), "sentinel", 0)]:
(root / "pids" / f"{pid}.json").write_text(json.dumps(dict(pid=pid, role=role, attempt=attempt, instance=str(pid))))
subprocess.run([sys.argv[1], sys.argv[2], "git", str(root), "early-leader-exit",
"-C", str(root / "workspace"), "checkout"], cwd=root / "workspace", check=True)
observed = json.loads((root / "events.jsonl").read_text())
assert observed["alive"] == [], "fixture counted a terminated zombie as a live writer"
assert observed["sentinelAlive"]
# Reap the session/group leader while its real descendant still owns the pipe.
# A PID-only query or Darwin's legacy -g must not lose that remaining writer.
with subprocess.Popen([sys.executable, "-I", "-S", "-c", """
import os, sys
if os.fork():
os._exit(0)
print(os.getpid(), os.getpgrp(), os.getsid(0), flush=True)
sys.stdin.read()
"""], start_new_session=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) as child:
descendant, pgid, sid = map(int, child.stdout.readline().split())
assert descendant != child.pid and pgid == sid == child.pid
child.wait(timeout=2)
actual_run = subprocess.run
command_mode = os.environ.get("COMMAND_MODE")
def scoped_census(command, **options):
assert "-g" in command and command[command.index("-g") + 1] == str(pgid), "owner census must select its owned group/session"
assert not set(command) & {"-a", "-A", "-e", "-x", "-axo", "-p"}, "owner census broadened or lost descendants"
result = actual_run(command, **options)
assert result.returncode == 0 and result.stderr == ""
assert [int(line.split()[0]) for line in result.stdout.splitlines()] == [pgid]
return result
try:
subprocess.run = scoped_census
for mode in ("legacy", "unix2003"):
os.environ["COMMAND_MODE"] = mode
assert group_alive(pgid, time.monotonic() + 2), "reaped leader hid a live descendant"
assert os.environ["COMMAND_MODE"] == mode, "query changed its owner's environment"
finally:
subprocess.run = actual_run
if command_mode is None:
os.environ.pop("COMMAND_MODE", None)
else:
os.environ["COMMAND_MODE"] = command_mode
child.communicate(timeout=2)
deadline = time.monotonic() + 2
while group_alive(pgid, deadline):
assert time.monotonic() < deadline, "descendant survived pipe closure"
time.sleep(0.01)
# A denied signal is safe to normalize only if the same census proves extinction.
with subprocess.Popen([sys.executable, "-I", "-S", "-c",
"import sys; print('ready', flush=True); sys.stdin.read()"],
start_new_session=True, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, text=True) as child:
assert child.stdout.readline().strip() == "ready"
actual_killpg = os.killpg
def denied(pgid, signum):
assert pgid == child.pid and signum in (0, signal.SIGTERM)
raise PermissionError(errno.EPERM, "test-owned signal denial")
actual_run = subprocess.run
try:
for probe in (actual_killpg, denied):
os.killpg = probe
for code, output, diagnostic in [
(1, "", ""), (0, "", ""), (0, " \n", ""), (2, "", ""), (-9, "", ""),
(1, f"{child.pid} Z\n", ""),
(0, f"{child.pid} Z\n", "injected census diagnostic\n"),
(1, "", "injected census diagnostic\n"),
("timeout", "", "injected census diagnostic\n"),
(0, f"{child.pid} Z\nbroken\n", ""),
(0, f"{child.pid} S\nbroken\n", ""),
(0, f"{child.pid} Z", ""),
(0, f"{os.getpgrp()} S\n", ""),
(0, "invalid Z\n", ""),
(0, f"{child.pid} Zbogus\n", ""),
(0, f"{child.pid} Z extra\n", ""),
]:
def census_result(command, **options):
if code == "timeout":
raise subprocess.TimeoutExpired(command, options["timeout"], stderr=diagnostic.encode())
result = subprocess.CompletedProcess(command, code, output, diagnostic)
if options.get("check"):
result.check_returncode()
return result
subprocess.run = census_result
captured = io.StringIO()
with contextlib.redirect_stderr(captured):
try:
group_alive(child.pid, time.monotonic() + 2)
except (RuntimeError, ValueError, PermissionError, subprocess.SubprocessError):
pass
else:
raise AssertionError(f"ambiguous census accepted: {(code, output, diagnostic)!r}")
assert captured.getvalue() == diagnostic, "census lost its diagnostic"
finally:
subprocess.run = actual_run
os.killpg = actual_killpg
os.killpg = denied
try:
try:
group_signal(child.pid, signal.SIGTERM, time.monotonic() + 10)
except PermissionError:
pass
else:
raise AssertionError("live denied group was accepted as terminated")
finally:
os.killpg = actual_killpg
# Force the real probe/query race: the group exists at killpg(0), then exits
# before native ps selects it. Only the subsequent native ESRCH proves absence.
def census_after_exit(command, **options):
child.communicate(timeout=2)
result = actual_run(command, **options)
assert result.returncode == 1 and result.stdout == result.stderr == ""
return result
try:
subprocess.run = census_after_exit
assert not group_alive(child.pid, time.monotonic() + 2)
finally:
subprocess.run = actual_run
if sys.platform == "darwin":
# Replay the native EPERM + ?E exit window while retaining a real owned child.
# Exiting is still pending: only the subsequent native census permits drain to return.
with subprocess.Popen([sys.executable, "-I", "-S", "-c",
"import sys; print('ready', flush=True); sys.stdin.read()"],
start_new_session=True, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, text=True) as child:
assert child.stdout.readline().strip() == "ready"
exiting = False
observed_exiting = 0
native_probes = 0
actual_killpg = os.killpg
actual_run = subprocess.run
def terminating_signal(pgid, signum):
global exiting, native_probes
assert pgid == child.pid
if signum == signal.SIGTERM:
actual_killpg(pgid, signal.SIGKILL)
exiting = True
if exiting:
raise PermissionError(errno.EPERM, "owned group is exiting")
if signum == 0:
native_probes += 1
return actual_killpg(pgid, signum)
def exiting_census(command, **options):
global exiting, observed_exiting
if exiting:
observed_exiting += 1
exiting = observed_exiting < 2
return subprocess.CompletedProcess(command, 0, f"{child.pid} ?E \n", "")
return actual_run(command, **options)
try:
os.killpg = terminating_signal
subprocess.run = exiting_census
drain(child, None)
assert observed_exiting == 2, "drain did not wait through the exiting process window"
assert native_probes, "drain accepted exiting as proof of termination"
assert child.returncode is not None, "drain returned before the owned child settled"
assert not group_alive(child.pid, time.monotonic() + 2)
finally:
os.killpg = actual_killpg
subprocess.run = actual_run
print("group contract passed")
`,
process.execPath,
ciCheckoutFixture,
],
{ input: owner, encoding: "utf8", timeout: 15_000, killSignal: "SIGKILL" },
);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("group contract passed");
},
);
const diagnosticSecret = "synthetic-diagnostic-secret";
const diagnosticPrefix = "[ci-git-owner] diagnostic=";
function runOwnerDiagnostic(policy: string) {
const result = spawnSync(
process.platform === "win32" ? "python" : "python3",
["-I", "-S", path.resolve(".github/actions/git-owner/owner.py"), "--policy", "-"],
{
input: `import ci_git_owner as owner, os, sys
secret = ${JSON.stringify(diagnosticSecret)}
sys.argv.append(secret)
os.environ["OWNER_DIAGNOSTIC_SECRET"] = secret
${policy}`,
encoding: "utf8",
timeout: 15_000,
killSignal: "SIGKILL",
},
);
expect(result.error).toBeUndefined();
expect(result.status, result.stderr).toBe(125);
expect(result.signal).toBeNull();
expect(result.stdout).toBe("");
expect(result.stderr).not.toContain(diagnosticSecret);
expect(result.stderr).not.toContain(process.cwd());
expect(result.stderr).not.toContain("Traceback");
const lines = result.stderr.trim().split(/\r?\n/u);
expect(lines).toHaveLength(2);
expect(lines[0]).toMatch(
/^::error::Git ownership\/setup failed \([A-Za-z]+\); refusing reuse or retry$/u,
);
expect(lines[1]?.startsWith(diagnosticPrefix)).toBe(true);
expect(result.stderr.length).toBeLessThan(4_096);
return { annotation: lines[0], diagnostic: lines[1]!.slice(diagnosticPrefix.length) };
}
it.each([
{ scenario: "direct denial", setup: "", types: ["PermissionError"] },
{
scenario: "timeout context",
setup: "error.__context__ = owner.FetchTimeout()",
types: ["PermissionError", "FetchTimeout"],
},
{
scenario: "explicit cause before context",
setup: "error.__cause__ = owner.FetchTimeout()\nerror.__context__ = ValueError(secret)",
types: ["PermissionError", "FetchTimeout"],
},
{
scenario: "cyclic context",
setup: "error.__context__ = error",
types: ["PermissionError"],
},
{
scenario: "bounded context",
setup:
"cursor = error\nfor _ in range(8):\n cursor.__context__ = RuntimeError(secret)\n cursor = cursor.__context__",
types: ["PermissionError", "RuntimeError", "RuntimeError", "RuntimeError"],
},
])("retains bounded terminal diagnostics: $scenario", ({ scenario, setup, types }) => {
const { diagnostic } = runOwnerDiagnostic(`
error = PermissionError(13, secret, secret + "/private-path")
error.winerror = 5
${setup}
raise error
`);
const chain = JSON.parse(diagnostic) as { type: string; via: string; owner_frames: unknown[] }[];
expect(chain.map((record) => record.type)).toEqual(types);
expect(chain.map((record) => record.via)).toEqual([
"terminal",
...types.slice(1).map(() => (scenario.startsWith("explicit") ? "cause" : "context")),
]);
expect(chain[0]).toEqual({
type: "PermissionError",
via: "terminal",
errno: 13,
winerror: 5,
owner_frames: [
{ function: "<module>", line: expect.any(Number) },
{ function: "main", line: expect.any(Number) },
],
});
for (const record of chain.slice(1)) {
expect(record).toEqual({ type: record.type, via: record.via, owner_frames: [] });
}
});
it.each([
{ errno: "secret", winerror: "True" },
{ errno: "2 ** 100", winerror: "-(2 ** 100)" },
{ errno: "type('NumericSecret', (int,), {})(13)", winerror: "None" },
])("redacts terminal diagnostic metadata ($errno, $winerror)", ({ errno, winerror }) => {
const { annotation, diagnostic } = runOwnerDiagnostic(`
error = type(secret, (Exception,), {"__module__": "builtins"})(secret)
error.errno, error.winerror = ${errno}, ${winerror}
# Even the owner's filename and globals cannot turn policy code into owner source.
owner.diagnostic_error = error
exec(compile("def synthetic_diagnostic_secret():\\n raise diagnostic_error\\nsynthetic_diagnostic_secret()",
owner.__file__, "exec"), vars(owner))
`);
expect(annotation).toContain("(unknown)");
expect(JSON.parse(diagnostic)).toEqual([
{
type: "unknown",
via: "terminal",
owner_frames: [
{ function: "<module>", line: expect.any(Number) },
{ function: "main", line: expect.any(Number) },
],
},
]);
});
it("bounds terminal diagnostics to the last six actual owner frames", () => {
const { diagnostic } = runOwnerDiagnostic(`
import io, sys
owner.diagnostic_depth = 0
owner.diagnostic_policy = '''import ci_git_owner as owner, io, sys
owner.diagnostic_depth += 1
if owner.diagnostic_depth == 12:
raise ValueError("synthetic-diagnostic-secret")
sys.stdin = io.StringIO(owner.diagnostic_policy)
owner.main()
'''
sys.stdin = io.StringIO(owner.diagnostic_policy)
owner.main()
`);
expect(JSON.parse(diagnostic)).toEqual([
{
type: "ValueError",
via: "terminal",
owner_frames: Array.from({ length: 6 }, () => ({
function: "main",
line: expect.any(Number),
})),
},
]);
});
it.each(["raises", "malformed traceback"])(
"keeps terminal exit 125 with %s metadata and cyclic context",
(fault) => {
const { diagnostic } = runOwnerDiagnostic(`
class BrokenMetadata(Exception):
def __getattribute__(self, name):
if name == "errno" and ${JSON.stringify(fault)} == "raises":
raise SystemExit(42)
if name == "__traceback__" and ${JSON.stringify(fault)} == "malformed traceback":
return self
return super().__getattribute__(name)
error = BrokenMetadata(secret)
error.__context__ = error
raise error
`);
expect(diagnostic).toBe("unavailable");
},
);
it.each([...(process.platform === "win32" ? ["setup"] : []), "launch", "timeout-drain"])(
"distinguishes terminal diagnostic failure sites: %s",
(site) => {
const { diagnostic } = runOwnerDiagnostic(String.raw`
import os, shlex, subprocess, sys, tempfile
site = ${JSON.stringify(site)}
if os.name == "nt":
import ctypes as c
from ctypes import wintypes as w
kernel = c.WinDLL("kernel32", use_last_error=True)
duplicate = kernel.DuplicateHandle
duplicate.argtypes = [w.HANDLE, w.HANDLE, w.HANDLE, c.POINTER(w.HANDLE), w.DWORD, w.BOOL, w.DWORD]
duplicate.restype = w.BOOL
current = kernel.GetCurrentProcess
current.argtypes, current.restype = [], w.HANDLE
def restricted_call(actual, rights, *args):
handle = w.HANDLE()
if not duplicate(current(), args[0], current(), c.byref(handle), rights, False, 0):
raise c.WinError(c.get_last_error())
try:
return actual(handle, *args[1:])
finally:
owner.close_handle(handle)
if site == "setup":
actual = owner.set_job
owner.set_job = lambda *args: restricted_call(actual, 0x4, *args)
elif site == "timeout-drain":
actual = owner.query_job
owner.query_job = lambda *args: restricted_call(actual, 0x8, *args)
elif site == "timeout-drain":
actual_drain = owner.drain
def denied_drain(*args):
actual_drain(*args)
raise PermissionError(13, secret)
owner.drain = denied_drain
# No files are created or removed after deliberately unverified Job cleanup.
directory = tempfile.gettempdir()
if site == "launch":
actual_popen = subprocess.Popen
def invalid_executable(*args, **kwargs):
kwargs["executable"] = directory
return actual_popen(*args, **kwargs)
subprocess.Popen = invalid_executable
alias = "!" + shlex.join([sys.executable.replace("\\", "/"), "-I", "-S", "-c", "import time; time.sleep(30)"])
owner.run_git(directory, "-c", "alias.diagnostic=" + alias, "diagnostic", timeout=0.1,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
`);
const chain = JSON.parse(diagnostic) as {
type: string;
errno?: number;
winerror?: number;
owner_frames: { function: string; line: number }[];
}[];
expect(chain.map((record) => record.type)).toEqual(
site === "timeout-drain"
? ["RuntimeError", "PermissionError", "FetchTimeout"]
: ["PermissionError"],
);
const denial = expectDefined(
chain.find((record) => record.type === "PermissionError"),
"recorded permission denial",
);
expect(denial.errno).toBe(13);
expect(denial.winerror).toBe(process.platform === "win32" ? 5 : undefined);
expect(denial.owner_frames.map((frame) => frame.function)).toContain("run_git");
expect(denial.owner_frames.some((frame) => frame.function === "drain")).toBe(
process.platform === "win32" && site === "timeout-drain",
);
const windowsDrain = process.platform === "win32" && site === "timeout-drain";
expect(denial.owner_frames.some((frame) => frame.function === "job_members")).toBe(
windowsDrain,
);
for (const frame of chain.flatMap((record) => record.owner_frames)) {
expect(Number.isInteger(frame.line) && frame.line > 0).toBe(true);
expect([
"<module>",
"main",
"run_git",
"drain",
...(windowsDrain ? ["job_members"] : []),
]).toContain(frame.function);
}
},
);