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.
443 lines • 23.8 kB
TypeScript
/**
* 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 type { VaultHeadTarget } from "../namespaces/vault.types.js";
/** `MAX_GIT_OBJECT_BYTES` — any single object above this is `GIT_OBJECT_TOO_LARGE`. */
export declare const VAULT_MAX_GIT_OBJECT_BYTES: number;
/** `refs/run402/deploys/latest` — the single protocol-owned deploy ref (round-3 H9). */
export declare const VAULT_DEPLOY_REF = "refs/run402/deploys/latest";
/** Probed git version range: `[2.32, ∞)` — `GIT_CONFIG_GLOBAL` landed in 2.32. */
export declare const VAULT_MIN_GIT_VERSION: readonly [2, 32];
export interface HardenedGitOptions {
/** Bytes written to stdin (closed immediately when omitted). */
input?: Uint8Array | string;
/** Extra environment members — only `GIT_INDEX_FILE` and the author/committer identity are ever passed here. */
env?: Record<string, string>;
/** Accept these non-zero exit statuses instead of throwing. */
okStatuses?: number[];
/** Max stdout bytes (packs are large). Default 1 GiB. */
maxBuffer?: number;
}
export interface HardenedGitResult {
status: number;
stdout: Buffer;
stderr: string;
text(): string;
lines(): string[];
/** NUL-separated output → entries (trailing NUL dropped). */
nul(): string[];
}
/** The environment every vault git process runs under — nothing inherited except PATH. */
export declare function hardenedGitEnv(extra?: Record<string, string>): Record<string, string>;
/**
* 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 declare function run402PassthroughEnv(source?: NodeJS.ProcessEnv): Record<string, string>;
/** The argv prefix that neutralizes hooks, fsmonitor, replace refs, and filter autodetection for every invocation. */
export declare const HARDENED_GIT_ARGV_PREFIX: () => string[];
/**
* 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 declare function hardenedGit(cwd: string, args: string[], options?: HardenedGitOptions): Promise<HardenedGitResult>;
/**
* The repository a `git-remote-*` helper was invoked for.
*
* `repo_dir` is what every hardened git call takes as `cwd`. Because
* {@link hardenedGitEnv} builds its environment from scratch — `GIT_DIR` is
* never inherited — git's discovery starts at that directory, and starting it
* AT the git directory makes discovery land on that repository and no other.
*/
export interface GitInvocationRepo {
/** The absolute git directory git named for this invocation. */
git_dir: string;
/** Hand this to `hardenedGit` as `cwd`; discovery from here resolves to `git_dir`. */
repo_dir: string;
}
/**
* 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 declare function resolveGitInvocationRepo(env?: {
GIT_DIR?: string | undefined;
}, cwd?: string): Promise<GitInvocationRepo>;
/** `git version` parsed as [major, minor]; refuses below {@link VAULT_MIN_GIT_VERSION}. */
export declare function probeGitVersion(cwd?: string): Promise<[number, number]>;
export interface GitConfigDiscoveryEnv {
HOME?: string;
XDG_CONFIG_HOME?: string;
}
/** The frozen config subset: `[section "sub"]` headers + `key = value` lines; quoting per git-config; comments `#`/`;`. */
export interface ParsedGitConfigEntry {
section: string;
subsection: string | null;
key: string;
value: string;
file: string;
}
export type GlobalExcludesRefusal = {
code: "VAULT_CONFIG_INCLUDE_REFUSED";
file: string;
key: string;
} | {
code: "VAULT_CONFIG_UNREADABLE";
file: string;
} | {
code: "VAULT_EXCLUDES_PATH_UNSUPPORTED";
file: string;
value: string;
} | {
code: "VAULT_EXCLUDES_UNREADABLE";
file: string;
};
export interface GlobalExcludesDiscovery {
/** The resolved excludes path — `null` when no file exists (an absent DEFAULT path is fine; an absent EXPLICIT `core.excludesFile` is also fine — git ignores it too). */
path: string | null;
/** Which config (if any) set `core.excludesFile`. */
source: "core.excludesFile" | "default";
/** The config files that were read (in read order). */
configs_read: string[];
/** The pattern bytes, read as data, when `path` exists. */
patterns: string | null;
}
/** Parse a git config file as data (frozen subset). Throws by name on `include`/`includeIf`. */
export declare function parseGitConfigAsData(text: string, file: string): ParsedGitConfigEntry[];
/**
* 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 declare function discoverGlobalExcludes(env?: GitConfigDiscoveryEnv): GlobalExcludesDiscovery;
export type VaultRepositoryRefusalCode = "VAULT_BARE_REPOSITORY" | "VAULT_LINKED_WORKTREE" | "VAULT_SPARSE_CHECKOUT" | "VAULT_SHALLOW_CLONE" | "VAULT_SUBMODULES_UNSUPPORTED" | "GIT_OBJECT_FORMAT_UNSUPPORTED" | "VAULT_ALTERNATES_UNSUPPORTED" | "VAULT_GRAFTS_UNSUPPORTED" | "VAULT_CONFIG_INCLUDE_REFUSED" | "VAULT_NOT_A_REPOSITORY";
export interface VaultRepositoryInspection {
top_level: string;
git_dir: string;
git_common_dir: string;
object_format: "sha1";
head: VaultHeadTarget & {
resolved_oid: string | null;
};
unmerged_paths: string[];
}
/** Inspect `dir`; every unsupported layout is refused with its named code. */
export declare function inspectRepository(dir: string): Promise<VaultRepositoryInspection>;
export interface VaultSnapshotOptions {
/** The work tree (any directory inside it). */
dir: string;
/** Discovery environment; defaults to the process's HOME / XDG_CONFIG_HOME. */
env?: GitConfigDiscoveryEnv;
/** When set, also move the LOCAL `refs/run402/deploys/latest` to the snapshot (keeps a synthetic commit reachable across `git gc`). Default true. */
update_local_deploy_ref?: boolean;
/** Commit message for a synthetic commit. */
message?: string;
/** Clock (synthetic commit timestamps). */
now?: () => Date;
/**
* Capture a dirty tree anyway. Default `false` — a dirty tree (any
* modified/staged tracked path, or any untracked-not-ignored path) is
* refused with `SNAPSHOT_DIRTY_TREE` before any object is created, in
* BOTH capture lanes (manual `repos capture` and the deploy-lane
* capture). Has no effect on a clean tree, and never overrides
* `SNAPSHOT_CONFLICTED_INDEX` (an unmerged index is checked first and
* refuses regardless). See {@link VaultSnapshot.modified_captured} /
* {@link VaultSnapshot.untracked_captured} for the disclosure this
* produces once set.
*/
allowDirty?: boolean;
}
/** How many dirty-tree paths `SNAPSHOT_DIRTY_TREE`'s `details.modified`/`details.untracked` list before capping. */
export declare const SNAPSHOT_DIRTY_TREE_LIST_CAP = 500;
export interface VaultSnapshotRefusalPath {
path: string;
filter: string;
}
export interface VaultSnapshot {
/** `head` = the tree was clean and the snapshot IS `HEAD`; `synthetic` = a commit was created for a dirty tree. */
kind: "head" | "synthetic";
/** The snapshot commit oid — the `vault_commit`. */
oid: string;
/** The tree the snapshot resolves to. */
tree_oid: string;
/** What `HEAD` was at capture — symref (possibly unborn) or detached. */
head: VaultHeadTarget;
/** `HEAD`'s commit when it had one. */
head_oid: string | null;
/** Paths included in a synthetic commit (tracked + untracked-not-ignored); empty for `head`. */
paths: string[];
/**
* The CAPTURED SET, always populated for both kinds: every tracked or
* untracked-but-not-ignored path present on disk at capture, with the mode
* and the raw-bytes oid the snapshot corresponds to. This is the set the
* correspondence check re-derives — NOT the whole working directory, and
* deliberately NOT gitignored build output (see {@link capturedSetDigest}).
*/
captured: VaultCapturedFile[];
/** `sha256` over {@link captured} — the value re-derived before a plan commits. */
captured_digest: string;
/** The global excludes file honored (as data), when one existed. */
global_excludes_path: string | null;
top_level: string;
/**
* Disclosure for an `allowDirty: true` capture: tracked paths that were
* modified/staged relative to `HEAD` (or, on an unborn `HEAD`, every
* populated index path) and got swept into the capture. Empty for a clean
* tree (`kind: "head"`) — there was nothing to sweep in.
*/
modified_captured: string[];
/**
* Disclosure for an `allowDirty: true` capture: untracked-not-ignored
* paths that got swept into the capture. Empty for a clean tree
* (`kind: "head"`).
*/
untracked_captured: string[];
}
/** One member of the captured set: path, mode, and the oid of its RAW bytes (`--no-filters`). */
export interface VaultCapturedFile {
path: string;
mode: "100644" | "100755" | "120000";
oid: string;
}
/**
* `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 declare function capturedSetDigest(files: VaultCapturedFile[]): string;
/**
* 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 declare function deriveCapturedSet(options: {
top_level: string;
global_excludes_path?: string | null;
}): Promise<VaultCapturedFile[]>;
/** What changed between two captured sets. Empty on every list means the set is byte-identical. */
export interface VaultCapturedSetDrift {
added: string[];
removed: string[];
modified: string[];
}
/** Name the paths that moved — added, removed, or changed content/mode. */
export declare function diffCapturedSets(before: VaultCapturedFile[], after: VaultCapturedFile[]): VaultCapturedSetDrift;
/** True when nothing in the captured set moved. */
export declare function capturedSetUnchanged(drift: VaultCapturedSetDrift): boolean;
/**
* 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 declare function captureSnapshot(options: VaultSnapshotOptions): Promise<VaultSnapshot>;
/** `git check-attr filter` over paths; returns every path whose `filter` attribute is set (LFS included). */
export declare function detectActiveFilters(top: string, paths: string[]): Promise<VaultSnapshotRefusalPath[]>;
/**
* 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 declare const VAULT_HANDOFF_SENSITIVE_DENYLIST: readonly string[];
/**
* 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 declare function globMatchesGitPath(glob: string, relPath: string): boolean;
/** True iff `relPath` (an untracked-not-ignored path) matches the sensitive denylist and is not re-admitted by `includeSensitive`. */
export declare function isHandoffSensitivePath(relPath: string, includeSensitive?: readonly string[]): boolean;
/** What {@link VaultHandoffCaptureOptions.message} learns once capture figures are known — everything a Handoff Note's `capture` block needs, before the outer commit exists. */
export interface VaultHandoffCaptureStats {
base_head_oid: string;
/** `HEAD`'s symref name (`refs/heads/<branch>`, stripped) — `null` on a detached HEAD. */
branch: string | null;
modified_captured: string[];
untracked_captured: string[];
sensitive_excluded: string[];
ignored_not_transferred_count: number;
}
export interface VaultHandoffCaptureOptions {
/** The work tree (any directory inside it). */
dir: string;
env?: GitConfigDiscoveryEnv;
/**
* The outer commit's message (the Handoff Note). A plain string is used
* verbatim (the caller has already composed and secret-scanned it). A
* FUNCTION is called with the real capture figures right before the
* outer `commit-tree` — the one point at which a Handoff Note's
* `capture` block can be filled with true numbers, since the note (and
* the commit that carries it) cannot exist before those numbers are
* known. Throwing inside it (the client-side secret scan) aborts the
* capture before the outer commit is written; the two throwaway parent
* commits (index/untracked) are already unreachable garbage at that
* point, exactly as if the capture had never run.
*/
message: string | ((stats: VaultHandoffCaptureStats) => string);
now?: () => Date;
/** Re-admit named untracked paths the sensitive denylist would otherwise exclude. */
includeSensitive?: string[];
}
/**
* A stash-shaped checkpoint (design D1): one synthetic commit, ALWAYS
* created (a clean tree still produces it — there is one shape, one code
* path). `oid`'s tree is the worktree state of TRACKED paths only; its
* three parents are `base_head_oid` (parent 1), `index_commit_oid` (parent
* 2, tree = the real index), and `untracked_commit_oid` (parent 3,
* parentless, tree = the admitted untracked set — the EMPTY tree when
* nothing untracked survives the denylist). Restore is `git clone` at
* `base_head_oid` then `git stash apply --index <oid>`.
*/
export interface VaultHandoffSnapshot {
oid: string;
tree_oid: string;
base_head_oid: string;
index_commit_oid: string;
untracked_commit_oid: string;
head: VaultHeadTarget;
top_level: string;
/** Tracked paths whose on-disk content differs from `base_head_oid`'s tree (staged, unstaged, or both) and are present in `tree_oid`. */
modified_captured: string[];
/** Untracked-not-ignored paths admitted into `untracked_commit_oid`'s tree (denylist already applied). */
untracked_captured: string[];
/** Untracked-not-ignored paths excluded by the sensitive denylist (before any `includeSensitive` re-admission). */
sensitive_excluded: string[];
/** Ignored (gitignore/exclude) files present on disk — never transferred, counted only. */
ignored_not_transferred_count: number;
captured: VaultCapturedFile[];
captured_digest: string;
global_excludes_path: string | null;
}
/**
* 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 declare function captureHandoffSnapshot(options: VaultHandoffCaptureOptions): Promise<VaultHandoffSnapshot>;
/**
* 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 declare function materializeSnapshot(repoDir: string, oid: string, targetDir?: string): Promise<string>;
/** `snapshot_oid_hmac` = HMAC(K_digest("snapshot_oid"), JCS({oid, format:"sha1"})) — the keyed commitment the platform compares without ever seeing the oid. */
export declare function snapshotCommitment(kRepo: Uint8Array, repoId: string, epoch: string, oid: string): string;
/** The client-side `vault_commit` line — printed ALWAYS; commit ids never reach the platform. */
export declare function vaultCommitLine(snapshot: Pick<VaultSnapshot, "oid" | "kind">): string;
/** Object sizes for a set of oids (`cat-file --batch-check`), for the per-object cap on tracked history. */
export declare function findOversizeObjects(repoDir: string, tips: string[], cap?: number): Promise<Array<{
oid: string;
size_bytes: number;
}>>;
/**
* 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 declare function isAncestor(repoDir: string, ancestor: string, descendant: string): Promise<boolean>;
/** Does the object exist locally? */
export declare function hasObject(repoDir: string, oid: string): Promise<boolean>;
/**
* 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 declare function hasObjects(repoDir: string, oids: string[]): Promise<Set<string>>;
//# sourceMappingURL=vault-snapshot.d.ts.map