run402-mcp
Version:
MCP server for Run402 — full-stack backend infrastructure for AI agents: Postgres, auth, storage, serverless functions and atomic deploys. Paid with x402/MPP. Includes $0.03 image generation.
974 lines • 54.9 kB
JavaScript
/**
* vault — snapshot + hardened git execution (protocol rev 41 §6.6; task 5.5).
*
* Snapshot policy: clean tree → `HEAD`; dirty → REFUSED BY DEFAULT
* (`SNAPSHOT_DIRTY_TREE`, before any object is created) unless
* `allowDirty: true`, in which case a synthetic commit of tracked +
* untracked-not-ignored paths is captured (parented on HEAD; parentless on
* an unborn HEAD) and the result discloses exactly what got swept in
* (`modified_captured` / `untracked_captured`). "Dirty" means any modified
* or staged tracked path, or any untracked-not-ignored path — a project
* whose build step writes gitignored output is never dirty on that account.
* Unmerged index → `SNAPSHOT_CONFLICTED_INDEX` (checked first — allowDirty
* does not touch it); detached HEAD is representable; linked worktrees, bare
* layouts, sparse checkouts, shallow clones, submodules, SHA-256
* repositories, alternates/grafts, and active filters on any captured path
* are REFUSED BY NAME before any object is created.
*
* Ignore authority is FROZEN and read AS DATA: repository `.gitignore`,
* `.git/info/exclude`, and the user's global excludes file. The global path
* is discovered deterministically (round-6 M11) by parsing
* `$XDG_CONFIG_HOME/git/config` then `~/.gitconfig` with a frozen config
* subset (last value wins, `~/`/`$HOME` expansion only; `include`/`includeIf`
* or an unreadable config/excludes file → refuse by name). git never sees the
* user's config: the discovered file is handed to `git ls-files` as an
* `--exclude-from` pattern source, so git's matcher runs over OUR discovery.
*
* Object creation NEVER invokes filters for any path (tracked or untracked):
* bytes are hashed with `git hash-object --no-filters`; every included path is
* first checked with `git check-attr filter`, and any configured filter (LFS
* included) refuses the capture by name — no clean/smudge/process command is
* ever started (the acceptance test asserts a sentinel the filter would write
* never appears).
*
* The capture also records the CAPTURED SET (`snapshot.captured`) and a digest
* over it (`snapshot.captured_digest`). The deploy lane re-derives that digest
* after artifacts are collected and before a plan commits, and refuses
* `SNAPSHOT_MOVED_DURING_DEPLOY` on any difference. The set is tracked plus
* untracked-but-not-ignored — so a build rewriting gitignored output changes
* nothing, deliberately.
*
* Every git invocation is argv-only (no shell), with `GIT_*` cleared,
* `GIT_CONFIG_NOSYSTEM`, `GIT_CONFIG_GLOBAL=/dev/null` (no user config), an
* empty `core.hooksPath`, `--no-replace-objects`, `core.fsmonitor=false`, and
* no alternates. Repository-config `include`/`includeIf` is refused by name
* (git applies it before any `-c`; there is no override switch).
*/
import { execFile } from "node:child_process";
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { LocalError } from "../errors.js";
import { VAULT_OID40_RE, deriveDigestKey, keyedCommitment, sha256Hex, snapshotOidContent } from "../namespaces/vault.crypto.js";
// ─── Constants (constants.json) ──────────────────────────────────────────────
/** `MAX_GIT_OBJECT_BYTES` — any single object above this is `GIT_OBJECT_TOO_LARGE`. */
export const VAULT_MAX_GIT_OBJECT_BYTES = 200 * 1024 * 1024;
/** `refs/run402/deploys/latest` — the single protocol-owned deploy ref (round-3 H9). */
export const VAULT_DEPLOY_REF = "refs/run402/deploys/latest";
/** Probed git version range: `[2.32, ∞)` — `GIT_CONFIG_GLOBAL` landed in 2.32. */
export const VAULT_MIN_GIT_VERSION = [2, 32];
function fail(code, message, context, details, next_actions) {
throw new LocalError(message, context, { code, details, ...(next_actions ? { next_actions } : {}) });
}
let emptyHooksDir = null;
function hooksPath() {
if (!emptyHooksDir) {
emptyHooksDir = mkdtempSync(join(tmpdir(), "run402-vault-nohooks-"));
}
return emptyHooksDir;
}
/** The environment every vault git process runs under — nothing inherited except PATH. */
export function hardenedGitEnv(extra = {}) {
const env = {
PATH: process.env.PATH ?? "/usr/bin:/bin",
LC_ALL: "C",
GIT_CONFIG_NOSYSTEM: "1",
GIT_CONFIG_GLOBAL: "/dev/null",
GIT_NO_REPLACE_OBJECTS: "1",
GIT_TERMINAL_PROMPT: "0",
GIT_OPTIONAL_LOCKS: "0",
// hash-object/commit-tree identity for synthetic commits — fixed, never the user's.
GIT_AUTHOR_NAME: "run402 vault",
GIT_AUTHOR_EMAIL: "vault@run402.com",
GIT_COMMITTER_NAME: "run402 vault",
GIT_COMMITTER_EMAIL: "vault@run402.com",
};
if (process.env.HOME)
env.HOME = process.env.HOME; // only so /dev/null config resolution has a home; never read.
for (const [k, v] of Object.entries(extra))
env[k] = v;
return env;
}
/**
* The `RUN402_*` environment this process was configured with — for the ONE
* vault git invocation that touches the network (`cloneVaultRemote`):
* that clone spawns the `git-remote-run402` helper,
* which must authenticate as THIS session's wallet and config dir. Under the
* bare {@link hardenedGitEnv} the helper saw no `RUN402_CONFIG_DIR` /
* `RUN402_WALLET` / `RUN402_API_BASE` and fell back to the machine's default
* config — a different wallet, not a member of the vault's org — so a
* `resume` on any machine that selects its identity by env (a laptop with
* several agent config dirs, a CI job, a harness with `RUN402_WALLET`) died
* `VAULT_ACCESS_DENIED` "while resolving the project's vault" right
* after a successful claim (kygit-handoff live rerun, 2026-09-03). Only
* `RUN402_*` keys pass; git's own hardening is untouched.
*/
export function run402PassthroughEnv(source = process.env) {
const out = {};
for (const [k, v] of Object.entries(source)) {
if (k.startsWith("RUN402_") && typeof v === "string")
out[k] = v;
}
return out;
}
/** The argv prefix that neutralizes hooks, fsmonitor, replace refs, and filter autodetection for every invocation. */
export const HARDENED_GIT_ARGV_PREFIX = () => [
"--no-replace-objects",
"-c", `core.hooksPath=${hooksPath()}`,
"-c", "core.fsmonitor=false",
"-c", "core.untrackedCache=false",
"-c", "core.commitGraph=false",
"-c", "gc.auto=0",
"-c", "protocol.allow=never",
];
/**
* Run git with explicit args (no shell) in `cwd` under the hardened env.
* Throws `GIT_COMMAND_FAILED` on a non-ok exit.
*
* Node's `execFile` reports a MISSING `cwd` with the exact same `ENOENT` as a
* missing `git` binary, so a caller that forgot to create its working
* directory (e.g. `recover`'s `out_dir` before this fix) saw the misleading
* `GIT_UNAVAILABLE: git could not be executed` even though git itself was
* perfectly installed. Disambiguate BEFORE spawning: a missing `cwd` is its
* own distinct, honest refusal, never folded into "git is unavailable."
*/
export function hardenedGit(cwd, args, options = {}) {
if (!existsSync(cwd)) {
return Promise.reject(new LocalError(`git working directory does not exist: ${cwd}`, "running hardened git", { code: "GIT_CWD_MISSING", details: { cwd, args } }));
}
const argv = [...HARDENED_GIT_ARGV_PREFIX(), ...args];
return new Promise((resolvePromise, reject) => {
const child = execFile("git", argv, { cwd, env: hardenedGitEnv(options.env), encoding: "buffer", maxBuffer: options.maxBuffer ?? 1024 * 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => {
const status = error && typeof error.code === "number" ? (error.code) : error ? -1 : 0;
const stderrText = Buffer.from(stderr).toString("utf8");
if (error && status === -1) {
reject(new LocalError(`git could not be executed: ${error.message}`, "running hardened git", { code: "GIT_UNAVAILABLE", details: { args: argv }, cause: error }));
return;
}
if (status !== 0 && !(options.okStatuses ?? []).includes(status)) {
reject(new LocalError(`git ${args[0] ?? ""} failed (exit ${status}): ${stderrText.trim().slice(0, 500)}`, "running hardened git", { code: "GIT_COMMAND_FAILED", details: { args, status, stderr: stderrText.slice(0, 2000) } }));
return;
}
const out = Buffer.from(stdout);
resolvePromise({
status,
stdout: out,
stderr: stderrText,
text: () => out.toString("utf8"),
lines: () => out.toString("utf8").split("\n").filter((l) => l.length > 0),
nul: () => out.toString("utf8").split("\0").filter((l) => l.length > 0),
});
});
if (options.input !== undefined)
child.stdin?.end(typeof options.input === "string" ? Buffer.from(options.input, "utf8") : Buffer.from(options.input));
else
child.stdin?.end();
});
}
/** `realpath` when it resolves, the input otherwise — path equality across `/tmp` → `/private/tmp` and friends. */
function canonicalPath(p) {
try {
return realpathSync(p);
}
catch {
return p;
}
}
/**
* Resolve — and PROVE — the repository git invoked this helper for.
*
* git always sets `GIT_DIR` when it runs a remote helper against a
* repository: `.git` (relative, cwd already moved to the top level) for
* fetch/push, the linked worktree's git dir inside a worktree, `.` in a bare
* repository, and the ABSOLUTE path of the not-yet-populated target during
* `git clone`. It is unset only for a repository-free invocation such as
* `git ls-remote <url>` run outside any checkout, which needs no repository
* at all.
*
* So `process.cwd()` is NOT the repository: during a clone it is the directory
* the user ran clone FROM, which is routinely inside some unrelated repo. A
* helper that discovers its repository from cwd therefore writes the vault's
* decrypted objects into a repository the user never named — the leak this
* function exists to make impossible. It fails closed: no `GIT_DIR`, a
* `GIT_DIR` that is not a repository, or a `GIT_DIR` whose own
* `--absolute-git-dir` disagrees with the path we were handed, all refuse.
* The caller must write nothing after a refusal.
*/
export async function resolveGitInvocationRepo(env = process.env, cwd = process.cwd()) {
const declared = typeof env.GIT_DIR === "string" ? env.GIT_DIR.trim() : "";
if (declared === "") {
fail("GIT_INVOCATION_REPO_UNRESOLVED", "git set no GIT_DIR for this invocation, so the repository it wants operated on cannot be established; refusing rather than guessing from the current directory", "resolving the invoking git repository", { reason: "no_git_dir", cwd });
}
const absolute = resolve(cwd, declared);
let observed;
try {
observed = (await hardenedGit(absolute, ["rev-parse", "--absolute-git-dir"])).text().trim();
}
catch (cause) {
fail("GIT_INVOCATION_REPO_UNRESOLVED", `GIT_DIR ${absolute} is not a readable git repository`, "resolving the invoking git repository", { reason: "git_dir_not_a_repository", git_dir: absolute, cause: cause instanceof Error ? cause.message : String(cause) });
}
// The proof: discovery started AT the named directory must come back to that
// same directory. A `GIT_DIR` pointing at a plain subdirectory of some other
// repository resolves upward to that other repository — which is exactly the
// confusion that must never be acted on.
if (canonicalPath(observed) !== canonicalPath(absolute)) {
fail("GIT_INVOCATION_REPO_UNRESOLVED", `GIT_DIR ${absolute} resolves to a different repository (${observed}); refusing to operate on a repository git did not name`, "resolving the invoking git repository", { reason: "git_dir_mismatch", git_dir: absolute, resolved: observed });
}
return { git_dir: observed, repo_dir: observed };
}
/** `git version` parsed as [major, minor]; refuses below {@link VAULT_MIN_GIT_VERSION}. */
export async function probeGitVersion(cwd = process.cwd()) {
const r = await hardenedGit(cwd, ["version"]);
const m = /git version (\d+)\.(\d+)/.exec(r.text());
if (!m)
fail("GIT_UNAVAILABLE", `unrecognized git version output: ${r.text().trim()}`, "probing git version");
const v = [Number(m[1]), Number(m[2])];
const [minMajor, minMinor] = VAULT_MIN_GIT_VERSION;
if (v[0] < minMajor || (v[0] === minMajor && v[1] < minMinor)) {
fail("GIT_VERSION_UNSUPPORTED", `git ${v[0]}.${v[1]} is below the supported ${minMajor}.${minMinor}`, "probing git version", { version: v });
}
return v;
}
function unquoteConfigValue(raw) {
// git-config: value runs to end of line; `#`/`;` outside quotes start a comment; backslash escapes \" \\ \n \t \b; quoted segments keep spaces.
let out = "";
let inQuote = false;
for (let i = 0; i < raw.length; i++) {
const ch = raw[i];
if (ch === "\\" && i + 1 < raw.length) {
const n = raw[++i];
out += n === "n" ? "\n" : n === "t" ? "\t" : n === "b" ? "\b" : n;
continue;
}
if (ch === '"') {
inQuote = !inQuote;
continue;
}
if (!inQuote && (ch === "#" || ch === ";"))
break;
out += ch;
}
return inQuote ? out : out.trim();
}
/** Parse a git config file as data (frozen subset). Throws by name on `include`/`includeIf`. */
export function parseGitConfigAsData(text, file) {
const entries = [];
let section = "";
let subsection = null;
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#") || line.startsWith(";"))
continue;
const header = /^\[([A-Za-z0-9.-]+)(?:\s+"((?:[^"\\]|\\.)*)")?\]$/.exec(line);
if (header) {
section = header[1].toLowerCase();
subsection = header[2] !== undefined ? header[2].replace(/\\(.)/g, "$1") : null;
// `[section.sub]` legacy form — lowercase everything.
if (subsection === null && section.includes(".")) {
const dot = section.indexOf(".");
subsection = section.slice(dot + 1);
section = section.slice(0, dot);
}
if (section === "include" || section === "includeif") {
throw new LocalError(`${file}: [${section}] is not supported by the frozen ignore-authority discovery; remove it or set core.excludesFile directly`, "discovering global excludes", { code: "VAULT_CONFIG_INCLUDE_REFUSED", details: { file, key: section } });
}
continue;
}
const kv = /^([A-Za-z][A-Za-z0-9-]*)\s*(?:=\s*(.*))?$/.exec(line);
if (!kv)
continue; // not in the subset — ignored as data
entries.push({ section, subsection, key: kv[1].toLowerCase(), value: kv[2] === undefined ? "true" : unquoteConfigValue(kv[2]), file });
}
return entries;
}
function expandConfigPath(value, home, file) {
if (value.startsWith("~/")) {
if (!home)
fail("VAULT_EXCLUDES_PATH_UNSUPPORTED", `${file}: core.excludesFile uses ~/ but HOME is unset`, "discovering global excludes", { file, value });
return join(home, value.slice(2));
}
if (value.startsWith("$HOME/") || value === "$HOME") {
if (!home)
fail("VAULT_EXCLUDES_PATH_UNSUPPORTED", `${file}: core.excludesFile uses $HOME but HOME is unset`, "discovering global excludes", { file, value });
return join(home, value.slice(5));
}
if (value.startsWith("/"))
return value;
fail("VAULT_EXCLUDES_PATH_UNSUPPORTED", `${file}: core.excludesFile "${value}" is relative or environment-dependent; only absolute, ~/, and $HOME/ paths are supported`, "discovering global excludes", { file, value });
}
function readAsData(file, code, context) {
try {
return readFileSync(file, "utf8");
}
catch (e) {
const err = e;
if (err.code === "ENOENT")
return null;
throw new LocalError(`${file} exists but is unreadable (${err.code ?? "error"}); refusing the capture rather than silently widening the snapshot`, context, { code, details: { file, errno: err.code }, cause: e });
}
}
/**
* Deterministic discovery of the user's global excludes file (M11):
* `$XDG_CONFIG_HOME/git/config` (default `~/.config/git/config`) THEN
* `~/.gitconfig`, last value wins; default `$XDG_CONFIG_HOME/git/ignore`
* falling back to `~/.config/git/ignore`. Every refusal names its file.
*/
export function discoverGlobalExcludes(env = { HOME: process.env.HOME, XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME }) {
const home = env.HOME;
const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.startsWith("/") ? env.XDG_CONFIG_HOME : home ? join(home, ".config") : null;
const configs = [];
if (xdg)
configs.push(join(xdg, "git", "config"));
if (home)
configs.push(join(home, ".gitconfig"));
let excludes = null;
const read = [];
for (const file of configs) {
const text = readAsData(file, "VAULT_CONFIG_UNREADABLE", "discovering global excludes");
if (text === null)
continue;
read.push(file);
for (const e of parseGitConfigAsData(text, file)) {
if (e.section === "core" && e.subsection === null && e.key === "excludesfile")
excludes = { value: e.value, file };
}
}
let path;
let source;
if (excludes) {
path = expandConfigPath(excludes.value, home, excludes.file);
source = "core.excludesFile";
}
else {
source = "default";
const candidates = [xdg ? join(xdg, "git", "ignore") : null, home ? join(home, ".config", "git", "ignore") : null].filter((p) => p !== null);
path = candidates.find((p) => existsSync(p)) ?? null;
}
let patterns = null;
if (path) {
patterns = readAsData(path, "VAULT_EXCLUDES_UNREADABLE", "reading global excludes as data");
if (patterns === null)
path = source === "default" ? null : path; // an explicitly configured but absent file: git ignores it too
}
return { path: patterns === null ? null : path, source, configs_read: read, patterns };
}
async function gitRevParse(dir, flag) {
return (await hardenedGit(dir, ["rev-parse", flag])).text().trim();
}
/** Inspect `dir`; every unsupported layout is refused with its named code. */
export async function inspectRepository(dir) {
const abs = resolve(dir);
// `--is-bare-repository` FIRST: inside a bare repository `--show-toplevel` fails (there is no
// work tree), so probing it first would report every bare repo as "not a repository".
let bare;
try {
bare = await gitRevParse(abs, "--is-bare-repository");
}
catch (e) {
fail("VAULT_NOT_A_REPOSITORY", `${abs} is not inside a git repository`, "inspecting repository", { dir: abs, cause: String(e.message) });
}
if (bare === "true")
fail("VAULT_BARE_REPOSITORY", "bare repositories are refused: a snapshot needs a work tree", "inspecting repository", { dir: abs });
let top;
try {
top = await gitRevParse(abs, "--show-toplevel");
}
catch (e) {
fail("VAULT_NOT_A_REPOSITORY", `${abs} is not inside a git work tree`, "inspecting repository", { dir: abs, cause: String(e.message) });
}
const gitDir = resolve(top, await gitRevParse(top, "--git-dir"));
const commonDir = resolve(top, await gitRevParse(top, "--git-common-dir"));
if (gitDir !== commonDir)
fail("VAULT_LINKED_WORKTREE", `linked worktrees are refused by name: ${top} is a linked worktree of ${commonDir}`, "inspecting repository", { worktree: top, git_dir: gitDir, git_common_dir: commonDir });
if ((await gitRevParse(top, "--is-shallow-repository")) === "true")
fail("VAULT_SHALLOW_CLONE", "shallow/partial clones are refused: the object set is incomplete", "inspecting repository", { dir: top });
const format = (await gitRevParse(top, "--show-object-format")).trim();
if (format !== "sha1")
fail("GIT_OBJECT_FORMAT_UNSUPPORTED", `object format ${format} is not supported in V0 (sha1 only)`, "inspecting repository", { object_format: format });
for (const [file, code, what] of [
[join(commonDir, "objects", "info", "alternates"), "VAULT_ALTERNATES_UNSUPPORTED", "objects/info/alternates"],
[join(commonDir, "info", "grafts"), "VAULT_GRAFTS_UNSUPPORTED", "info/grafts"],
]) {
if (existsSync(file))
fail(code, `${what} is present; alternates/grafts are refused so the captured object set is exactly this repository's`, "inspecting repository", { file });
}
// sparse checkout: the config bit or the sparse-checkout file
const sparse = await hardenedGit(top, ["config", "--bool", "--get", "core.sparseCheckout"], { okStatuses: [1] });
if (sparse.text().trim() === "true" || existsSync(join(gitDir, "info", "sparse-checkout")))
fail("VAULT_SPARSE_CHECKOUT", "sparse checkouts are refused: the work tree is not the whole tree", "inspecting repository", { dir: top });
// repository config includes — git applies them before -c; refuse by name
const cfg = await hardenedGit(top, ["config", "--file", join(commonDir, "config"), "--list", "--name-only"], { okStatuses: [1] });
for (const name of cfg.lines()) {
const lower = name.toLowerCase();
if (lower.startsWith("include.") || lower.startsWith("includeif."))
fail("VAULT_CONFIG_INCLUDE_REFUSED", `repository config carries ${name}; include/includeIf is refused by name`, "inspecting repository", { key: name });
}
// submodules: .gitmodules or gitlinks in the index
if (existsSync(join(top, ".gitmodules")))
fail("VAULT_SUBMODULES_UNSUPPORTED", ".gitmodules present; submodules are not supported in V0", "inspecting repository", { dir: top });
const gitlinks = (await hardenedGit(top, ["ls-files", "-s", "-z"])).nul().filter((l) => l.startsWith("160000 "));
if (gitlinks.length > 0)
fail("VAULT_SUBMODULES_UNSUPPORTED", "the index contains gitlinks; submodules are not supported in V0", "inspecting repository", { paths: gitlinks.map((l) => l.split("\t")[1]) });
// HEAD
const symref = await hardenedGit(top, ["symbolic-ref", "-q", "HEAD"], { okStatuses: [1] });
const resolved = await hardenedGit(top, ["rev-parse", "-q", "--verify", "HEAD^{commit}"], { okStatuses: [1] });
const resolvedOid = resolved.status === 0 ? resolved.text().trim() : null;
let head;
if (symref.status === 0) {
const ref = symref.text().trim();
if (!ref.startsWith("refs/heads/"))
fail("REFNAME_UNSUPPORTED", `HEAD points at ${ref}, which is not a refs/heads/* branch`, "inspecting repository", { ref });
head = { kind: "symref", ref, resolved_oid: resolvedOid };
}
else {
if (!resolvedOid)
fail("VAULT_NOT_A_REPOSITORY", "HEAD is neither a symbolic ref nor a commit", "inspecting repository", { dir: top });
head = { kind: "detached", oid: resolvedOid, resolved_oid: resolvedOid };
}
const unmerged = [...new Set((await hardenedGit(top, ["ls-files", "-u", "-z"])).nul().map((l) => l.split("\t")[1]))];
return { top_level: top, git_dir: gitDir, git_common_dir: commonDir, object_format: "sha1", head, unmerged_paths: unmerged };
}
/** How many dirty-tree paths `SNAPSHOT_DIRTY_TREE`'s `details.modified`/`details.untracked` list before capping. */
export const SNAPSHOT_DIRTY_TREE_LIST_CAP = 500;
function cappedPathList(paths) {
if (paths.length <= SNAPSHOT_DIRTY_TREE_LIST_CAP)
return { list: paths, more: 0 };
return { list: paths.slice(0, SNAPSHOT_DIRTY_TREE_LIST_CAP), more: paths.length - SNAPSHOT_DIRTY_TREE_LIST_CAP };
}
function isExecutable(mode) { return (mode & 0o111) !== 0; }
/**
* Stat every candidate path, restricted to what is actually present on disk
* (a deleted/absent path is silently dropped — that IS the encoding of a
* deletion). Sizes ride along so the caller can enforce the per-object cap
* before any object is written. Shared by every capture lane (the ordinary
* tracked-plus-untracked enumeration below, and the handoff capture's
* tracked-only / untracked-only enumerations) so they can never drift into
* disagreeing about what "present on disk" means for one path.
*/
function statPresentPaths(top, candidates) {
const present = [];
for (const p of candidates) {
const abs = join(top, p);
let st;
try {
st = lstatSync(abs);
}
catch {
continue;
} // deleted in the work tree → absent from the snapshot
if (st.isSymbolicLink())
present.push({ path: p, mode: "120000", size: st.size });
else if (st.isFile())
present.push({ path: p, mode: isExecutable(st.mode) ? "100755" : "100644", size: st.size });
// directories (e.g. nested repos) and specials are skipped — git would not track them either
}
return present;
}
/**
* Enumerate the captured set: tracked ∪ untracked-but-not-ignored, restricted
* to what is actually present on disk.
*
* This is the ONE enumeration both the capture and the correspondence check
* use, so the two can never drift into disagreeing about what "the captured
* set" means — which would show up as a deploy that refuses an untouched tree.
*/
const isPlatformRuntimePath = (path) => path.split("/").includes(".run402");
async function enumerateCapturedPaths(top, excludeArgs, untracked) {
const others = untracked ?? (await hardenedGit(top, ["ls-files", "-z", "--others", "--exclude-standard", ...excludeArgs])).nul();
const tracked = (await hardenedGit(top, ["ls-files", "-z", "--cached"])).nul();
// Platform-owned runtime state is never source, even if accidentally tracked.
const candidates = [...new Set([...tracked, ...others])]
.filter((path) => !isPlatformRuntimePath(path))
.sort();
return statPresentPaths(top, candidates);
}
/**
* Hash the enumerated set's RAW bytes (`--no-filters`, so no clean/smudge
* command is ever started). `write` persists the blobs — the capture needs
* them to build a tree; the correspondence check does not and must not.
*/
async function hashCapturedPaths(top, present, write, context) {
const entries = [];
const writeArgs = write ? ["-w"] : [];
const regular = present.filter((e) => e.mode !== "120000");
if (regular.length > 0) {
const out = await hardenedGit(top, ["hash-object", ...writeArgs, "--no-filters", "--stdin-paths"], { input: regular.map((e) => e.path).join("\n") + "\n" });
const oids = out.lines();
if (oids.length !== regular.length)
fail("GIT_COMMAND_FAILED", "hash-object returned a different number of ids than paths", context);
regular.forEach((e, i) => entries.push({ path: e.path, mode: e.mode, oid: oids[i] }));
}
for (const e of present.filter((x) => x.mode === "120000")) {
const target = readlinkSync(join(top, e.path));
const out = await hardenedGit(top, ["hash-object", ...writeArgs, "--no-filters", "--stdin"], { input: target });
entries.push({ path: e.path, mode: "120000", oid: out.text().trim() });
}
// regular files were hashed in one batch and symlinks after them — restore path order.
entries.sort((a, b) => (a.path === b.path ? 0 : a.path < b.path ? -1 : 1));
return entries;
}
/**
* `sha256` over the captured set, in git's own index-info line form
* (`<mode> <oid>\t<path>\n`, path-sorted) so the serialization is the one the
* tree is built from rather than a second, drifting encoding.
*
* WHAT THIS COVERS, EXACTLY: the captured source set — tracked plus
* untracked-but-not-ignored. It does NOT cover gitignored paths, so a build
* that rewrites `dist/` between capture and commit changes nothing here, by
* design: a project with a build step would otherwise be undeployable. The
* guarantee this digest buys is "the captured source did not change while the
* artifacts were produced", NOT "the artifacts are a function of that source".
*/
export function capturedSetDigest(files) {
const sorted = [...files].sort((a, b) => (a.path === b.path ? 0 : a.path < b.path ? -1 : 1));
return sha256Hex(new TextEncoder().encode(sorted.map((e) => `${e.mode} ${e.oid}\t${e.path}\n`).join("")));
}
/**
* Re-derive the captured set from the work tree, without writing any object.
*
* `global_excludes_path` is the file the capture honored — passed through
* rather than rediscovered, so the comparison is about the TREE and cannot be
* perturbed by the user's git config changing mid-deploy.
*/
export async function deriveCapturedSet(options) {
const excludeArgs = options.global_excludes_path ? ["--exclude-from", options.global_excludes_path] : [];
const present = await enumerateCapturedPaths(options.top_level, excludeArgs);
return hashCapturedPaths(options.top_level, present, false, "re-deriving the captured set");
}
/** Name the paths that moved — added, removed, or changed content/mode. */
export function diffCapturedSets(before, after) {
const b = new Map(before.map((e) => [e.path, e]));
const a = new Map(after.map((e) => [e.path, e]));
const drift = { added: [], removed: [], modified: [] };
for (const [path, entry] of a) {
const prior = b.get(path);
if (!prior)
drift.added.push(path);
else if (prior.oid !== entry.oid || prior.mode !== entry.mode)
drift.modified.push(path);
}
for (const path of b.keys())
if (!a.has(path))
drift.removed.push(path);
drift.added.sort();
drift.removed.sort();
drift.modified.sort();
return drift;
}
/** True when nothing in the captured set moved. */
export function capturedSetUnchanged(drift) {
return drift.added.length === 0 && drift.removed.length === 0 && drift.modified.length === 0;
}
/**
* Capture the work tree per the §6.6 policy. Branches, index, and work tree
* are left byte-identical; a synthetic commit lives only in the object
* database (plus the local deploy ref when enabled).
*/
export async function captureSnapshot(options) {
const repo = await inspectRepository(options.dir);
const top = repo.top_level;
if (repo.unmerged_paths.length > 0) {
fail("SNAPSHOT_CONFLICTED_INDEX", `the index has unmerged paths; resolve the merge conflict, then redeploy`, "capturing snapshot", { paths: repo.unmerged_paths });
}
// Ignore authority — discovered and read AS DATA before git sees anything.
const excludes = discoverGlobalExcludes(options.env);
const excludeArgs = excludes.path ? ["--exclude-from", excludes.path] : [];
const headTarget = repo.head.kind === "symref" ? { kind: "symref", ref: repo.head.ref } : { kind: "detached", oid: repo.head.oid };
const headOid = repo.head.resolved_oid;
// Dirty detection: index/worktree vs HEAD, plus untracked-not-ignored.
// Both lists are collected in full (not just a boolean) — SNAPSHOT_DIRTY_TREE
// discloses them below, and an `allowDirty` capture discloses them again as
// `modified_captured` / `untracked_captured` on the result.
const untracked = (await hardenedGit(top, ["ls-files", "-z", "--others", "--exclude-standard", ...excludeArgs])).nul().filter((path) => !isPlatformRuntimePath(path));
let modified;
if (!headOid) {
modified = (await hardenedGit(top, ["ls-files", "-z"])).nul(); // unborn HEAD with a populated index — every entry is new
}
else {
await hardenedGit(top, ["update-index", "-q", "--refresh"], { okStatuses: [1] });
modified = (await hardenedGit(top, ["diff-index", "--name-only", "-z", "HEAD", "--"])).nul();
}
modified = modified.filter((path) => !isPlatformRuntimePath(path));
const hasTrackedRuntime = (await hardenedGit(top, ["ls-files", "-z", "--cached"])).nul().some(isPlatformRuntimePath);
const dirty = untracked.length > 0 || modified.length > 0;
// Fail fast and free: this refusal fires BEFORE any object is created,
// hashed, or uploaded — Tal's decision "help people not make mistakes"
// applies to both capture lanes (manual `repos capture` and the
// deploy-lane capture), and to `--dry-run` (which reaches this same
// function — a preview that hid the refusal would lie).
if (dirty && options.allowDirty !== true) {
const modifiedCap = cappedPathList(modified);
const untrackedCap = cappedPathList(untracked);
fail("SNAPSHOT_DIRTY_TREE", `the work tree is dirty (${modified.length} modified/staged tracked path(s), ${untracked.length} untracked-not-ignored path(s)) — refusing to capture by default. Commit your changes and retry, or capture the tree as-is with allowDirty:true (SDK) / --allow-dirty (CLI); the result will disclose exactly what was swept in.`, "capturing snapshot", {
modified: modifiedCap.list,
modified_more: modifiedCap.more,
untracked: untrackedCap.list,
untracked_more: untrackedCap.more,
// An unborn HEAD (the agent ran `git init` and never committed) is
// the first-push shape: everything is untracked, nothing is wrong.
...(headOid ? {} : { unborn: true }),
}, headOid
? [
{ type: "edit_request", why: "Commit your changes, then retry." },
{ type: "edit_request", why: "Or capture the tree as-is: pass allowDirty:true (SDK) / --allow-dirty (CLI)." },
]
: [
{ type: "commit_changes", command: "git add -A && git commit -m init", why: "This repository has no commits yet. Commit first (git add -A && git commit -m init), or pass --allow-dirty to capture the working tree as-is." },
{ type: "edit_request", why: "Or capture the tree as-is: pass allowDirty:true (SDK) / --allow-dirty (CLI)." },
]);
}
if (!dirty && headOid && !hasTrackedRuntime) {
const tree = (await hardenedGit(top, ["rev-parse", "HEAD^{tree}"])).text().trim();
// The captured set is recorded for a clean tree too, through the SAME
// enumerate+hash path the dirty branch uses. Reading it off `HEAD`'s tree
// instead would be cheaper and wrong: a filtered path's committed blob is
// the CLEANED content while the work tree holds the SMUDGED bytes, so an
// untouched tree would re-derive differently and refuse its own deploy.
const cleanPresent = await enumerateCapturedPaths(top, excludeArgs, untracked);
const cleanCaptured = await hashCapturedPaths(top, cleanPresent, false, "capturing snapshot");
return { kind: "head", oid: headOid, tree_oid: tree, head: headTarget, head_oid: headOid, paths: [], captured: cleanCaptured, captured_digest: capturedSetDigest(cleanCaptured), global_excludes_path: excludes.path, top_level: top, modified_captured: [], untracked_captured: [] };
}
// Synthetic commit: tracked paths present on disk + untracked-not-ignored.
// Reached only when `dirty` (an unborn HEAD with an empty index is NOT
// dirty and falls through to here too — `headOid` is null, `modified`/
// `untracked` are both empty, and the loop below simply commits nothing).
const present = await enumerateCapturedPaths(top, excludeArgs, untracked);
// Size cap before any object is written.
const oversize = present.find((e) => e.size > VAULT_MAX_GIT_OBJECT_BYTES);
if (oversize) {
fail("GIT_OBJECT_TOO_LARGE", `${oversize.path} is ${oversize.size} bytes; the per-object cap is ${VAULT_MAX_GIT_OBJECT_BYTES} bytes (200 MiB)`, "capturing snapshot", { path: oversize.path, size_bytes: String(oversize.size), cap_bytes: String(VAULT_MAX_GIT_OBJECT_BYTES) });
}
// Filter detection over EVERY included path (LFS is a filter) — refuse by name, never start one.
const active = await detectActiveFilters(top, present.map((e) => e.path));
if (active.length > 0) {
const lfs = active.filter((a) => a.filter === "lfs");
const code = lfs.length > 0 ? "VAULT_LFS_UNSUPPORTED" : "VAULT_FILTER_ACTIVE";
fail(code, `${active.length} captured path(s) have an active clean/smudge filter (${[...new Set(active.map((a) => a.filter))].join(", ")}); vault never runs filters, so the capture is refused by name — first: ${active[0].path}`, "capturing snapshot", { paths: active });
}
// Hash raw bytes with filters structurally disabled (`-w`: the tree below needs the blobs).
const entries = await hashCapturedPaths(top, present, true, "capturing snapshot");
// Build the tree in a TEMPORARY index so the user's index stays byte-identical.
const tmp = mkdtempSync(join(tmpdir(), "run402-vault-snapshot-"));
try {
const indexFile = join(tmp, "index");
const info = entries.map((e) => `${e.mode} ${e.oid}\t${e.path}\n`).join("");
await hardenedGit(top, ["update-index", "--index-info"], { input: info, env: { GIT_INDEX_FILE: indexFile } });
const tree = (await hardenedGit(top, ["write-tree"], { env: { GIT_INDEX_FILE: indexFile } })).text().trim();
const when = Math.floor((options.now ?? (() => new Date()))().getTime() / 1000);
const dateEnv = { GIT_AUTHOR_DATE: `${when} +0000`, GIT_COMMITTER_DATE: `${when} +0000` };
const parentArgs = headOid ? ["-p", headOid] : [];
const commit = (await hardenedGit(top, ["commit-tree", tree, ...parentArgs, "-m", options.message ?? "run402 repos capture (synthetic commit of the work tree)"], { env: dateEnv })).text().trim();
if (options.update_local_deploy_ref !== false) {
await hardenedGit(top, ["update-ref", VAULT_DEPLOY_REF, commit]);
}
// Disclosure (allowDirty override): restrict `modified`/`untracked` to
// paths that actually landed in the captured set — a `modified` path
// that was deleted in the work tree is real drift but is not itself
// CAPTURED (it is simply absent, like any other deleted path).
const presentPaths = new Set(present.map((e) => e.path));
return {
kind: "synthetic", oid: commit, tree_oid: tree, head: headTarget, head_oid: headOid,
paths: entries.map((e) => e.path), captured: entries, captured_digest: capturedSetDigest(entries),
global_excludes_path: excludes.path, top_level: top,
modified_captured: modified.filter((p) => presentPaths.has(p)),
untracked_captured: untracked.filter((p) => presentPaths.has(p)),
};
}
finally {
rmSync(tmp, { recursive: true, force: true });
}
}
/** `git check-attr filter` over paths; returns every path whose `filter` attribute is set (LFS included). */
export async function detectActiveFilters(top, paths) {
if (paths.length === 0)
return [];
const out = await hardenedGit(top, ["check-attr", "-z", "--stdin", "filter"], { input: paths.join("\0") + "\0" });
const parts = out.nul();
const active = [];
for (let i = 0; i + 2 < parts.length; i += 3) {
const value = parts[i + 2];
if (value !== "unspecified" && value !== "unset")
active.push({ path: parts[i], filter: value });
}
return active;
}
// ─── Handoff capture (kygit-handoff design D1/D10) ──────────────────────────
/**
* Sensitive untracked-path denylist (design D10, verbatim). Applies to the
* UNTRACKED set only — a tracked file is the user's own committed choice and
* is never filtered by this list. `--include-sensitive <glob>` re-admits a
* named path by matching the SAME glob grammar against the candidate.
*/
export const VAULT_HANDOFF_SENSITIVE_DENYLIST = [
".env",
".env.*",
"*.pem",
"*.key",
"*.p12",
"*.pfx",
"*.jks",
"*.keystore",
"id_rsa*",
"id_ed25519*",
"id_ecdsa*",
".npmrc",
".netrc",
".pypirc",
".git-credentials",
"*.tfstate*",
"*credentials*.json",
".aws/**",
".ssh/**",
".gnupg/**",
"*.secret",
"secrets.*",
];
function escapeGlobLiteral(ch) {
return /[.+^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
}
/** `*` → any run of non-`/` chars, `**` → any run of chars (incl. `/`), `?` → one non-`/` char, everything else literal. */
function globToRegExpSource(glob) {
let out = "";
for (let i = 0; i < glob.length; i++) {
const c = glob[i];
if (c === "*") {
if (glob[i + 1] === "*") {
out += ".*";
i++;
}
else
out += "[^/]*";
}
else if (c === "?") {
out += "[^/]";
}
else {
out += escapeGlobLiteral(c);
}
}
return out;
}
/**
* A slash-free glob matches the path's BASENAME at any depth (gitignore's
* "no slash" rule). A glob ending `/**` (e.g. `.aws/**`) matches the named
* directory component at ANY depth in the tree, plus everything under it —
* a deliberately broader-than-gitignore reading for a credential-directory
* denylist, where over-excluding is the safe failure mode. Any other
* slash-bearing glob matches the full relative path exactly.
*/
export function globMatchesGitPath(glob, relPath) {
if (!glob.includes("/")) {
const re = new RegExp(`^${globToRegExpSource(glob)}$`);
const base = relPath.slice(relPath.lastIndexOf("/") + 1);
return re.test(base);
}
if (glob.endsWith("/**")) {
const dir = glob.slice(0, -3);
const dirRe = new RegExp(`(^|/)${globToRegExpSource(dir)}(/|$)`);
return dirRe.test(relPath);
}
const re = new RegExp(`^${globToRegExpSource(glob)}$`);
return re.test(relPath);
}
/** True iff `relPath` (an untracked-not-ignored path) matches the sensitive denylist and is not re-admitted by `includeSensitive`. */
export function isHandoffSensitivePath(relPath, includeSensitive = []) {
if (includeSensitive.some((g) => globMatchesGitPath(g, relPath)))
return false;
return VAULT_HANDOFF_SENSITIVE_DENYLIST.some((g) => globMatchesGitPath(g, relPath));
}
/**
* Capture a stash-shaped handoff checkpoint. Reuses every hardened-execution
* guarantee `captureSnapshot` already provides (filter-free hashing, frozen
* ignore authority, refusal-by-name for conflicted/linked/submodule/sparse/
* shallow/LFS layouts) via {@link inspectRepository} + {@link
* detectActiveFilters}; the shape below is the ONLY thing new.
*/
export async function captureHandoffSnapshot(options) {
const repo = await inspectRepository(options.dir);
const top = repo.top_level;
if (repo.unmerged_paths.length > 0) {
fail("SNAPSHOT_CONFLICTED_INDEX", `the index has unmerged paths; resolve the merge conflict, then hand off`, "capturing handoff snapshot", { paths: repo.unmerged_paths });
}
if (!repo.head.resolved_oid) {
fail("HANDOFF_NO_BASE_COMMIT", "the repository has no commits yet (unborn HEAD) — a handoff needs a base commit to clone from; make an initial commit first", "capturing handoff snapshot", { dir: top });
}
const baseHeadOid = repo.head.resolved_oid;
const headTarget = repo.head.kind === "symref" ? { kind: "symref", ref: repo.head.ref } : { kind: "detached", oid: repo.head.oid };
const excludes = discoverGlobalExcludes(options.env);
const excludeArgs = excludes.path ? ["--exclude-from", excludes.path] : [];
const trackedList = (await hardenedGit(top, ["ls-files", "-z", "--cached"])).nul();
const untrackedList = (await hardenedGit(top, ["ls-files", "-z", "--others", "--exclude-standard", ...excludeArgs])).nul();
const ignoredList = (await hardenedGit(top, ["ls-files", "-z", "--others", "--ignored", "--exclude-standard", ...excludeArgs])).nul();
await hardenedGit(top, ["update-index", "-q", "--refresh"], { okStatuses: [1] });
const modified = (await hardenedGit(top, ["diff-index", "--name-only", "-z", "HEAD", "--"])).nul();
const includeSensitive = options.includeSensitive ?? [];
const sensitiveExcluded = [];
const admittedUntracked = [];
for (const p of untrackedList) {
if (isHandoffSensitivePath(p, includeSensitive))
sensitiveExcluded.push(p);
else
admittedUntracked.push(p);
}
const trackedPresent = statPresentPaths(top, trackedList);
const untrackedPresent = statPresentPaths(top, admittedUntracked);
const oversize = [...trackedPresent, ...untrackedPresent].find((e) => e.size > VAULT_MAX_GIT_OBJECT_BYTES);
if (oversize) {
fail("GIT_OBJECT_TOO_LARGE", `${oversize.path} is ${oversize.size} bytes; the per-object cap is ${VAULT_MAX_GIT_OBJECT_BYTES} bytes (200 MiB)`, "capturing handoff snapshot", { path: oversize.path, size_bytes: String(oversize.size), cap_bytes: String(VAULT_MAX_GIT_OBJECT_BYTES) });
}
const allIncludedPaths = [...trackedPresent.map((e) => e.path), ...untrackedPresent.map((e) => e.path)];
const active = await detectActiveFilters(top, allIncludedPaths);
if (active.length > 0) {
const lfs = active.filter((a) => a.filter === "lfs");
const code = lfs.length > 0 ? "VAULT_LFS_UNSUPPORTED" : "VAULT_FILTER_ACTIVE";
fail(code, `${active.length} captured path(s) have an active clean/smudge filter (${[...new Set(active.map((a) => a.filter))].join(", ")}); vault never runs filters, so the capture is refused by name — first: ${active[0].path}`, "capturing handoff snapshot", { paths: active });
}
const trackedEntries = await hashCapturedPaths(top, trackedPresent, true, "capturing handoff snapshot");
const untrackedEntries = await hashCapturedPaths(top, untrackedPresent, true, "capturing handoff snapshot");
const when = Math.floor((options.now ?? (() => new Date()))().getTime() / 1000);
const dateEnv = { GIT_AUTHOR_DATE: `${when} +0000`, GIT_COMMITTER_DATE: `${when} +0000` };
async function buildTree(entries) {
const tmp = mkdtempSync(join(tmpdir(), "run402-vault-handoff-"));
try {
const indexFile = join(tmp, "index");
const info = entries.map((e) => `${e.mode} ${e.oid}\t${e.path}\n`).join("");
await hardenedGit(top, ["update-index", "--index-info"], { input: info, env: { GIT_INDEX_FILE: indexFile } });
return (await hardenedGit(top, ["write-tree"], { env: { GIT_INDEX_FILE: indexFile } })).text().trim();
}
finally {
rmSync(tmp, { recursive: true, force: true });
}
}
// parent 1: base HEAD (already exists — no object to build).
// parent 2: the index commit — tree = the REAL index (no synthetic
// rebuild needed, its blobs are already objects), parent = base HEAD
// (matches real `git stash`'s own index-commit shape, verified against
// git 2.43's plumbing output).
const indexTreeOid = (await hardenedGit(top, ["write-tree"])).text().trim();
const indexCommitOid = (await hardenedGit(top, ["commit-tree", indexTreeOid, "-p", baseHeadOid, "-m", "vault handoff: index"], { env: dateEnv })).text().trim();
// parent 3: the untracked commit — parentless, tree = admitted untracked
// set (the empty tree when nothing survives the denylist).
const untrackedTreeOid = await buildTree(untrackedEntries);
const untrackedCommitOid = (await hardenedGit(top, ["commit-tree", untrackedTreeOid, "-m", "vault handoff: untracked"], { env: dateEnv })).text().trim();
const trackedPresentSet = new Set(trackedPresent.map((e) => e.path));
const untrackedPresentSet = new Set(untrackedPresent.map((e) => e.path));
const modifiedCaptured = modified.filter((p) => trackedPresentSet.has(p));
const untrackedCaptured = admittedUntracked.filter((p) => untrackedPresentSet.has(p));
const stats = {
base_head_oid: baseHeadOid,
branch: headTarget.kind === "symref" ? headTarget.ref.replace(/^refs\/heads\//, "") : null,
modified_captured: modifiedCaptured,
untracked_captured: untrackedCaptured,
sensitive_excluded: [...sensitiveExcluded].sort(),
ignored_not_transferred_count: ignoredList.length,
};
const message = typeof options.message === "function" ? options.message(stats) : options.message;
// The outer commit: tree = tracked worktree state; three parents; the
// Handoff Note as its message. ALWAYS created — a clean tree still
// produces this commit (one shape, one code path).
const worktreeTreeOid = await buildTree(trackedEntries);
const outerOid = (await hardenedGit(top, ["commit-tree", worktreeTreeOid, "-p", baseHeadOid, "-p", indexCommitOid, "-p", untrackedCommitOid, "-m", message], { env: dateEnv })).text().trim();
const captured = [...trackedEntries, ...untrackedEntries];
return {
oid: outerOid,
tree_oid: worktreeTreeOid,
base_head_oid: baseHeadOid,
index_commit_oid: indexCommitOid,
untracked_commit_oid: untrackedCommitOid,
head: headTarget,
top_level: top,
modified_captured: stats.modified_captured,
untracked_captured: stats.untracked_captured,
sensitive_excluded: stats.sensitive_excluded,
ignored_not_transferred_count: stats.ignored_not_transferred_count,
captured,
captured_digest: capturedSetDigest(captured),
global_excludes_path: excludes.path,
};
}
// ─── Materialization (the deploy lane builds from THIS, never the work tree) ─
/**
* Check out `oid`'s tree into `targetDir` through a temporary index
* (`read-tree` + `checkout-index`) — filter-free, hook-free, and isolated
* from the mutable work tree. Returns the absolute target directory.
*/
export async function materializeSnapshot(repoDir, oid, targetDir) {
if (!VAULT_OID40_RE.test(oid))
fail("VAULT_BAD_OID", `not a 40-hex oid: ${oid}`, "materializing snapshot");
const top = (await hardenedGit(resolve(repoDir), ["rev-parse", "--show-toplevel"])).text().trim();
const target = targetDir ? resolve(targetDir) : mkdtempSync(join(tmpdir(), "run402-vault-materialize-"));
mkdirSync(target, { recursive: true });
const tmp = mkdtempSync(join(tmpdir(), "run402-vault-index-"));
try {
const indexFile = join(tmp, "index");
await hardenedGit(top, ["read-tree", `${oid}^{tree}`], { env: { GIT_INDEX_FILE: indexFile } });
await hardenedGit(top, ["-c", "core.autocrlf=false", "checkout-index", "-a", "-f", `--prefix=${target.endsWith("/") ? target : target + "/"}`], { env: { GIT_INDEX_FILE: indexFile } });
}
finally {
rmSync(tmp, { recursive: true, force: true });
}
return target;
}
// ─── Commitment ──────────────────────────────────────────────────────────────
/** `snapshot_oid_hmac` = HMAC(K_digest("snapshot_oid"), JCS({oid, format:"sha1"})) — the keyed commitment the platform compares without ever seeing the oid. */
export function snapshotCommitment(kRepo, repoId, epoch, oid) {
return keyedCommitment(deriveDigestKey(kRepo, repoId, epoch, "snapshot_oid"), snapshotOidContent(oid));
}
/** The client-side `vault_commit` line — printed ALWAYS; commit ids never reach the platform. */
export function vaultCommitLine(snapshot) {
return `vault_commit ${snapshot.oid}${snapshot.kind === "synthetic" ? " (synthetic)" : ""}`;
}
/** Object sizes for a set of oids (`cat-file --batch-check`), for the per-object cap on tracked history. */
export async function findOversizeObjects(repoDir, tips, cap = VAULT_MAX_GIT_OBJECT_BYTES) {
if (tips.length === 0)
return [];
const objects = (await hardenedGit(repoDir, ["rev-list", "--objects", ...tips])).lines().map((l) => l.split(" ")[0]);
const out = await hardenedGit(repoDir, ["cat-file", "--batch-check=%(objectname) %(objectsize)"], { input: objects.join("\n") + "\n" });
const over = [];
for (const line of out.lines()) {
const [oid, size] = line.split(" ");
if (oid && size && Number(size) > cap)
over.push({ oid, size_bytes: Number(size) });
}
return over;
}
/**
* True iff `ancestor` is reachable from `descendant` (`merge-base --is-ancestor`).
* A commit this object store does not hold at all (git exits 128, "Not a
* valid commit name") answers FALSE, never throws: on a multi-writer vault a
* ref can name a commit only another writer's clone has (a handoff
* checkpoint minted elsewhere), and the callers use this answer to decide
* what to RETAIN — "not provably reachable" keeps the tip as a root, the
* conservative outcome, whereas a throw refused every push and mint from a
* checkout that had not seen the other writer's objects.
*/
export async function isAncestor(repoDir, ancestor, descendant) {
const r = await hardenedGit(repoDir, ["merge-base", "--is-ancestor", ancestor, descendant], { okStatuses: [1, 128] });
return r.status === 0;
}
/** Does the object exist locally? */
export async function hasObject(repoDir, oid) {
const r = await hardenedGit(repoDir, ["cat-file", "-e", `${oid}^{object}`], { okStatuses: [1, 128] });
return r.status === 0;
}
/**
* Which of `oids` exist locally — ONE `cat-file --batch-check` process for
* the whole set (vault-delta-fetch task 3.1), identical per-oid
* semantics to {@link hasObject}. `--batch-check` answers `<oid> <type>
* <size>` for a present object and `<oid> missing` otherwise, one line per
* input line, so presence is exactly "second column is not `missing`".
*/
export async function hasObjects(repoDir, oids) {
if (oids.length === 0)
return new Set();
const input = new TextEncoder().encode(oids.map((o) => `${o}\n`).join(""));
const r = await hardenedGit(repoDir, ["cat-file", "--batch-check"], { input, okStatuses: [1, 128] });
const present = new Set();
for (const line of r.stdout.toString("utf8").split("\n")) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 2 && parts[1] !== "missing" && parts[1] !== "ambiguous")
present.add(parts[0]);
}
return present;
}
//# sourceMappingURL=vault-snapshot.js.map