UNPKG

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.

218 lines • 11.4 kB
/** * vault — handoff restore (kygit-handoff design D1/D10). * * Restore is porcelain git, in two stages that deliberately run through * DIFFERENT runners: * * 1. `git clone <remote-url> <dir>` and, when needed, one * `git fetch origin '+refs/run402/*:refs/run402/*'` — REAL network * operations (the vault is reached through the `git-remote-run402` * remote helper), so they must NOT run under `hardenedGit`'s * `-c protocol.allow=never` (that flag exists to keep LOCAL plumbing * calls — hash-object, commit-tree, write-tree — from ever touching * the network by accident; these are the only calls in this module * that are SUPPOSED to). `--no-checkout` so nothing in the target * directory's initial default-branch checkout can fire a * template-installed hook before step 2 neutralizes hooks. * 2. Every LOCAL-only step after that (checkout the base, apply the * stash-shaped commit) runs through {@link hardenedGit} exactly like * every other vault plumbing call — hooks, fsmonitor, and replace * refs all disabled, argv-only, no shell. * * The handoff commit is a `retention root` (design D6), and * `restoreObjectsInto` — what the remote helper's `fetch` verb runs — * materializes the vault's FULL retained object set, not just * ref-reachable history. But `git clone` fetches only `refs/heads/*` and * tags: on a vault whose history lives only on `refs/run402/deploys/latest` * (populated by `run402 up`, no branch ever pushed) clone never calls the * helper's `fetch` verb and the clone is empty. {@link ensureCheckpointObjects} * closes that gap with one fetch of the protocol refs. */ import { execFile } from "node:child_process"; import { mkdtempSync, existsSync, readFileSync, appendFileSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { LocalError } from "../errors.js"; import { hardenedGit, hardenedGitEnv, run402PassthroughEnv } from "./vault-snapshot.js"; function fail(code, message, context, details) { throw new LocalError(message, context, { code, details }); } let emptyCloneHooksDir = null; function cloneHooksDir() { emptyCloneHooksDir ??= mkdtempSync(join(tmpdir(), "run402-vault-resume-clone-nohooks-")); return emptyCloneHooksDir; } /** * Run one protocol-permitting git command (hooks and terminal prompts still * neutralized) and reject with `code` on failure. */ function networkGit(argv, cwd, failure) { const { code, context, details } = failure; return new Promise((resolvePromise, reject) => { execFile("git", ["--no-replace-objects", "-c", `core.hooksPath=${cloneHooksDir()}`, ...argv], // The remote helper this command spawns authenticates as THIS session: pass // the RUN402_* selection through (config dir, wallet, api base, trace). // In-process helper, never the resident daemon: the keystore it // must read was written moments ago by the same resume, and a daemon // that predates that write serves the vault from what it loaded then // (live: a post-rotation resume opened only the newest epoch and died // VAULT_EPOCH_NOT_OPENABLE at generation 1). A restore gains nothing // from a daemon; every later fetch/push in the restored checkout does. { cwd, env: { ...hardenedGitEnv(run402PassthroughEnv()), RUN402_DAEMON: "0" }, encoding: "buffer", maxBuffer: 1024 * 1024 * 1024, windowsHide: true }, (error, _stdout, stderr) => { if (error) { const stderrText = Buffer.from(stderr).toString("utf8"); reject(new LocalError(`git ${argv.filter((a) => a !== "--").join(" ")} failed: ${stderrText.trim().slice(0, 1000) || error.message}`, context, { code, details: { ...details, stderr: stderrText.slice(0, 2000) } })); return; } resolvePromise(); }); }); } /** * `git clone --no-checkout <remoteUrl> <targetDir>`. `targetDir` must not * already exist (git's own clone precondition). */ export function cloneVaultRemote(remoteUrl, targetDir) { return networkGit(["clone", "--no-checkout", "--", remoteUrl, targetDir], tmpdir(), { code: "HANDOFF_CLONE_FAILED", context: "cloning the vault for resume", details: { remote_url: remoteUrl, target_dir: targetDir } }); } /** * Make sure the checkpoint commit `oid` is in `dir`'s object database. A * vault with no branch heads clones empty (see the module comment), so when * the commit is absent this fetches the protocol refs from `origin` — the * helper's `fetch` verb restores the full retained set, checkpoint and its * parents included. No-op when the commit is already present. */ export async function ensureCheckpointObjects(dir, oid) { const present = async () => (await hardenedGit(dir, ["cat-file", "-e", `${oid}^{commit}`], { okStatuses: [1, 128] })).status === 0; if (await present()) return; await networkGit(["fetch", "--no-tags", "origin", "+refs/run402/*:refs/run402/*"], dir, { code: "HANDOFF_CHECKPOINT_FETCH_FAILED", context: "fetching the handoff checkpoint", details: { dir, stash_oid: oid } }); if (!(await present())) { throw new LocalError(`the checkpoint commit ${oid} is not in the vault's retained objects`, "fetching the handoff checkpoint", { code: "HANDOFF_CHECKPOINT_MISSING", details: { stash_oid: oid } }); } } function sanitizeBranchName(name) { return name && /^[A-Za-z0-9._/-]+$/.test(name) && !name.startsWith("-") ? name : null; } /** * Check out the base, land on a real branch (never left detached), then * `git stash apply --index <stash_oid>` — staged, unstaged, deleted, and * untracked changes come back distinctly (verified against real git 2.43 * plumbing during design). Every step here is LOCAL — no network — so it * runs fully hardened. */ export async function applyHandoffCheckpoint(options) { const { dir } = options; let baseHeadOid = options.base_head_oid ?? null; if (!baseHeadOid) { try { baseHeadOid = (await hardenedGit(dir, ["rev-parse", "--verify", `${options.stash_oid}^1`])).text().trim(); } catch (e) { fail("HANDOFF_RESTORE_APPLY_FAILED", `could not read the base commit (first parent) off ${options.stash_oid} — it may not be present in the clone yet`, "restoring the handoff checkpoint", { stash_oid: options.stash_oid, cause: e instanceof Error ? e.message : String(e) }); } } let branch = sanitizeBranchName(options.branch_hint); if (!branch) { // The clone's HEAD symref names the vault's default branch only when it // exists. An empty clone points HEAD at the unborn `init.defaultBranch` // (often `master`), which says nothing about the vault. try { const symref = sanitizeBranchName((await hardenedGit(dir, ["symbolic-ref", "-q", "--short", "HEAD"], { okStatuses: [1] })).text().trim()); const born = symref ? (await hardenedGit(dir, ["rev-parse", "-q", "--verify", `refs/heads/${symref}`], { okStatuses: [1] })).status === 0 : false; branch = born ? symref : null; } catch { branch = null; } } branch ??= "main"; await hardenedGit(dir, ["checkout", "-q", baseHeadOid]); await hardenedGit(dir, ["checkout", "-q", "-B", branch]); try { await hardenedGit(dir, ["stash", "apply", "--index", options.stash_oid]); } catch (e) { fail("HANDOFF_RESTORE_APPLY_FAILED", `\`git stash apply --index\` failed to reconstruct the handed-off state: ${e instanceof Error ? e.message : String(e)}`, "restoring the handoff checkpoint", { base_head_oid: baseHeadOid, stash_oid: options.stash_oid, cause: e instanceof Error ? e.message : String(e) }); } return { branch, base_head_oid: baseHeadOid, stash_oid: options.stash_oid }; } /** * `--to <dir>` wins; otherwise the vault's address-form name (`org/name` → * `name`) when the redeem response carried one, otherwise the vault id. * Always absolute — `cloneVaultRemote` runs in `tmpdir()`, so a * relative destination must be resolved against the CALLER's cwd first. */ export function resolveResumeTargetDir(to, address, vaultId) { if (to) return isAbsolute(to) ? to : resolve(process.cwd(), to); const name = address ? address.split("/").pop() : null; return resolve(process.cwd(), name || vaultId); } /** The commit message (the Handoff Note, verbatim) — `null` when `oid` cannot be read (e.g. not yet present). */ export async function readGitCommitMessage(dir, oid) { try { return (await hardenedGit(dir, ["log", "-1", "--format=%B", oid])).text().replace(/\n$/, ""); } catch { return null; } } /** * `dir`'s real git directory (`git rev-parse --git-dir`, resolved to an * absolute path against `dir` when the answer comes back relative) — the * correct place to write LOCAL, never-committed exclusions even from a * linked worktree, where `.git` is a file rather than a directory. */ async function resolveGitDir(dir) { const raw = (await hardenedGit(dir, ["rev-parse", "--git-dir"])).text().trim(); return isAbsolute(raw) ? raw : resolve(dir, raw); } /** * Add `.run402/` to `dir`'s LOCAL `.git/info/exclude` — never `.gitignore` * (kygit-invite design D5): the ignore file is part of the captured tree * and touching it would break exact-state on the first `git status` after * `join`/`resume`. `.git/info/exclude` is local-only, per-clone git state, * exactly like the id-pins in `vault-address.ts`. Idempotent — a second * call on an already-excluding checkout is a no-op. Used by BOTH `join` and * `resume` (restore is kind-agnostic, kygit-invite design D5). */ export async function excludeMessagingCacheFromGit(dir) { const gitDir = await resolveGitDir(dir); const excludePath = join(gitDir, "info", "exclude"); let existing = ""; try { existing = readFileSync(excludePath, "utf-8"); } catch { existing = ""; } const lines = existing.split("\n"); if (lines.some((l) => l.trim() === ".run402/")) return; // already excluded mkdirSync(dirname(excludePath), { recursive: true }); const needsLeadingNewline = existing.length > 0 && !existing.endsWith("\n"); appendFileSync(excludePath, `${needsLeadingNewline ? "\n" : ""}.run402/\n`); } /** * Read-only counterpart to {@link excludeMessagingCacheFromGit} — whether * `dir`'s LOCAL `.git/info/exclude` already carries `.run402/` (kygit-invite * design D9's risk list: "`.git/info/exclude` is per-clone and silent" → * `repos view` reports `messaging_cache_excluded` so the state is visible). * `false` on any read failure (not a repository, no exclude file yet) — * never throws. */ export async function isMessagingCacheExcludedFromGit(dir) { try { const gitDir = await resolveGitDir(dir); const excludePath = join(gitDir, "info", "exclude"); if (!existsSync(excludePath)) return false; const existing = readFileSync(excludePath, "utf-8"); return existing.split("\n").some((l) => l.trim() === ".run402/"); } catch { return false; } } //# sourceMappingURL=vault-restore.js.map