UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

669 lines (668 loc) 21.7 kB
import "./src-vebZIeLe.js"; import { t as expectDefined } from "./expect-CyE8FADM.js"; import { i as getOrCreatePromise } from "./lazy-promise-DGqyc4Y4.js"; import { t as runCommandBuffered } from "./exec-BIE-3oLG.js"; import { t as GIT_TIMEOUT_MS } from "./git-exec-0RazRvYf.js"; import { r as gitEnvironment, u as runGit } from "./git-BuLvhVpD.js"; import { constants } from "node:fs"; import path from "node:path"; import fs$1 from "node:fs/promises"; import crypto from "node:crypto"; //#region src/sessions/session-diff-revisions.ts const emptyTreesInFlight = /* @__PURE__ */ new Map(); /** Picks the merge base used for branch-relative session diffs. */ async function resolveSessionDiffBase(params) { const remoteDefault = (await params.gitOut(params.root, [ "symbolic-ref", "--short", "refs/remotes/origin/HEAD" ]))?.trim() || null; const defaultShort = remoteDefault?.replace(/^origin\//, ""); if (remoteDefault && defaultShort && params.branch && params.branch !== defaultShort) { const mergeBase = await params.gitOut(params.root, [ "merge-base", remoteDefault, "HEAD" ]); if (mergeBase?.trim()) return { base: mergeBase.trim(), baseRef: defaultShort }; } if (params.branch && params.branch !== "main" && params.branch !== "master") { for (const candidate of ["main", "master"]) if ((await params.gitOut(params.root, [ "rev-parse", "--verify", "--quiet", candidate ]))?.trim()) { const mergeBase = await params.gitOut(params.root, [ "merge-base", candidate, "HEAD" ]); if (mergeBase?.trim()) return { base: mergeBase.trim(), baseRef: candidate }; } } return { base: "HEAD", baseRef: "HEAD" }; } /** Resolves the repository-format-specific empty tree without writing it. */ async function resolveSessionDiffEmptyTree(root) { try { const emptyTree = await getOrCreatePromise(emptyTreesInFlight, root, async () => { const result = await runGit(root, [ "hash-object", "-t", "tree", "--stdin" ], { input: "" }); return result.code === 0 ? result.stdout.trim() : ""; }, { evictOnSettled: true }); return emptyTree ? { base: emptyTree } : null; } catch { return null; } } function parseCommitRecord(line) { const separator = line.indexOf("\0"); if (separator <= 0) return; return { sha: line.slice(0, separator), subject: line.slice(separator + 1) }; } function parseCommitRecords(text) { return text.split("\n").map(parseCommitRecord).filter((record) => record !== void 0); } /** Loads the bounded branch history metadata shared by every diff scope. */ async function loadSessionDiffBranchMetadata(params) { if (params.base === "HEAD" || params.base === params.head) return {}; const range = `${params.base}..HEAD`; const [aheadText, commitsText, mergeBaseText] = await Promise.all([ params.gitOut(params.root, [ "rev-list", "--count", range ]), params.gitOut(params.root, [ "log", "--max-count=50", "--format=%h%x00%s", range, "--" ]), params.gitOut(params.root, [ "show", "--no-patch", "--format=%h%x00%s", params.base, "--" ]) ]); const normalizedAhead = aheadText?.trim(); const aheadCount = normalizedAhead && /^\d+$/.test(normalizedAhead) ? Number.parseInt(normalizedAhead, 10) : void 0; const mergeBase = mergeBaseText ? parseCommitRecords(mergeBaseText)[0] : void 0; return { ...aheadCount !== void 0 ? { aheadCount } : {}, ...commitsText !== null ? { commits: parseCommitRecords(commitsText) } : {}, ...mergeBase ? { mergeBase } : {} }; } //#endregion //#region src/sessions/session-diff.ts const MAX_FILES = 500; const MAX_UNTRACKED_FILES = 100; const MAX_PATCH_BYTES_PER_FILE = 1e5; const MAX_TOTAL_PATCH_BYTES = 15e5; const MAX_BASELINE_GIT_OUTPUT_BYTES = 512e3; const MAX_BASELINE_FILE_BYTES = 4194304; const MAX_BASELINE_TOTAL_BYTES = 16777216; const MAX_TOTAL_CHANGED_LINES = 1e5; const PATCH_DIFF_ARGS = [ "--patch", "--no-color", "--no-ext-diff", "--no-textconv", "--src-prefix=a/", "--dst-prefix=b/" ]; async function gitOut(cwd, args, okCodes = [0]) { try { const result = await runGit(cwd, [ "-c", "core.quotePath=false", ...args ]); return okCodes.includes(result.code ?? -1) ? result.stdout : null; } catch { return null; } } async function loadCheckoutRevision(cwd) { try { const result = await runGit(cwd, [ "rev-parse", "--show-toplevel", "--verify", "--quiet", "HEAD" ]); if (result.termination !== "exit" || result.code !== 0 && result.code !== 1) return; const lines = result.stdout.replace(/\n$/, "").split("\n"); const head = result.code === 0 ? lines.pop() : void 0; const root = lines.join("\n"); if (!root) return; const branchOut = head ? (await gitOut(root, [ "rev-parse", "--abbrev-ref", "HEAD" ]))?.trim() : void 0; return { root, head, branch: branchOut && branchOut !== "HEAD" ? branchOut : void 0 }; } catch { return; } } /** Parses `git diff --name-status -z -M` output; R/C entries consume two paths. */ function parseNameStatusZ(text) { const tokens = text.split("\0"); const entries = []; for (let i = 0; i < tokens.length; i += 1) { const code = tokens[i]; if (!code) continue; const letter = code[0]; if (letter === "R" || letter === "C") { const oldPath = tokens[i + 1]; const path = tokens[i + 2]; i += 2; if (path) entries.push({ path, oldPath, status: letter === "R" ? "renamed" : "added" }); continue; } const path = tokens[i + 1]; i += 1; if (!path) continue; const status = letter === "A" ? "added" : letter === "D" ? "deleted" : "modified"; entries.push({ path, status }); } return entries; } /** Parses `git diff --numstat -z -M`; rename entries put paths in follow-up tokens. */ function parseNumstatZ(text) { const tokens = text.split("\0"); const byPath = /* @__PURE__ */ new Map(); for (let i = 0; i < tokens.length; i += 1) { const token = tokens[i]; if (!token) continue; const [added, deleted, ...pathParts] = token.split(" "); const inlinePath = pathParts.join(" "); if (added === void 0 || deleted === void 0) continue; const binary = added === "-"; const entry = { additions: binary ? 0 : Number.parseInt(added, 10) || 0, deletions: binary ? 0 : Number.parseInt(deleted, 10) || 0, binary }; if (inlinePath) { byPath.set(inlinePath, entry); continue; } const path = tokens[i + 2]; i += 2; if (path) byPath.set(path, entry); } return byPath; } function chunkPath(chunk) { const newFile = /(?:^|\n)\+\+\+ b\/([^\n]+)(?:\n|$)/.exec(chunk); if (newFile) return expectDefined(newFile[1], "new file capture group 1"); const oldFile = /(?:^|\n)--- a\/([^\n]+)(?:\n|$)/.exec(chunk); if (oldFile) return expectDefined(oldFile[1], "old file capture group 1"); const renameTo = /(?:^|\n)rename to ([^\n]+)(?:\n|$)/.exec(chunk); if (renameTo) return expectDefined(renameTo[1], "rename to capture group 1"); const header = /(?:^|\n)diff --git a\/[^\n]+ b\/([^\n]+)(?:\n|$)/.exec(chunk); return header ? expectDefined(header[1], "header capture group 1") : null; } /** Splits a multi-file `git diff --patch` into per-file chunks keyed by path. */ function splitPatchByFile(patch) { const byPath = /* @__PURE__ */ new Map(); if (!patch.trim()) return byPath; const parts = patch.split(/(?<=\n)(?=diff --git )/); for (const part of parts) { if (!part.startsWith("diff --git ")) continue; const path = chunkPath(part); if (path) byPath.set(path, part); } return byPath; } function readPatchHeader(chunk) { const header = /(?:^|\n)(@@ [^\n]*|Binary files [^\n]* differ|GIT binary patch)\n/.exec(chunk)?.[1]; const added = /^@@ -0,0 \+1(?:,(\d+))? @@(?: |$)/.exec(header ?? ""); return { ...added ? { additions: Number(added[1] ?? 1) } : {}, binary: header !== void 0 && !header.startsWith("@@ ") }; } /** * A patch-producing `git diff` reads working-tree file contents, so a * checkout-planted hardlink to an out-of-tree secret would otherwise leak * through this read-scoped RPC (same threat the fs-safe workspace readers * reject). Content is only emitted for a real, single-linked regular file * whose realpath stays inside the checkout. Deleted files are exempt: git * reads their content from the object DB, never the filesystem. */ async function isPatchableWorkingTreePath(realRoot, relPath) { const abs = path.resolve(realRoot, relPath); try { const info = await fs$1.lstat(abs); if (!info.isFile() || info.nlink !== 1) return false; const resolved = await fs$1.realpath(abs); return resolved === realRoot || resolved.startsWith(realRoot + path.sep); } catch { return false; } } function takePatch(chunk, budget) { if (!chunk) return { truncated: true }; const bytes = Buffer.byteLength(chunk, "utf8"); if (bytes > MAX_PATCH_BYTES_PER_FILE || bytes > budget.remaining) return { truncated: true }; budget.remaining -= bytes; return { patch: chunk }; } async function collectUntrackedFiles(root, realRoot, budget) { const paths = (await gitOut(root, [ "ls-files", "--others", "--exclude-standard", "-z" ]) ?? "").split("\0").filter(Boolean); const truncated = paths.length > MAX_UNTRACKED_FILES; const files = []; for (const filePath of paths.slice(0, MAX_UNTRACKED_FILES)) { const file = { path: filePath, status: "added", additions: 0, deletions: 0, untracked: true }; files.push(file); if (!await isPatchableWorkingTreePath(realRoot, filePath)) { file.truncated = true; continue; } const result = await runCommandBuffered([ "git", "-C", root, "-c", "core.quotePath=false", "diff", ...PATCH_DIFF_ARGS, "--no-index", "--", "/dev/null", filePath ], { timeoutMs: GIT_TIMEOUT_MS, env: gitEnvironment(), maxOutputBytes: MAX_PATCH_BYTES_PER_FILE, discardOutput: { stderr: true } }); const patchTruncated = result.termination === "output-limit" && result.outputLimitStream === "stdout"; if (!patchTruncated && (result.termination !== "exit" || result.code !== 0 && result.code !== 1)) { file.truncated = true; continue; } const patch = result.stdout.toString("utf8"); if (!patch.startsWith("diff --git ")) { file.truncated = true; continue; } const header = readPatchHeader(patch); file.additions = header.additions ?? 0; if (header.binary) { file.binary = true; continue; } Object.assign(file, takePatch(patchTruncated ? void 0 : patch, budget)); } return { files, truncated }; } async function collectTrackedFiles(root, realRoot, revisions, budget) { const diffArgs = (options) => [ "diff", "-M", ...options, ...revisions, "--" ]; const nameStatus = await gitOut(root, diffArgs(["--name-status", "-z"])); if (nameStatus === null) return { files: [], truncated: false }; const entries = parseNameStatusZ(nameStatus); if (entries.length === 0) return { files: [], truncated: false }; const numstat = parseNumstatZ(await gitOut(root, diffArgs(["--numstat", "-z"])) ?? ""); const patchText = [...numstat.values()].reduce((sum, entry) => sum + entry.additions + entry.deletions, 0) > MAX_TOTAL_CHANGED_LINES ? null : await gitOut(root, diffArgs(PATCH_DIFF_ARGS)); const chunks = patchText === null ? /* @__PURE__ */ new Map() : splitPatchByFile(patchText); const truncated = entries.length > MAX_FILES; const files = []; for (const entry of entries.slice(0, MAX_FILES)) { const stat = numstat.get(entry.path); const chunk = chunks.get(entry.path); const binary = stat?.binary === true || chunk !== void 0 && readPatchHeader(chunk).binary; const file = { path: entry.path, status: entry.status, additions: stat?.additions ?? 0, deletions: stat?.deletions ?? 0 }; if (entry.oldPath) file.oldPath = entry.oldPath; if (binary) { file.binary = true; files.push(file); continue; } if (!(revisions.length === 2 || entry.status === "deleted" || await isPatchableWorkingTreePath(realRoot, entry.path))) { file.truncated = true; files.push(file); continue; } const taken = takePatch(chunk, budget); if (taken.patch !== void 0) file.patch = taken.patch; if (taken.truncated) file.truncated = true; files.push(file); } return { files, truncated }; } async function loadCheckoutDiff(params) { const empty = (unavailableReason) => ({ sessionKey: params.sessionKey, files: [], additions: 0, deletions: 0, ...unavailableReason ? { unavailableReason } : {} }); const checkout = await loadCheckoutRevision(params.cwd); if (!checkout) return empty("not_git"); const { root, head, branch } = checkout; const realRoot = await fs$1.realpath(root).catch(() => root); const branchBase = head ? await resolveSessionDiffBase({ branch, gitOut, root }) : await resolveSessionDiffEmptyTree(root); const metadata = head && branchBase ? await loadSessionDiffBranchMetadata({ base: branchBase.base, gitOut, head, root }) : {}; const repositoryFields = { sessionKey: params.sessionKey, root, ...branch ? { branch } : {}, ...branchBase?.baseRef ? { baseRef: branchBase.baseRef } : {}, ...metadata }; const unknownCommit = () => ({ ...repositoryFields, files: [], additions: 0, deletions: 0, unavailableReason: "unknown_commit" }); const scope = params.scope ?? "all"; let revisions; if (scope === "commit") { if (!head || !branchBase || branchBase.base === "HEAD" || branchBase.base === head) return unknownCommit(); const commit = (await gitOut(root, [ "rev-parse", "--verify", "--quiet", "--end-of-options", `${params.commit}^{commit}` ]))?.trim(); if (!commit) return unknownCommit(); const isCommitInHeadHistory = await gitOut(root, [ "merge-base", "--is-ancestor", commit, "HEAD" ], [0]) !== null; const isCommitInBaseHistory = await gitOut(root, [ "merge-base", "--is-ancestor", commit, branchBase.base ], [0]) !== null; if (!isCommitInHeadHistory || isCommitInBaseHistory) return unknownCommit(); const parent = (await gitOut(root, [ "rev-parse", "--verify", "--quiet", `${commit}^` ]))?.trim(); const commitBase = parent ? { base: parent } : await resolveSessionDiffEmptyTree(root); revisions = commitBase ? [commitBase.base, commit] : void 0; } else if (scope === "uncommitted") revisions = head ? ["HEAD"] : branchBase ? [branchBase.base] : void 0; else revisions = branchBase ? [branchBase.base] : void 0; const budget = { remaining: MAX_TOTAL_PATCH_BYTES }; const tracked = revisions ? await collectTrackedFiles(root, realRoot, revisions, budget) : { files: [], truncated: false }; const untracked = scope === "commit" ? { files: [], truncated: false } : await collectUntrackedFiles(root, realRoot, budget); const files = [...tracked.files, ...untracked.files].toSorted((a, b) => a.path.localeCompare(b.path)); const additions = files.reduce((sum, file) => sum + file.additions, 0); const deletions = files.reduce((sum, file) => sum + file.deletions, 0); const truncated = tracked.truncated || untracked.truncated || files.some((file) => file.truncated === true); return { ...repositoryFields, files, additions, deletions, ...truncated ? { truncated: true } : {} }; } function sameMutationFingerprint(left, right) { return left.ctimeNs === right.ctimeNs && left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.mtimeNs === right.mtimeNs && left.nlink === right.nlink && left.size === right.size; } function hashBaselineDescriptor(candidate, content) { return crypto.createHash("sha256").update([ candidate.path, candidate.oldPath ?? "", candidate.status, candidate.untracked === true ? "untracked" : "tracked", content ].join("\0")).digest("hex"); } async function fingerprintBaselineCandidate(params) { const { candidate } = params; if (candidate.status === "deleted") return hashBaselineDescriptor(candidate, "deleted"); const absolutePath = path.resolve(params.root, candidate.path); const relativePath = path.relative(params.root, absolutePath); if (relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) return; const initial = await fs$1.lstat(absolutePath, { bigint: true }).catch(() => void 0); if (!initial) return; if (initial.isSymbolicLink()) { const target = await fs$1.readlink(absolutePath).catch(() => void 0); return target === void 0 ? void 0 : hashBaselineDescriptor(candidate, `symlink:${target}`); } if (!initial.isFile() || initial.nlink !== 1n || initial.size > BigInt(MAX_BASELINE_FILE_BYTES) || initial.size > BigInt(params.budget.remaining)) return; const resolved = await fs$1.realpath(absolutePath).catch(() => void 0); if (!resolved || resolved !== params.realRoot && !resolved.startsWith(params.realRoot + path.sep)) return; const handle = await fs$1.open(absolutePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)).catch(() => void 0); if (!handle) return; params.budget.remaining -= Number(initial.size); try { const opened = await handle.stat({ bigint: true }); if (!opened.isFile() || opened.nlink !== 1n || !sameMutationFingerprint(initial, opened)) return; const digest = crypto.createHash("sha256"); digest.update([ candidate.path, candidate.oldPath ?? "", candidate.status, candidate.untracked === true ? "untracked" : "tracked", opened.mode.toString(), opened.size.toString() ].join("\0")); const buffer = Buffer.allocUnsafe(65536); let offset = 0; while (offset < Number(opened.size)) { const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, Number(opened.size) - offset), offset); if (bytesRead === 0) return; digest.update(buffer.subarray(0, bytesRead)); offset += bytesRead; } return sameMutationFingerprint(opened, await handle.stat({ bigint: true })) ? digest.digest("hex") : void 0; } finally { await handle.close().catch(() => void 0); } } async function gitOutForBaseline(cwd, args) { const result = await runCommandBuffered([ "git", "-C", cwd, "-c", "core.quotePath=false", ...args ], { timeoutMs: 3e4, env: gitEnvironment(), maxOutputBytes: { stdout: MAX_BASELINE_GIT_OUTPUT_BYTES, stderr: 32768 } }); if (result.termination !== "exit" || result.code !== 0) return null; return result.stdout.toString("utf8"); } async function collectBaselineCandidates(params) { const checkout = await loadCheckoutRevision(params.cwd); if (!checkout) return; const { root, head, branch } = checkout; const baseInfo = head ? await resolveSessionDiffBase({ branch, gitOut, root }) : await resolveSessionDiffEmptyTree(root); const trackedText = baseInfo ? await gitOutForBaseline(root, [ "diff", "-M", baseInfo.base, "--name-status", "-z" ]) : ""; const untrackedText = await gitOutForBaseline(root, [ "ls-files", "--others", "--exclude-standard", "-z" ]); if (trackedText === null || untrackedText === null) return { root, candidates: [], truncated: true }; const tracked = parseNameStatusZ(trackedText); const untrackedPaths = untrackedText.split("\0").filter(Boolean); return { root, candidates: [...tracked.slice(0, MAX_FILES), ...untrackedPaths.slice(0, MAX_UNTRACKED_FILES).map((path) => ({ path, status: "added", untracked: true }))].toSorted((left, right) => left.path.localeCompare(right.path)), truncated: tracked.length > MAX_FILES || untrackedPaths.length > MAX_UNTRACKED_FILES }; } async function fingerprintBaselineCandidates(params) { const realRoot = await fs$1.realpath(params.root).catch(() => params.root); const budget = { remaining: MAX_BASELINE_TOTAL_BYTES }; const files = []; for (const candidate of params.candidates) { const fingerprint = await fingerprintBaselineCandidate({ budget, candidate, realRoot, root: params.root }); if (fingerprint) files.push({ path: candidate.path, fingerprint }); } return { files, truncated: files.length !== params.candidates.length }; } async function captureSessionDiffBaseline(params) { const collected = await collectBaselineCandidates({ cwd: params.cwd }); if (!collected) return; const fingerprinted = await fingerprintBaselineCandidates({ candidates: collected.candidates, root: collected.root }); return { version: 1, sessionId: params.sessionId, root: collected.root, files: fingerprinted.files, ...collected.truncated || fingerprinted.truncated ? { truncated: true } : {} }; } async function applySessionDiffBaseline(params) { const { baseline, diff } = params; if (baseline?.version !== 1 || baseline.sessionId !== params.sessionId || !diff.root || baseline.root !== diff.root) return diff; const fingerprints = new Map(baseline.files.map((file) => [file.path, file.fingerprint])); const current = await fingerprintBaselineCandidates({ candidates: diff.files.filter((file) => fingerprints.has(file.path)), root: diff.root }); const currentFingerprints = new Map(current.files.map((file) => [file.path, file.fingerprint])); const files = diff.files.filter((file) => { const baselineFingerprint = fingerprints.get(file.path); return !baselineFingerprint || currentFingerprints.get(file.path) !== baselineFingerprint; }); if (files.length === diff.files.length) return diff; return { ...diff, files, additions: files.reduce((sum, file) => sum + file.additions, 0), deletions: files.reduce((sum, file) => sum + file.deletions, 0) }; } //#endregion export { parseNumstatZ as a, parseNameStatusZ as i, captureSessionDiffBaseline as n, splitPatchByFile as o, loadCheckoutDiff as r, applySessionDiffBaseline as t };