make webhook

This commit is contained in:
2026-07-13 18:50:38 +02:00
parent 2f128550fc
commit 40c822d3bf
67 changed files with 6286 additions and 2053 deletions
+229
View File
@@ -0,0 +1,229 @@
import { lstat } from "node:fs/promises";
import { resolve } from "node:path";
import { sha256 } from "../../core/contracts.js";
import { gitAuthEnv, run, subprocessOptions } from "./process.js";
import { changedFiles } from "./repository/changes.js";
const forbiddenPaths = [
".gitea/",
".ci-agents/",
".opencode/",
".git/",
".gitmodules",
"AGENTS.md",
];
export function validateChangedFiles(files: string[]): void {
if (files.length > 80)
throw new Error(`Agent changed ${files.length} files; maximum is 80`);
for (const file of files) {
if (
!file ||
file.includes("\0") ||
file.includes("\n") ||
file.startsWith("/") ||
file.includes("../")
) {
throw new Error(`Unsafe changed path: ${JSON.stringify(file)}`);
}
if (
forbiddenPaths.some(
(path) => file === path || file.startsWith(path),
)
) {
throw new Error(`Agent changed protected path: ${file}`);
}
if (
file
.split("/")
.some(
(segment) =>
segment.toLowerCase() === "bin" ||
segment.toLowerCase() === "obj",
)
) {
throw new Error(`Agent changed generated output path: ${file}`);
}
}
}
export async function validateChangedFileTypes(
workspace: string,
files: string[],
): Promise<void> {
for (const file of files) {
try {
const path = resolve(workspace, file);
if (!path.startsWith(`${resolve(workspace)}/`))
throw new Error(`Changed path escapes workspace: ${file}`);
const stat = await lstat(path);
if (stat.isSymbolicLink() || !stat.isFile())
throw new Error(`Changed path is not a regular file: ${file}`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
}
export async function commitAndPush(input: {
workspace: string;
files: string[];
branch: string;
token: string;
pushUrl: string;
message: string;
expectedRemoteSha?: string | null;
baseSha?: string;
expectedDiffDigest?: string;
signal?: AbortSignal;
timeoutMs?: number;
}): Promise<string> {
validateChangedFiles(input.files);
await validateChangedFileTypes(input.workspace, input.files);
const working = await changedFiles(input.workspace, input);
validateChangedFiles(working);
await validateChangedFileTypes(input.workspace, working);
if (
working.length &&
JSON.stringify(working) !== JSON.stringify([...input.files].sort())
) {
throw new Error(
"Working-tree changes differ from the publication file list",
);
}
if (input.expectedRemoteSha !== undefined) {
const remoteRef = `refs/heads/${input.branch}`;
const output = await run(
"git",
["ls-remote", "--heads", input.pushUrl, remoteRef],
subprocessOptions(input.workspace, input, {
env: gitAuthEnv(input.token),
}),
);
const remoteShas = parseRemoteShas(output, remoteRef, input.branch);
const remoteSha = remoteShas[0] || null;
if (remoteSha !== input.expectedRemoteSha) {
const localSha = (
await run(
"git",
["rev-parse", "HEAD"],
subprocessOptions(input.workspace, input),
)
).trim();
if (!working.length && remoteSha === localSha) {
await verifyCommittedDiff(input, localSha);
return localSha;
}
throw new Error(
`Remote branch ${input.branch} changed before publication`,
);
}
}
if (working.length) {
await run(
"git",
["-c", "core.hooksPath=/dev/null", "add", "--", ...input.files],
subprocessOptions(input.workspace, input),
);
await run(
"git",
[
"-c",
"core.hooksPath=/dev/null",
"-c",
"commit.gpgSign=false",
"commit",
"-m",
input.message,
],
subprocessOptions(input.workspace, input, {
env: {
GIT_AUTHOR_NAME:
process.env.CI_AGENT_GIT_NAME || "Olixero CI Agent",
GIT_AUTHOR_EMAIL:
process.env.CI_AGENT_GIT_EMAIL ||
"ci-agent@olixero.local",
GIT_COMMITTER_NAME:
process.env.CI_AGENT_GIT_NAME || "Olixero CI Agent",
GIT_COMMITTER_EMAIL:
process.env.CI_AGENT_GIT_EMAIL ||
"ci-agent@olixero.local",
},
}),
);
}
const sha = (
await run(
"git",
["rev-parse", "HEAD"],
subprocessOptions(input.workspace, input),
)
).trim();
await verifyCommittedDiff(input, sha);
await run(
"git",
[
"-c",
"core.hooksPath=/dev/null",
"-c",
"push.gpgSign=false",
"push",
"--no-force",
input.pushUrl,
`HEAD:refs/heads/${input.branch}`,
],
subprocessOptions(input.workspace, input, {
env: gitAuthEnv(input.token),
}),
);
return sha;
}
function parseRemoteShas(
output: string,
remoteRef: string,
branch: string,
): string[] {
const values: string[] = [];
for (const line of output.split("\n").filter(Boolean)) {
const match = /^([0-9a-f]{40}|[0-9a-f]{64})\s+(.+)$/.exec(line);
if (!match)
throw new Error(
`Remote branch ${branch} returned an invalid state`,
);
const sha = match[1];
if (match[2] === remoteRef && sha) values.push(sha);
}
if (values.length > 1)
throw new Error(`Remote branch ${branch} returned an invalid state`);
return values;
}
async function verifyCommittedDiff(
input: {
workspace: string;
baseSha?: string;
expectedDiffDigest?: string;
signal?: AbortSignal;
timeoutMs?: number;
},
head: string,
): Promise<void> {
if (!input.baseSha || !input.expectedDiffDigest) return;
const diff = await run(
"git",
[
"diff",
"--binary",
"--no-ext-diff",
"--no-color",
"--unified=5",
input.baseSha,
head,
"--",
],
subprocessOptions(input.workspace, input, { maxOutput: 500_000 }),
);
if (sha256(diff) !== input.expectedDiffDigest)
throw new Error("Committed diff differs from the reviewed content");
}